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 <boudra.moha@gmail.com>
This commit is contained in:
Karan Sharma
2026-09-08 00:38:50 +05:30
committed by GitHub
parent c43df5d4c3
commit a7a708bec9
4 changed files with 222 additions and 35 deletions
+11 -5
View File
@@ -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.
@@ -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 });
}
},
@@ -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");
@@ -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<number>((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<void> };
setup(context: {
event: { subscribe(options: { signal: AbortSignal }): AsyncIterable<OpenCodeEvent> };
}): () => 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 };
}