diff --git a/.github/workflows/opencode-bridge.yml b/.github/workflows/opencode-bridge.yml new file mode 100644 index 000000000..10865ee13 --- /dev/null +++ b/.github/workflows/opencode-bridge.yml @@ -0,0 +1,42 @@ +name: OpenCode notification bridge + +on: + push: + paths: + - nebula_app/res/hooks/opencode.js + - scripts/tests/opencode-notification-bridge.test.mjs + - .github/workflows/opencode-bridge.yml + pull_request: + paths: + - nebula_app/res/hooks/opencode.js + - scripts/tests/opencode-notification-bridge.test.mjs + - .github/workflows/opencode-bridge.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + bridge: + name: OpenCode bridge (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-14] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: oven-sh/setup-bun@v2 + with: + # Match the runtime used by OpenCode v2.0.18. + bun-version: '1.4.2' + - name: Node bridge regression tests + run: node --test scripts/tests/opencode-notification-bridge.test.mjs + - name: Bun bridge regression tests + run: bun test scripts/tests/opencode-notification-bridge.test.mjs diff --git a/architecture/notes/nebula_app/ai_hook/2026-09-28-opencode-v2-plugin-lifecycle.md b/architecture/notes/nebula_app/ai_hook/2026-09-28-opencode-v2-plugin-lifecycle.md new file mode 100644 index 000000000..94d6ea9eb --- /dev/null +++ b/architecture/notes/nebula_app/ai_hook/2026-09-28-opencode-v2-plugin-lifecycle.md @@ -0,0 +1,63 @@ +# OpenCode V2 notification lifecycle + +## Status + +Proposed in PR #298; maintainer review pending. + +## Context + +The generated OpenCode hook must keep the existing V1 server entrypoint while +supporting V2's default plugin definition and async event subscription. Both paths +must feed the same Pebrel notification reducer and helper protocol. A V2 plugin +may be unloaded while its event stream or helper delivery is still pending. + +## Evidence + +- OpenCode v2.0.18 validates a default plugin definition with an `id` and an + `effect` or `setup` entrypoint: [loader](https://github.com/anomalyco/opencode/blob/v2.0.18/packages/core/src/plugin/module.ts). +- V2's Promise plugin event domain reuses the generated client Event API. In + v2.0.18, `event.subscribe` takes one optional request-options argument; it has + no query argument: [plugin event type](https://github.com/anomalyco/opencode/blob/v2.0.18/packages/plugin/src/promise/event.ts), + [generated event client](https://github.com/anomalyco/opencode/blob/v2.0.18/packages/client/src/promise/generated/client.ts#L1660). +- The bridge's regression suite exercises the installed ESM export, V1 and V2 + event mapping, cleanup, and helper argument delivery. + +## Decision + +Keep one default object with `id`, the V1 `server` entrypoint, and the V2 `setup` +entrypoint. `setup` subscribes with +`ctx.event.subscribe({ signal: controller.signal })`, translates only the required +V2 lifecycle events, and forwards them through the existing reducer. Its cleanup +aborts the subscription and helper process and waits for queued delivery to end. + +## Rejected alternatives + +- Separate V1 and V2 hook files would duplicate lifecycle mapping and risk two + active bridge instances. +- Passing a query and request options separately does not match the pinned V2 + event API; other generated client operations use that two-argument shape, but + `event.subscribe` does not. +- Replacing the reducer with V2-specific notification state would create a + second authority for session lifecycle and ordering. + +## Consequences + +V1 and V2 remain adapters over the same notification payload and process helper. +V2 cleanup owns both the stream and any pending helper work. When OpenCode changes +its plugin export or event schema, the generated hook and focused bridge workflow +must be checked against that release before widening the supported version range. + +## Validation + +The Node/Bun workflow runs the focused bridge suite on Linux, Windows, and macOS. +The suite covers the installed module and cleanup contract; it does not claim a +full OpenCode UI or SSH end-to-end run. + +## Supersedes + +None. + +## Revisit when + +Revisit if V1 support is dropped, the pinned V2 event API changes, or the Pebrel +helper protocol no longer owns notification delivery and ordering. diff --git a/nebula_app/res/hooks/opencode.js b/nebula_app/res/hooks/opencode.js index 12f40a4ef..1170f9e8f 100644 --- a/nebula_app/res/hooks/opencode.js +++ b/nebula_app/res/hooks/opencode.js @@ -1,9 +1,12 @@ // Pebrel ↔ opencode bridge — AUTO-GENERATED by Pebrel, do not edit. // Forwards turn lifecycle to Pebrel's sidebar (icon + spinner + toasts). // Inert outside Pebrel (no PEBREL_HOOK_EXE or legacy alias in the environment). -export const PebrelNotify = async ({ $, directory, worktree }) => { +import { execFile } from "node:child_process" + +const PebrelNotify = async ({ directory, worktree }) => { const hook = process.env.PEBREL_HOOK_EXE ?? process.env.NEBULA_HOOK_EXE if (!hook) return {} + const controller = new AbortController() // Provider events interleave sessions. Permission waits keep a turn active; // a child session's idle must not consume the primary session's completion. const turns = new Map() @@ -30,13 +33,23 @@ export const PebrelNotify = async ({ $, directory, worktree }) => { obj.bridge_sequence = bridgeSequence if (!obj.event_id) obj.event_id = `${sessionId || "pending"}:${bridgeSequence}` const payload = JSON.stringify(obj) - // 看门狗:一个卡住的 helper 会把整条链堵死,包括最后那个 idle——badge - // 就永远停在转圈上。超时后不再等它,直接放行下一次发送。 + // Run without a shell and bound a stuck helper so it cannot block idle. sendChain = sendChain - .then(() => Promise.race([ - $`${hook} opencode ${payload}`.quiet().nothrow(), - new Promise((resolve) => setTimeout(resolve, WATCHDOG_MS)), - ])) + .then(() => { + if (controller.signal.aborted) return + let watchdog + return Promise.race([ + new Promise((resolve) => { + execFile(hook, ["opencode", payload], { + timeout: WATCHDOG_MS, + killSignal: "SIGKILL", + windowsHide: true, + signal: controller.signal, + }, () => resolve()) + }), + new Promise((resolve) => { watchdog = setTimeout(resolve, WATCHDOG_MS) }), + ]).finally(() => clearTimeout(watchdog)) + }) .catch(() => {}) } catch (_) {} @@ -54,12 +67,16 @@ export const PebrelNotify = async ({ $, directory, worktree }) => { kind: "attention", event_id: requestId ? `${sessionId || "pending"}:permission:${requestId}` : undefined, message: request.title || request.message || request.reason || request.description || "", - permission_or_tool: permissionType || request.type || request.tool || "permission", + permission_or_tool: permissionType || request.action || request.type || request.tool || "permission", cwd: request.directory || request.cwd || "", context: request, }) } return { + dispose: async () => { + controller.abort() + await sendChain + }, event: async ({ event }) => { const t = event && event.type const props = (event && event.properties) || {} @@ -92,8 +109,8 @@ export const PebrelNotify = async ({ $, directory, worktree }) => { } else if (t === "session.deleted") { turns.delete(sessionId || "pending") send({ kind: "session-end" }) - } - }, + } + }, // OpenCode exposes a named permission hook. Keeping // this separate callback is what guarantees delivery of the full request // context; merely looking for an event named permission.ask is insufficient. @@ -102,3 +119,55 @@ export const PebrelNotify = async ({ $, directory, worktree }) => { }, } } + +// The shared object entrypoint is supported by OpenCode >= 1.18.29 and V2. +// Keep only one export: V1 loaders otherwise initialize the bridge twice. +export default { + id: "pebrel", + server: PebrelNotify, + async setup(ctx) { + const bridge = await PebrelNotify(ctx.location) + if (!bridge.event) return + const controller = new AbortController() + const consume = (async () => { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (controller.signal.aborted) break + // OpenCode routes unlocated session events to their owning location. + // Reject explicitly foreign events without dropping those lifecycles. + if (event.location && event.location.directory !== ctx.location.directory) continue + const data = event.data + let type + let properties = data + switch (event.type) { + case "session.execution.started": + type = "message.updated" + properties = { info: { role: "user", sessionID: data.sessionID, id: event.id } } + break + case "session.execution.succeeded": + case "session.execution.failed": + case "session.execution.interrupted": + type = "session.idle" + break + case "permission.asked": + type = "permission.ask" + break + case "session.tool.success": + case "session.tool.failed": + type = "tool.execute.after" + break + case "session.deleted": + type = "session.deleted" + break + default: + continue + } + await bridge.event({ event: { type, properties } }) + } + })().catch(() => {}) // Notification failures must not fail the Agent. + return async () => { + controller.abort() + await bridge.dispose() + await consume + } + }, +} diff --git a/scripts/tests/opencode-notification-bridge.test.mjs b/scripts/tests/opencode-notification-bridge.test.mjs index e607f8ae0..32fc4fa56 100644 --- a/scripts/tests/opencode-notification-bridge.test.mjs +++ b/scripts/tests/opencode-notification-bridge.test.mjs @@ -1,25 +1,24 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { test } from "node:test"; import { runInNewContext } from "node:vm"; // Execute the installed source with a provider event bus and a fake helper. // The tests do not install plugins or contact a running Agent. const embedded = readFileSync(new URL("../../nebula_app/res/hooks/opencode.js", import.meta.url), "utf8"); -const code = embedded.replace("export const PebrelNotify", "const PebrelNotify"); +const code = embedded + .replace('import { execFile } from "node:child_process"', "") + .replace("export default", "const plugin ="); async function bridge(enabled = true) { const sent = []; - const shell = (_strings, _helper, payload) => ({ - quiet() { return this; }, - nothrow() { sent.push(JSON.parse(payload)); return Promise.resolve(); }, - }); - const handlers = await runInNewContext(`${code}\nPebrelNotify({ $, directory: '/project' });`, { - $: shell, + const handlers = await runInNewContext(`${code}\nplugin.server({ directory: '/project' });`, { + execFile(_helper, args, _options, done) { sent.push(JSON.parse(args[1])); done(null); }, process: { env: enabled ? { PEBREL_HOOK_EXE: "fake-helper" } : {} }, - // A successful fake helper always wins the watchdog race; do not create - // real timers for each synthetic event. - setTimeout: () => 0, + AbortController, setTimeout, clearTimeout, }); const flush = () => new Promise(setImmediate); return { @@ -74,3 +73,236 @@ test("the installed plugin is inert outside the terminal environment", async () assert.deepEqual(Object.keys(b.handlers), []); assert.deepEqual(b.sent, []); }); + +// Import the installed module as ESM: evaluating a stripped V1 function alone +// cannot catch the missing-default-export failure from OpenCode's V2 loader. +test("the installed ESM has one default definition for V1.18.29+ and V2", async () => { + const module = await import(`data:text/javascript;base64,${Buffer.from(embedded).toString("base64")}`); + assert.deepEqual(Object.keys(module), ["default"]); + assert.equal(module.default.id, "pebrel"); + assert.equal(typeof module.default.server, "function"); + assert.equal(typeof module.default.setup, "function"); +}); + +async function v2({ env = { PEBREL_HOOK_EXE: "fake helper" }, execute, timer = setTimeout } = {}) { + const sent = []; + const calls = []; + const plugin = runInNewContext(`${code}\nplugin;`, { + process: { env }, + AbortController, setTimeout: timer, clearTimeout, + execFile(helper, args, options, done) { + calls.push({ helper, args, options }); + sent.push(JSON.parse(args[1])); + if (execute) execute(options, done, calls.length); + else done(null); + }, + }); + const pending = []; + let wake = () => {}; + let failure; + let subscriptions = 0; + let closed = 0; + const cleanup = await plugin.setup({ + location: { directory: "/project" }, + event: { + async *subscribe({ signal }) { + subscriptions++; + const abort = () => wake(); + signal.addEventListener("abort", abort, { once: true }); + try { + while (!signal.aborted) { + if (failure) throw failure; + if (pending.length) yield pending.shift(); + else await new Promise(resolve => { wake = resolve; }); + } + } finally { + signal.removeEventListener("abort", abort); + closed++; + } + }, + }, + }); + let id = 0; + return { + sent, calls, cleanup, + get subscriptions() { return subscriptions; }, + get closed() { return closed; }, + async emit(type, data, extra = {}) { + pending.push({ id: `evt_${++id}`, type, data, location: { directory: "/project" }, ...extra }); + wake(); + await new Promise(setImmediate); + }, + async fail() { failure = new Error("subscription closed"); wake(); await new Promise(setImmediate); }, + }; +} + +test("V2 durable execution events preserve permission context, ordering and completion", async (t) => { + const b = await v2(); + t.after(b.cleanup); + await b.emit("session.execution.started", { sessionID: "main" }); + const request = { sessionID: "main", id: "per_1", action: "bash", resources: ["echo 'hello'"], message: "Run shell?" }; + await b.emit("permission.asked", request); + await b.emit("session.tool.success", { sessionID: "main" }); + await b.emit("session.execution.succeeded", { sessionID: "main" }); + await b.emit("session.execution.succeeded", { sessionID: "main" }); + assert.deepEqual(b.sent.map(e => e.kind), ["session-start", "prompt", "attention", "tool-complete", "done"]); + const attention = b.sent[2]; + assert.deepEqual(attention.context, request); + assert.equal(attention.permission_or_tool, "bash"); + assert.equal(attention.event_id, "main:permission:per_1"); + assert.equal(attention.message, "Run shell?"); + assert(b.sent.every(e => e.cwd === "/project" && e.session_id === "main")); + assert(b.sent.every((e, i) => !i || BigInt(e.bridge_sequence) > BigInt(b.sent[i - 1].bridge_sequence))); + assert(b.calls.every(c => c.helper === "fake helper" && c.args[0] === "opencode")); +}); + +test("V2 ignores other locations, startup idle and non-waiting permission events", async (t) => { + const b = await v2(); + t.after(b.cleanup); + await b.emit("session.execution.started", { sessionID: "other" }, { location: { directory: "/other" } }); + await b.emit("session.status", { sessionID: "main", status: { type: "idle" } }); + await b.emit("session.idle", { sessionID: "main" }); + await b.emit("permission.replied", { sessionID: "main", requestID: "per_1", reply: "once" }); + assert.deepEqual(b.sent, []); +}); + +test("V2 interleaved sessions and repeated starts do not lose either completion", async (t) => { + const b = await v2(); + t.after(b.cleanup); + await b.emit("session.execution.started", { sessionID: "main" }, { id: "evt_main" }); + await b.emit("session.execution.started", { sessionID: "main" }, { id: "evt_main" }); + await b.emit("session.execution.started", { sessionID: "child" }); + await b.emit("session.execution.succeeded", { sessionID: "child" }); + await b.emit("session.execution.succeeded", { sessionID: "main" }); + assert.deepEqual(b.sent.filter(e => e.kind === "prompt").map(e => e.session_id), ["main", "child"]); + assert.deepEqual(b.sent.filter(e => e.kind === "done").map(e => e.session_id), ["child", "main"]); +}); + +for (const terminal of ["failed", "interrupted"]) { + test(`V2 ${terminal} executions stop the spinner, and deletion ends the session`, async (t) => { + const b = await v2(); + t.after(b.cleanup); + await b.emit("session.execution.started", { sessionID: "main" }); + await b.emit("session.tool.failed", { sessionID: "main" }); + await b.emit(`session.execution.${terminal}`, { sessionID: "main" }); + await b.emit("session.deleted", { sessionID: "main" }); + assert.deepEqual(b.sent.map(e => e.kind), ["session-start", "prompt", "tool-complete", "done", "session-end"]); + }); +} + +test("V2 cleanup cancels the subscription, active helper and queued deliveries", async () => { + const b = await v2({ execute(options, done) { + options.signal.addEventListener("abort", () => done(new Error("aborted")), { once: true }); + } }); + await b.emit("session.execution.started", { sessionID: "main" }); + assert.equal(b.calls.length, 1); // The prompt waits behind session-start. + const options = b.calls[0].options; + assert.equal(options.timeout, 3000); + assert.equal(options.killSignal, "SIGKILL"); + assert.equal(options.windowsHide, true); + assert.equal(options.shell, undefined); + await b.cleanup(); + assert.equal(options.signal.aborted, true); + assert.equal(b.closed, 1); + assert.equal(b.calls.length, 1); + await b.emit("session.execution.succeeded", { sessionID: "main" }); + assert.equal(b.calls.length, 1); +}); + +test("helper failures do not poison the serialized queue or the Agent", async (t) => { + const b = await v2({ execute(_options, done, count) { + if (count === 1) throw new Error("spawn failed"); + done(count === 2 ? new Error("timed out") : null); + } }); + t.after(b.cleanup); + await b.emit("session.execution.started", { sessionID: "main" }); + await b.emit("session.execution.succeeded", { sessionID: "main" }); + assert.deepEqual(b.sent.map(e => e.kind), ["session-start", "prompt", "done"]); +}); + +test("V2 subscription rejection is handled and cleanup still completes", async () => { + const b = await v2(); + await b.fail(); + await b.cleanup(); + assert.equal(b.closed, 1); +}); + +test("V2 is inert outside Pebrel and retains the legacy helper environment alias", async (t) => { + const inert = await v2({ env: {} }); + assert.equal(inert.subscriptions, 0); + assert.equal(inert.cleanup, undefined); + const legacy = await v2({ env: { NEBULA_HOOK_EXE: "legacy helper" } }); + t.after(legacy.cleanup); + await legacy.emit("session.execution.started", { sessionID: "main" }); + assert(legacy.calls.every(c => c.helper === "legacy helper")); +}); + +// Bus.Subscribe routes these by session ownership without changing the public +// envelope (OpenCode v2.0.18 packages/core/src/bus.ts). +test("V2 unlocated durable session events still produce a complete turn", async (t) => { + const b = await v2(); + t.after(b.cleanup); + await b.emit("session.execution.started", { sessionID: "main" }, { location: undefined }); + await b.emit("session.execution.succeeded", { sessionID: "main" }, { location: undefined }); + assert.deepEqual(b.sent.map(e => e.kind), ["session-start", "prompt", "done"]); +}); + +test("the watchdog releases the queue even when a helper never calls back", async (t) => { + let release; + const b = await v2({ + execute(_options, done, count) { if (count > 1) done(null); }, + timer(resolve) { release = resolve; return 0; }, + }); + t.after(b.cleanup); + await b.emit("session.execution.started", { sessionID: "main" }); + assert.equal(b.sent.length, 1); + release(); + await b.emit("session.execution.succeeded", { sessionID: "main" }); + assert.deepEqual(b.sent.map(e => e.kind), ["session-start", "prompt", "done"]); +}); + +test("the installed module delivers literal arguments through a real helper process", (t) => { + const directory = mkdtempSync(join(tmpdir(), "pebrel hook ")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + // Node/Bun acts as the helper executable; its first argument is the opencode + // script. No shell, global environment mutation or running Agent is needed. + writeFileSync(join(directory, "opencode"), ` + const { appendFileSync } = require("node:fs"); + appendFileSync("delivered.jsonl", process.argv[2] + "\\n"); + console.log("helper output must remain quiet"); + `); + writeFileSync(join(directory, "worker.mjs"), ` + import assert from "node:assert/strict"; + import { existsSync, readFileSync } from "node:fs"; + import { setTimeout as delay } from "node:timers/promises"; + const { default: plugin } = await import(process.argv[2]); + const bridge = await plugin.server({ directory: process.cwd() }); + const message = 'space "quote" $HOME & ; | < >'; + try { + await bridge.event({ event: { type: "message.updated", properties: { + info: { role: "user", sessionID: "main", id: "user-1" } + } } }); + await bridge["permission.ask"]({ sessionID: "main", id: "per_1", permission: "bash", message }); + await bridge.event({ event: { type: "session.idle", properties: { sessionID: "main" } } }); + let messages = []; + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + if (existsSync("delivered.jsonl")) { + messages = readFileSync("delivered.jsonl", "utf8").trim().split("\\n").map(JSON.parse); + if (messages.length === 4) break; + } + await delay(10); + } + assert.deepEqual(messages.map(e => e.kind), ["session-start", "prompt", "attention", "done"]); + assert.equal(messages[2].message, message); + } finally { await bridge.dispose(); } + `); + const stdout = execFileSync(process.execPath, [join(directory, "worker.mjs"), + new URL("../../nebula_app/res/hooks/opencode.js", import.meta.url).href], { + cwd: directory, + env: { ...process.env, PEBREL_HOOK_EXE: process.execPath }, + timeout: 15000, + encoding: "utf8", + }); + assert.equal(stdout, ""); +});