mirror of
https://github.com/getpaseo/paseo.git
synced 2026-09-14 20:36:44 +08:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user