diff --git a/.changeset/opencode-activity-hooks.md b/.changeset/opencode-activity-hooks.md new file mode 100644 index 0000000000..650081fc8d --- /dev/null +++ b/.changeset/opencode-activity-hooks.md @@ -0,0 +1,27 @@ +--- +"@aoagents/ao-plugin-agent-opencode": minor +--- + +Replace OpenCode terminal-regex activity detection with platform-event hooks. + +OpenCode exposes a plugin/event system (`.opencode/plugins/`) that streams 25+ +lifecycle events. Until now, AO inferred activity by regex-matching OpenCode's +rendered terminal output — and `waiting_input` had no authoritative source at +all, only a fragile prompt heuristic. + +This release pivots OpenCode to the same hook-driven model as Claude Code and +Codex: + +- `setupWorkspaceHooks` installs an auto-loaded activity plugin into the + workspace's `.opencode/plugins/` and excludes it from git (worktree-aware + `info/exclude`) so it never lands in the agent's PRs. +- The plugin maps `permission.asked` → `waiting_input`, `session.error` → + `blocked`, `session.idle` → `ready`, and tool/file/message events → `active`, + writing them to `.ao/activity.jsonl` with `source: "hook"`. It no-ops without + `AO_SESSION_ID` and honors `AO_OPENCODE_HOOK_ACTIVITY=0` as an opt-out. +- `getActivityState` now prefers fresh hook entries over the polled + `opencode session list` API for every state; the session-list API remains the + fallback when no hook entry exists. +- `recordActivity` is removed — the plugin is the sole JSONL writer, so + terminal-derived writes can no longer shadow authoritative hook events. The + terminal `detectActivity` classifier remains as the lifecycle's last resort. diff --git a/packages/plugins/agent-opencode/src/activity-plugin.integration.test.ts b/packages/plugins/agent-opencode/src/activity-plugin.integration.test.ts new file mode 100644 index 0000000000..39299285fa --- /dev/null +++ b/packages/plugins/agent-opencode/src/activity-plugin.integration.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, readFile, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { OPENCODE_ACTIVITY_PLUGIN } from "./activity-plugin.js"; + +/** + * Executes the real generated OpenCode activity plugin against synthetic + * events and asserts the JSONL it writes. This proves the event→state mapping + * and the env-based guards work, not just that the string contains markers. + */ + +interface OpenCodeEvent { + type: string; +} + +type PluginHooks = { + event?: (input: { event: OpenCodeEvent }) => Promise | void; +}; + +type PluginFactory = (ctx: { + directory?: string; + worktree?: string; +}) => Promise; + +let workDir: string; +let pluginUrl: string; +// Snapshot only the env keys these tests mutate, so we can restore them +// individually rather than reassigning process.env wholesale (which loses +// Node's special env getter/setter behavior). +const MUTATED_ENV_KEYS = ["AO_SESSION_ID", "AO_OPENCODE_HOOK_ACTIVITY"] as const; +const savedEnv = new Map(MUTATED_ENV_KEYS.map((k) => [k, process.env[k]] as const)); + +async function loadPlugin(): Promise { + // Write the generated plugin to a temp .mjs file and import it so we exercise + // the exact source AO ships, as ESM (matching opencode's Bun loader). + const pluginPath = join(workDir, "ao-activity.mjs"); + await writeFile(pluginPath, OPENCODE_ACTIVITY_PLUGIN, "utf8"); + // Cache-bust so each test gets a fresh module instance (dedup state resets). + pluginUrl = `${pathToFileURL(pluginPath).href}?t=${Date.now()}-${Math.random()}`; + const mod = (await import(pluginUrl)) as Record; + const factory = Object.values(mod).find((v) => typeof v === "function"); + if (!factory) throw new Error("plugin has no exported factory function"); + return factory; +} + +async function readEntries(): Promise>> { + const logPath = join(workDir, ".ao", "activity.jsonl"); + let raw: string; + try { + raw = await readFile(logPath, "utf8"); + } catch { + return []; + } + return raw + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as Record); +} + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "ao-oc-plugin-")); + await mkdir(workDir, { recursive: true }); + process.env["AO_SESSION_ID"] = "sess-xyz"; + delete process.env["AO_OPENCODE_HOOK_ACTIVITY"]; +}); + +afterEach(async () => { + for (const [key, value] of savedEnv) { + if (value === undefined) Reflect.deleteProperty(process.env, key); + else process.env[key] = value; + } + await rm(workDir, { recursive: true, force: true }); +}); + +describe("OpenCode activity plugin — event mapping", () => { + it("writes waiting_input on permission.asked", async () => { + const factory = await loadPlugin(); + const hooks = await factory({ directory: workDir }); + await hooks.event!({ event: { type: "permission.asked" } }); + + const entries = await readEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + state: "waiting_input", + source: "hook", + sessionId: "sess-xyz", + }); + }); + + it("writes blocked on session.error", async () => { + const factory = await loadPlugin(); + const hooks = await factory({ directory: workDir }); + await hooks.event!({ event: { type: "session.error" } }); + + const entries = await readEntries(); + expect(entries[0]).toMatchObject({ state: "blocked", source: "hook" }); + }); + + it("writes ready on session.idle (never idle — AO age-decay handles idle)", async () => { + const factory = await loadPlugin(); + const hooks = await factory({ directory: workDir }); + await hooks.event!({ event: { type: "session.idle" } }); + + const entries = await readEntries(); + expect(entries[0]).toMatchObject({ state: "ready", source: "hook" }); + }); + + it("writes active on tool execution", async () => { + const factory = await loadPlugin(); + const hooks = await factory({ directory: workDir }); + await hooks.event!({ event: { type: "tool.execute.before" } }); + + const entries = await readEntries(); + expect(entries[0]).toMatchObject({ state: "active", source: "hook" }); + }); + + it("ignores unrelated events", async () => { + const factory = await loadPlugin(); + const hooks = await factory({ directory: workDir }); + await hooks.event!({ event: { type: "lsp.updated" } }); + await hooks.event!({ event: { type: "todo.updated" } }); + + expect(await readEntries()).toHaveLength(0); + }); + + it("no-ops entirely when AO_SESSION_ID is unset (manual opencode runs don't bleed)", async () => { + delete process.env["AO_SESSION_ID"]; + const factory = await loadPlugin(); + const hooks = await factory({ directory: workDir }); + // event handler should be absent or a no-op + if (hooks.event) { + await hooks.event({ event: { type: "permission.asked" } }); + } + expect(await readEntries()).toHaveLength(0); + }); + + it("no-ops when AO_OPENCODE_HOOK_ACTIVITY=0 (opt-out)", async () => { + process.env["AO_OPENCODE_HOOK_ACTIVITY"] = "0"; + const factory = await loadPlugin(); + const hooks = await factory({ directory: workDir }); + if (hooks.event) { + await hooks.event({ event: { type: "permission.asked" } }); + } + expect(await readEntries()).toHaveLength(0); + }); + + it("deduplicates rapid active events but always writes actionable states", async () => { + const factory = await loadPlugin(); + const hooks = await factory({ directory: workDir }); + await hooks.event!({ event: { type: "tool.execute.before" } }); + await hooks.event!({ event: { type: "message.updated" } }); + await hooks.event!({ event: { type: "tool.execute.after" } }); + // actionable always writes through, even back-to-back + await hooks.event!({ event: { type: "permission.asked" } }); + + const entries = await readEntries(); + const active = entries.filter((e) => e.state === "active"); + const waiting = entries.filter((e) => e.state === "waiting_input"); + expect(active).toHaveLength(1); + expect(waiting).toHaveLength(1); + }); +}); diff --git a/packages/plugins/agent-opencode/src/activity-plugin.ts b/packages/plugins/agent-opencode/src/activity-plugin.ts new file mode 100644 index 0000000000..6f41066c4d --- /dev/null +++ b/packages/plugins/agent-opencode/src/activity-plugin.ts @@ -0,0 +1,133 @@ +/** + * OpenCode activity plugin install + the plugin source itself. + * + * OpenCode auto-loads any `.js`/`.ts` file under `.opencode/plugins/` at + * startup and invokes the exported factory with `{ directory, worktree, ... }`. + * AO installs a plugin there that subscribes to OpenCode's event stream and + * maps the relevant lifecycle events to AO activity states, replacing fragile + * terminal-regex inference. `permission.asked` is the only authoritative source + * of `waiting_input` OpenCode exposes. + */ +import { isWindows } from "@aoagents/ao-core"; +import { execFile } from "node:child_process"; +import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** Filename of the auto-loaded OpenCode plugin AO installs per workspace. */ +const OPENCODE_PLUGIN_FILENAME = "ao-activity.js"; + +/** Relative path used for the git-exclude entry that keeps the plugin out of PRs. */ +const OPENCODE_PLUGIN_EXCLUDE_PATH = `.opencode/plugins/${OPENCODE_PLUGIN_FILENAME}`; + +/** + * Source of the OpenCode plugin that writes authoritative activity events to + * `.ao/activity.jsonl` with `source: "hook"`. + * + * Guards mirror the Codex hook updater: it no-ops unless `AO_SESSION_ID` is set + * (so a human running `opencode` in the same worktree never writes AO entries) + * and honors `AO_OPENCODE_HOOK_ACTIVITY=0` as an opt-out. `idle` is never + * written — AO's age-decay derives idle from a stale `ready`/`active` entry. + */ +export const OPENCODE_ACTIVITY_PLUGIN = `// Agent Orchestrator activity plugin — auto-generated. Do not edit. +import { appendFile, mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; + +export const AoActivity = async ({ directory, worktree }) => { + const sessionId = process.env.AO_SESSION_ID; + if (!sessionId || process.env.AO_OPENCODE_HOOK_ACTIVITY === "0") return {}; + + const base = directory || worktree || process.cwd(); + const logPath = join(base, ".ao", "activity.jsonl"); + + let lastActiveWrite = 0; + + const write = async (state, trigger) => { + try { + await mkdir(dirname(logPath), { recursive: true }); + const entry = { ts: new Date().toISOString(), state, source: "hook", sessionId }; + if (trigger && (state === "waiting_input" || state === "blocked")) { + entry.trigger = trigger; + } + await appendFile(logPath, JSON.stringify(entry) + "\\n", "utf8"); + } catch { + // Best-effort: activity logging must never break the agent. + } + }; + + return { + event: async ({ event }) => { + const type = event && event.type; + switch (type) { + case "permission.asked": + return write("waiting_input", "permission.asked"); + case "session.error": + return write("blocked", "session.error"); + case "session.idle": + return write("ready", "session.idle"); + case "tool.execute.before": + case "tool.execute.after": + case "file.edited": + case "message.updated": + case "message.part.updated": { + // Coalesce high-frequency streaming events to bound JSONL growth. + const now = Date.now(); + if (now - lastActiveWrite < 5000) return; + lastActiveWrite = now; + return write("active"); + } + default: + return; + } + }, + }; +}; +`; + +/** + * Append a pattern to the workspace's git exclude file (worktree-aware via + * `git rev-parse --git-path`), idempotently. Best-effort: keeping the plugin + * out of the agent's PRs is a nicety, not a correctness requirement. + */ +async function addToGitExclude(workspacePath: string, pattern: string): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["-C", workspacePath, "rev-parse", "--git-path", "info/exclude"], + { + timeout: 10_000, + ...(isWindows() ? { shell: true, windowsHide: true } : {}), + }, + ); + const rel = stdout.trim(); + if (!rel) return; + const excludePath = isAbsolute(rel) ? rel : join(workspacePath, rel); + + let existing = ""; + try { + existing = await readFile(excludePath, "utf8"); + } catch { + // No exclude file yet — we'll create it below. + } + if (existing.split("\n").some((line) => line.trim() === pattern)) return; + + await mkdir(dirname(excludePath), { recursive: true }); + const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""; + await appendFile(excludePath, `${prefix}${pattern}\n`, "utf8"); + } catch { + // Best-effort only. + } +} + +/** + * Install the activity plugin into the workspace's `.opencode/plugins/` dir and + * exclude it from git so it never appears in the agent's PRs. + */ +export async function installOpenCodeActivityPlugin(workspacePath: string): Promise { + const pluginDir = join(workspacePath, ".opencode", "plugins"); + await mkdir(pluginDir, { recursive: true }); + await writeFile(join(pluginDir, OPENCODE_PLUGIN_FILENAME), OPENCODE_ACTIVITY_PLUGIN, "utf8"); + await addToGitExclude(workspacePath, OPENCODE_PLUGIN_EXCLUDE_PATH); +} diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts index 75ea49bca2..fa26916e45 100644 --- a/packages/plugins/agent-opencode/src/index.test.ts +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtemp, rm, readFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createActivitySignal, type Session, @@ -829,10 +832,114 @@ describe("getRestoreCommand", () => { describe("setupWorkspaceHooks", () => { const agent = create(); - it("is defined (delegates to shared setupPathWrapperWorkspace)", () => { + it("is defined", () => { expect(agent.setupWorkspaceHooks).toBeDefined(); expect(typeof agent.setupWorkspaceHooks).toBe("function"); }); + + it("installs the activity plugin into .opencode/plugins/", async () => { + const ws = await mkdtemp(join(tmpdir(), "ao-oc-hooks-")); + try { + // git rev-parse goes through the mocked execFile; point it at this ws. + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "git") return Promise.resolve({ stdout: ".git/info/exclude\n", stderr: "" }); + return Promise.reject(new Error("unexpected")); + }); + + await agent.setupWorkspaceHooks!(ws, { dataDir: ws }); + + const pluginPath = join(ws, ".opencode", "plugins", "ao-activity.js"); + const content = await readFile(pluginPath, "utf8"); + expect(content).toContain('source: "hook"'); + expect(content).toContain("permission.asked"); + expect(content).toContain("session.idle"); + expect(content).toContain("AO_SESSION_ID"); + expect(content).toContain("AO_OPENCODE_HOOK_ACTIVITY"); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); + + it("adds the plugin to git exclude so it stays out of the agent's PRs", async () => { + const ws = await mkdtemp(join(tmpdir(), "ao-oc-hooks-")); + try { + await mkdir(join(ws, ".git", "info"), { recursive: true }); + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "git") return Promise.resolve({ stdout: ".git/info/exclude\n", stderr: "" }); + return Promise.reject(new Error("unexpected")); + }); + + await agent.setupWorkspaceHooks!(ws, { dataDir: ws }); + + const exclude = await readFile(join(ws, ".git", "info", "exclude"), "utf8"); + expect(exclude).toContain(".opencode/plugins/ao-activity.js"); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); + + it("is idempotent — does not duplicate the git exclude entry", async () => { + const ws = await mkdtemp(join(tmpdir(), "ao-oc-hooks-")); + try { + await mkdir(join(ws, ".git", "info"), { recursive: true }); + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "git") return Promise.resolve({ stdout: ".git/info/exclude\n", stderr: "" }); + return Promise.reject(new Error("unexpected")); + }); + + await agent.setupWorkspaceHooks!(ws, { dataDir: ws }); + await agent.setupWorkspaceHooks!(ws, { dataDir: ws }); + + const exclude = await readFile(join(ws, ".git", "info", "exclude"), "utf8"); + const occurrences = exclude + .split("\n") + .filter((l) => l.trim() === ".opencode/plugins/ao-activity.js").length; + expect(occurrences).toBe(1); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); + + it("still installs the plugin when git exclude resolution fails (best-effort)", async () => { + const ws = await mkdtemp(join(tmpdir(), "ao-oc-hooks-")); + try { + mockExecFileAsync.mockRejectedValue(new Error("not a git repo")); + + await agent.setupWorkspaceHooks!(ws, { dataDir: ws }); + + const pluginPath = join(ws, ".opencode", "plugins", "ao-activity.js"); + const content = await readFile(pluginPath, "utf8"); + expect(content).toContain('source: "hook"'); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); + + // On Windows, git resolves `--git-path info/exclude` to an absolute path + // (e.g. C:/repo/.git/worktrees/branch/info/exclude). addToGitExclude must + // use it verbatim rather than re-joining it onto the workspace path. We use a + // real absolute path on the test OS so path.isAbsolute() agrees regardless of + // platform, and force the Windows code path via the isWindows() mock. + it("uses git's absolute exclude path verbatim on the Windows code path", async () => { + const ws = await mkdtemp(join(tmpdir(), "ao-oc-hooks-")); + const excludeDir = await mkdtemp(join(tmpdir(), "ao-oc-gitcommon-")); + const absExclude = join(excludeDir, "info-exclude"); + try { + mockIsWindows.mockReturnValueOnce(true); + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "git") return Promise.resolve({ stdout: `${absExclude}\n`, stderr: "" }); + return Promise.reject(new Error("unexpected")); + }); + + await agent.setupWorkspaceHooks!(ws, { dataDir: ws }); + + const exclude = await readFile(absExclude, "utf8"); + expect(exclude).toContain(".opencode/plugins/ao-activity.js"); + } finally { + await rm(ws, { recursive: true, force: true }); + await rm(excludeDir, { recursive: true, force: true }); + } + }); }); describe("postLaunchSetup", () => { @@ -973,28 +1080,17 @@ describe("invalid session ID rejection", () => { }); // ========================================================================= -// recordActivity +// recordActivity — intentionally removed (hooks are the JSONL writer) // ========================================================================= describe("recordActivity", () => { const agent = create(); - it("is defined", () => { - expect(agent.recordActivity).toBeDefined(); - }); - - it("does nothing when workspacePath is null", async () => { - await agent.recordActivity!(makeSession({ workspacePath: null }), "some output"); + it("is not implemented — the plugin's hooks write activity directly", () => { + // Mirrors Claude Code (#1941): terminal-derived recordActivity writes would + // only add stale duplicates that shadow authoritative hook events. + expect(agent.recordActivity).toBeUndefined(); expect(mockRecordTerminalActivity).not.toHaveBeenCalled(); }); - - it("delegates to recordTerminalActivity", async () => { - await agent.recordActivity!(makeSession(), "opencode is working"); - expect(mockRecordTerminalActivity).toHaveBeenCalledWith( - "/workspace/test", - "opencode is working", - expect.any(Function), - ); - }); }); // ========================================================================= @@ -1115,6 +1211,88 @@ describe("getActivityState with activity JSONL", () => { expect(result?.state).toBe("idle"); }); + it("prefers a fresh hook entry over the polled session-list API", async () => { + mockTmuxWithProcess("opencode"); + // Hook says ready (turn just finished); the polled API says active (stale). + mockReadLastActivityEntry.mockResolvedValueOnce({ + entry: { ts: new Date().toISOString(), state: "ready", source: "hook" }, + modifiedAt: new Date(), + }); + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") { + return Promise.resolve({ + stdout: " PID TT ARGS\n 789 ttys003 opencode\n", + stderr: "", + }); + } + if (cmd === "opencode") { + return Promise.resolve({ + stdout: JSON.stringify([ + { + id: "ses_abc123", + title: "AO:test-1", + updated: new Date(Date.now() - 5_000).toISOString(), + }, + ]), + stderr: "", + }); + } + return Promise.reject(new Error("unexpected")); + }); + + const result = await agent.getActivityState( + makeSession({ + runtimeHandle: makeTmuxHandle(), + metadata: { opencodeSessionId: "ses_abc123" }, + }), + 60_000, + ); + // Hook wins: ready, not the API's active. + expect(result?.state).toBe("ready"); + }); + + it("does NOT let a terminal-source entry win over the session-list API", async () => { + mockTmuxWithProcess("opencode"); + // Legacy terminal entry says idle; API says active. API should win for + // non-hook sources (only hook entries are authoritative real-time signals). + mockReadLastActivityEntry.mockResolvedValueOnce({ + entry: { ts: new Date().toISOString(), state: "idle", source: "terminal" }, + modifiedAt: new Date(), + }); + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") { + return Promise.resolve({ + stdout: " PID TT ARGS\n 789 ttys003 opencode\n", + stderr: "", + }); + } + if (cmd === "opencode") { + return Promise.resolve({ + stdout: JSON.stringify([ + { + id: "ses_abc123", + title: "AO:test-1", + updated: new Date(Date.now() - 5_000).toISOString(), + }, + ]), + stderr: "", + }); + } + return Promise.reject(new Error("unexpected")); + }); + + const result = await agent.getActivityState( + makeSession({ + runtimeHandle: makeTmuxHandle(), + metadata: { opencodeSessionId: "ses_abc123" }, + }), + 60_000, + ); + expect(result?.state).toBe("active"); + }); + it("returns null when both session list and JSONL are unavailable", async () => { mockTmuxWithProcess("opencode"); mockReadLastActivityEntry.mockResolvedValueOnce(null); diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index 6c9a9ae258..3fd96c4f4b 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -5,7 +5,6 @@ import { readLastActivityEntry, checkActivityLogState, getActivityFallbackState, - recordTerminalActivity, asValidOpenCodeSessionId, isWindows, PROCESS_PROBE_INDETERMINATE, @@ -29,6 +28,7 @@ import { } from "@aoagents/ao-core"; import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; +import { installOpenCodeActivityPlugin } from "./activity-plugin.js"; const execFileAsync = promisify(execFile); @@ -304,8 +304,9 @@ function createOpenCodeAgent(): Agent { if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; - // 1. Check AO activity JSONL first (written by recordActivity from terminal output). - // This is the only source of waiting_input/blocked states for OpenCode. + // 1. Check AO activity JSONL first (written by the hook plugin that + // setupWorkspaceHooks installs). Hook entries are the authoritative + // source of waiting_input/blocked — terminal-regex inference is gone. let activityResult: Awaited> = null; if (session.workspacePath) { activityResult = await readLastActivityEntry(session.workspacePath); @@ -313,6 +314,16 @@ function createOpenCodeAgent(): Agent { if (activityState) return activityState; } + // 1b. Hook entries are authoritative real-time platform events, so they + // win over the polled session-list API for active/ready/idle too. + // (waiting_input/blocked already returned above via checkActivityLogState.) + // Terminal-source entries deliberately do NOT short-circuit here — + // only the event-driven plugin produces trustworthy timing. + if (activityResult?.entry.source === "hook") { + const hookState = getActivityFallbackState(activityResult, activeWindowMs, threshold); + if (hookState) return hookState; + } + // 2. Fallback: query OpenCode's session list API for timestamp-based detection const targetSession = await findOpenCodeSession(session); if (targetSession) { @@ -337,12 +348,10 @@ function createOpenCodeAgent(): Agent { return null; }, - async recordActivity(session: Session, terminalOutput: string): Promise { - if (!session.workspacePath) return; - await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => - this.detectActivity(output), - ); - }, + // recordActivity is intentionally NOT implemented. The activity plugin + // installed by setupWorkspaceHooks writes authoritative hook events to + // .ao/activity.jsonl directly; polling-driven terminal classification would + // only add stale duplicates that shadow those events (mirrors Claude #1941). async isProcessRunning(handle: RuntimeHandle): Promise { try { @@ -429,8 +438,11 @@ function createOpenCodeAgent(): Agent { return parts.join(" "); }, - async setupWorkspaceHooks(_workspacePath: string, _config: WorkspaceHooksConfig): Promise { + async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise { // PATH wrappers are installed by session-manager for all agents. + // Install the activity plugin so OpenCode emits authoritative activity + // events to .ao/activity.jsonl (replaces terminal-regex inference). + await installOpenCodeActivityPlugin(workspacePath); }, async postLaunchSetup(_session: Session): Promise {