From a7a708bec99e935ee4b8c6f7314a4b9a9984cfa6 Mon Sep 17 00:00:00 2001 From: Karan Sharma Date: Tue, 8 Sep 2026 00:38:50 +0530 Subject: [PATCH] fix(server): support OpenCode 2 in terminal activity plugin (#4300) * fix(server): support OpenCode 2 in terminal activity plugin OpenCode 2 rejects V1 hook-object plugins, so the installed paseo-terminal-activity.js failed to load on every OpenCode 2 session. Ship one definition object that serves both generations: OpenCode 2 loads id + setup() and ignores the 1.x server entrypoint, OpenCode 1 loads the same object through server(). * fix(server): preserve terminal activity across plugin generations * test(server): drain persistence before reload test cleanup --------- Co-authored-by: Mohamed Boudra --- docs/terminal-activity.md | 16 +- .../src/server/agent/agent-manager.test.ts | 2 + .../agent-hooks/opencode/opencode-plugin.ts | 73 ++++++-- .../agent-hooks/opencode/opencode.test.ts | 166 ++++++++++++++++-- 4 files changed, 222 insertions(+), 35 deletions(-) diff --git a/docs/terminal-activity.md b/docs/terminal-activity.md index fd2df0e95f..42826e5f49 100644 --- a/docs/terminal-activity.md +++ b/docs/terminal-activity.md @@ -65,12 +65,18 @@ Codex hook mapping: - `PermissionRequest` → `needs-input` - `Stop` → `idle` -OpenCode uses a server plugin instead of command hooks. The plugin listens to OpenCode bus events and emits these Paseo hook events: +OpenCode uses a server plugin instead of command hooks. Both generations discover the same global plugin file. Their loaders select separate entrypoints: OpenCode 1 calls `server()` with `{ type, properties }` bus events; OpenCode 2 calls `setup()` and subscribes to decoded `{ type, data }` events. Do not share their status mapping: V1 publishes `session.status` snapshots, while V2 publishes `session.execution.*` transitions. -- `session.status` with `busy` or `retry` → `running` -- `session.status` with `idle` → `idle` -- `permission.asked` → `needs-input` -- `permission.replied` → `running` +| OpenCode event | Generation | Activity | +| ----------------------------------------------------------- | ---------- | ----------- | +| `session.status` with `busy` or `retry` | 1 | running | +| `session.status` with `idle` | 1 | idle | +| `session.execution.started` | 2 | running | +| `session.execution.succeeded`, `.failed`, or `.interrupted` | 2 | idle | +| `permission.asked` | Both | needs-input | +| `permission.replied` | Both | running | + +The plugin translates both event contracts into the existing Paseo hook events. OpenCode 2 disposes its event subscription when the plugin unloads. The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running` → `state: working`, `idle` → `state: idle`, and `needs-input` → `state: idle` with `attentionReason: needs_input`. A `working` → `idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index bc38784e48..ccc9467a25 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2487,6 +2487,8 @@ test.each(["hang", "reject"])( expect(client.resumeSessionCalls).toBe(0); expect(manager.getAgent(snapshot.id)?.session).toBe(client.firstSession); } finally { + await manager.flush(); + await storage.flush(); rmSync(workdir, { recursive: true, force: true }); } }, diff --git a/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts b/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts index 5fce566cd5..1a76e17a44 100644 --- a/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts +++ b/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts @@ -1,37 +1,72 @@ import type { AgentHookPluginFileInstallStrategy } from "../agent-hook-installer.js"; +// Both generations discover the same file. Their loaders select the entrypoint: +// OpenCode 1 calls server(); OpenCode 2 calls setup(). Keep their event contracts +// separate: V1 publishes status snapshots, V2 publishes execution transitions. export const OPENCODE_PLUGIN_SOURCE = [ - "const STATUS_EVENTS = {", + "const V1_STATUS_EVENTS = {", ' busy: "session.status.busy",', ' retry: "session.status.retry",', ' idle: "session.status.idle",', "};", "", - "function paseoEventFor(event) {", - ' if (event?.type === "permission.asked") return "permission.asked";', - ' if (event?.type === "permission.replied") return "permission.replied";', - ' if (event?.type !== "session.status") return null;', - " return STATUS_EVENTS[event?.properties?.status?.type] ?? null;", + "const V2_EVENTS = {", + ' "session.execution.started": "session.status.busy",', + ' "session.execution.succeeded": "session.status.idle",', + ' "session.execution.failed": "session.status.idle",', + ' "session.execution.interrupted": "session.status.idle",', + ' "permission.asked": "permission.asked",', + ' "permission.replied": "permission.replied",', + "};", + "", + "function paseoEventForV1(event) {", + " const type = event.type;", + ' if (type === "permission.asked") return "permission.asked";', + ' if (type === "permission.replied") return "permission.replied";', + ' if (type !== "session.status") return null;', + " return V1_STATUS_EVENTS[event.properties.status.type] ?? null;", "}", "", + // CLI processes can finish out of order, especially for immediate failures. + // Both entrypoints enqueue reports so an older busy report cannot overwrite idle. + "let pendingHook = Promise.resolve();", + "", "function runPaseoHook(event) {", " if (!process.env.PASEO_TERMINAL_ID) return;", - " try {", - ' const child = Bun.spawn(["paseo", "hooks", "opencode", event], {', - ' stdin: "ignore",', - ' stdout: "ignore",', - ' stderr: "ignore",', - " });", - " void child.exited.catch(() => {});", - " } catch {}", + " pendingHook = pendingHook.then(async () => {", + " try {", + ' const child = Bun.spawn(["paseo", "hooks", "opencode", event], {', + ' stdin: "ignore",', + ' stdout: "ignore",', + ' stderr: "ignore",', + " });", + " await child.exited;", + " } catch {}", + " });", + " return pendingHook;", "}", "", - "export default async () => ({", - " event: async ({ event }) => {", - " const paseoEvent = paseoEventFor(event);", - " if (paseoEvent) runPaseoHook(paseoEvent);", + "export default {", + ' id: "paseo-terminal-activity",', + " server() {", + " return {", + " event: async ({ event }) => {", + " const paseoEvent = paseoEventForV1(event);", + " if (paseoEvent) await runPaseoHook(paseoEvent);", + " },", + " };", " },", - "});", + " setup(ctx) {", + " const controller = new AbortController();", + " void (async () => {", + " for await (const event of ctx.event.subscribe({ signal: controller.signal })) {", + " const paseoEvent = V2_EVENTS[event.type];", + " if (paseoEvent) await runPaseoHook(paseoEvent);", + " }", + " })().catch(() => {});", + " return () => controller.abort();", + " },", + "};", "", ].join("\n"); diff --git a/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts b/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts index 6ba28b22ee..7379e5e9d3 100644 --- a/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts +++ b/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts @@ -1,6 +1,9 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { addAbortSignal, PassThrough, Readable } from "node:stream"; +import { finished } from "node:stream/promises"; +import { runInNewContext } from "node:vm"; import { afterEach, describe, expect, it } from "vitest"; import { agentHooksAreInstalled, @@ -40,18 +43,123 @@ describe("OpenCode terminal agent hooks", () => { expect(agentHooksAreInstalled(opencodeAgentHookProvider, { configDir })).toBe(true); }); - it("writes the plugin that maps OpenCode bus events to paseo hook events", () => { - const configDir = createTempDir("paseo-opencode-config-source-"); - const { configPath } = installAgentHooks(opencodeAgentHookProvider, { configDir }); - const source = readFileSync(configPath, "utf8"); + it.each(["succeeded", "failed", "interrupted"])( + "reports OpenCode 2 execution start and %s as running then idle", + async (outcome) => { + const { plugin, commands } = loadInstalledPlugin(); + const events = Readable.from([ + { type: "session.execution.started", data: { sessionID: "session-1" } }, + { type: `session.execution.${outcome}`, data: { sessionID: "session-1" } }, + ]); - expect(source).toContain('busy: "session.status.busy"'); - expect(source).toContain('retry: "session.status.retry"'); - expect(source).toContain('idle: "session.status.idle"'); - expect(source).toContain('event?.type === "permission.asked"'); - expect(source).toContain('event?.type === "permission.replied"'); - expect(source).toContain('Bun.spawn(["paseo", "hooks", "opencode", event]'); - expect(source).toContain("PASEO_TERMINAL_ID"); + const dispose = plugin.setup({ event: { subscribe: () => events } }); + await finished(events); + dispose(); + + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "session.status.busy"], + ["paseo", "hooks", "opencode", "session.status.idle"], + ]); + }, + ); + + it("reports OpenCode 1 status and permission events through server()", async () => { + const { plugin, commands } = loadInstalledPlugin(); + const hooks = plugin.server(); + for (const event of [ + { type: "session.status", properties: { status: { type: "busy" } } }, + { type: "permission.asked" }, + { type: "permission.replied" }, + { type: "session.status", properties: { status: { type: "retry" } } }, + { type: "session.status", properties: { status: { type: "idle" } } }, + { type: "session.execution.started", data: { sessionID: "session-1" } }, + { type: "message.updated" }, + ]) { + await hooks.event({ event }); + } + + expect(plugin.id).toBe("paseo-terminal-activity"); + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "session.status.busy"], + ["paseo", "hooks", "opencode", "permission.asked"], + ["paseo", "hooks", "opencode", "permission.replied"], + ["paseo", "hooks", "opencode", "session.status.retry"], + ["paseo", "hooks", "opencode", "session.status.idle"], + ]); + }); + + it("reports OpenCode 2 permission events and ignores V1 status snapshots", async () => { + const { plugin, commands } = loadInstalledPlugin(); + const events = Readable.from([ + { type: "permission.asked", data: { sessionID: "session-1" } }, + { type: "permission.replied", data: { sessionID: "session-1" } }, + { type: "session.status", properties: { status: { type: "busy" } } }, + { type: "session.text.delta", data: { sessionID: "session-1" } }, + ]); + + const dispose = plugin.setup({ event: { subscribe: () => events } }); + await finished(events); + dispose(); + + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "permission.asked"], + ["paseo", "hooks", "opencode", "permission.replied"], + ]); + }); + + it("preserves activity order when the host dispatches events concurrently", async () => { + let finishFirstHook = () => {}; + const firstExit = new Promise((resolve) => { + finishFirstHook = () => resolve(0); + }); + const { plugin, commands } = loadInstalledPlugin("terminal-1", firstExit); + const hooks = plugin.server(); + + const working = hooks.event({ + event: { type: "session.status", properties: { status: { type: "busy" } } }, + }); + const idle = hooks.event({ + event: { type: "session.status", properties: { status: { type: "idle" } } }, + }); + await Promise.resolve(); + expect(commands).toEqual([["paseo", "hooks", "opencode", "session.status.busy"]]); + + finishFirstHook(); + await Promise.all([working, idle]); + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "session.status.busy"], + ["paseo", "hooks", "opencode", "session.status.idle"], + ]); + }); + + it("stops the OpenCode 2 subscription when unloaded", async () => { + const { plugin, commands } = loadInstalledPlugin(); + const events = new PassThrough({ objectMode: true }); + const closed = finished(events); + const dispose = plugin.setup({ + event: { subscribe: ({ signal }) => addAbortSignal(signal, events) }, + }); + + dispose(); + + await expect(closed).rejects.toMatchObject({ name: "AbortError" }); + expect(commands).toEqual([]); + }); + + it("keeps both generations inert outside Paseo terminals", async () => { + const { plugin, commands } = loadInstalledPlugin(""); + await plugin.server().event({ + event: { type: "session.status", properties: { status: { type: "busy" } } }, + }); + const events = Readable.from([ + { type: "session.execution.started", data: { sessionID: "session-1" } }, + { type: "permission.asked", data: { sessionID: "session-1" } }, + ]); + const dispose = plugin.setup({ event: { subscribe: () => events } }); + await finished(events); + dispose(); + + expect(commands).toEqual([]); }); it("uninstalls the OpenCode plugin file", () => { @@ -121,3 +229,39 @@ describe("OpenCode terminal agent hooks", () => { ).resolves.toBe(state); }); }); + +interface OpenCodeEvent { + type: string; + properties?: { status: { type: string } }; + data?: { sessionID: string }; +} + +interface InstalledPlugin { + id: string; + server(): { event(input: { event: OpenCodeEvent }): Promise }; + setup(context: { + event: { subscribe(options: { signal: AbortSignal }): AsyncIterable }; + }): () => void; +} + +function loadInstalledPlugin(terminalId = "terminal-1", exited = Promise.resolve(0)) { + const configDir = createTempDir("paseo-opencode-runtime-"); + const { configPath } = installAgentHooks(opencodeAgentHookProvider, { configDir }); + const source = readFileSync(configPath, "utf8"); + const commands: string[][] = []; + // Supply the host runtime at the script boundary; execute the installed source. + const plugin: InstalledPlugin = runInNewContext( + source.replace("export default", "globalThis.plugin ="), + { + AbortController, + process: { env: { PASEO_TERMINAL_ID: terminalId } }, + Bun: { + spawn(command: string[]) { + commands.push(command); + return { exited }; + }, + }, + }, + ); + return { plugin, commands }; +}