From 9c1961de392826e4890e210040d75412b324c8b6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:15:31 +0800 Subject: [PATCH] fix(agent-hooks): make the OpenCode plugin load on OpenCode 2.x (#999) (#1007) OpenCode 2.x only loads a default export shaped { id, setup | effect } and rejects the file otherwise ("Plugin must export a default definition with an id and an effect or setup function"). The generated tty7.js only had a named export, so every 2.x user got a load error. - Export default { id, server, setup }: 1.x (>= 1.3.4) reads `server`, 2.x reads `setup`; the named Tty7Presence export stays for older 1.x. - 2.x: subscribe via ctx.event.subscribe(), read event.data, map permission.asked, and treat session.created with a parentID as a child session. Stay inert inside the shared --service process, whose sessions belong to no single pane. - Spawn the emitter directly with node:child_process instead of `sh -c`, which does not exist on Windows. --- crates/tty7-core/src/core/agent_hooks.rs | 249 +++++++++++++++++------ 1 file changed, 186 insertions(+), 63 deletions(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index afac7e88..0b655d70 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -1694,77 +1694,156 @@ fn goose_hooks_json(target: &HookTarget) -> Option { serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok() } +/// The OpenCode plugin bridge — one file that both plugin loaders accept. +/// +/// OpenCode 1.x (since 1.3.4) takes a default export `{ id, server }`, and +/// OpenCode 2.x takes `{ id, setup }` (or `{ id, effect }`) and rejects +/// anything else with "Plugin must export a default definition with an id +/// and an effect or setup function" (#999). Each loader ignores the key the +/// other one reads, so a default export carrying all three loads on both. +/// The named `Tty7Presence` export is the same 1.x factory, for 1.x builds +/// older than 1.3.4 that call every export; newer loaders never look at it. +/// +/// The two majors differ in more than the export: 2.x hands `setup` no Bun +/// `$` and no `event` hook — events come from `ctx.event.subscribe()`, with +/// their fields under `data` rather than `properties`, and permission prompts +/// arrive as `permission.asked`. Both feed one tracker, so the session-id, +/// subagent and busy/idle rules are written once. +/// +/// The emitter is spawned directly (no shell): `sh` is not there on Windows, +/// and 2.x may run on Node rather than Bun. fn opencode_plugin_js(target: &HookTarget) -> Option { - let prefix = serde_json::to_string(&format!( - "{} ", - target.hook_command(HookAgent::OpenCode, "").trim_end() - )) - .ok()?; - Some(format!( + let exe = serde_json::to_string(&target.exe.display().to_string()).ok()?; + Some( r#"// tty7 agent-hook opencode bridge — generated by tty7, do not edit. // Bridges OpenCode plugin events onto `tty7 agent-hook opencode `, // which is inert outside tty7 (gated on the TTY7 env var). -export const Tty7Presence = async ({{ $ }}) => {{ - if (!process.env["TTY7"]) return {{}} - const cmd = {prefix} +// Loads on OpenCode 1.x (default `{ id, server }`) and 2.x (`{ id, setup }`). +import { spawn } from "node:child_process" + +const EXE = __EXE__ + +// Spawned directly, not through a shell: there is no `sh` on Windows. +function run(event, sessionId) { + return new Promise((resolve) => { + try { + const payload = sessionId ? JSON.stringify({ session_id: sessionId }) : "" + const child = spawn(EXE, ["agent-hook", "opencode", event], { + stdio: [payload ? "pipe" : "ignore", "ignore", "ignore"], + windowsHide: true, + }) + child.on("error", () => resolve()) + child.on("close", () => resolve()) + if (payload) { + child.stdin.on("error", () => {}) + child.stdin.end(payload) + } + } catch { + resolve() + } + }) +} + +// One pane's view of the event stream, shared by both plugin APIs. +function presence() { let sessionId = "" let announced = "" // A subagent runs in a child session on the *same* event stream, and its - // status events are indistinguishable from the pane's own — only - // `session.created`/`session.updated` name a parent. Remember the children, - // so a task tool cannot hand the pane the wrong session to resume, nor call - // the pane done when only the subagent is. + // status events are indistinguishable from the pane's own — only session + // creation names a parent. Remember the children, so a task tool cannot + // hand the pane the wrong session to resume, nor call the pane done when + // only the subagent is. const children = new Set() - const emit = (event) => {{ - // Every event carries the session id (`properties.sessionID`), so a pane - // that restarts can resume the same session with `opencode --session`. - const payload = sessionId ? new Response(JSON.stringify({{ session_id: sessionId }})) : undefined - const proc = payload ? $`sh -c ${{cmd + event}} < ${{payload}}` : $`sh -c ${{cmd + event}}` - return proc.quiet().nothrow() - }} - // The session id is only known once opencode creates the session (the - // `session.created` event); the session-start report rides on the first - // event that names one, so a restored pane can reattach to it. - const capture = async (id) => {{ + // Every event carries the session id, so a pane that restarts can resume + // the same session with `opencode --session`. + const emit = (event) => run(event, sessionId) + // The session id is only known once opencode creates the session; the + // session-start report rides on the first event that names one, so a + // restored pane can reattach to it. + const capture = async (id) => { if (id && !children.has(id)) sessionId = id - if (sessionId && sessionId !== announced) {{ + if (sessionId && sessionId !== announced) { announced = sessionId await emit("session-start") - }} - }} - const ACTION = {{ + } + } + const ACTION = { "session.status.busy": "prompt-submit", "session.status.idle": "stop", "session.idle": "stop", + "permission.asked": "permission-request", "permission.replied": "prompt-submit", - }} + } + // `fields` is 1.x's `event.properties` or 2.x's `event.data`. + const observe = async (type, fields) => { + const info = fields.info + // 1.x: `session.created`/`session.updated` carry the whole session. + if (info?.id && info.parentID) children.add(info.id) + // 2.x: `session.created` names the session and its parent directly. + if (type === "session.created" && fields.sessionID && fields.parentID) children.add(fields.sessionID) + if (fields.sessionID && children.has(fields.sessionID)) return + await capture(fields.sessionID) + const key = type === "session.status" ? `session.status.${fields.status?.type}` : type + const action = ACTION[key] + if (action) await emit(action) + } + return { emit, capture, observe } +} - return {{ - dispose: async () => {{ - await emit("session-end") - }}, - "tool.execute.before": async (input) => {{ - await capture(input?.sessionID) - await emit("prompt-submit") - }}, - "permission.ask": async (input) => {{ - await capture(input?.sessionID) - await emit("permission-request") - }}, - event: async ({{ event }}) => {{ - const properties = event.properties ?? {{}} - const info = properties.info - if (info?.id && info.parentID) children.add(info.id) - if (properties.sessionID && children.has(properties.sessionID)) return - await capture(properties.sessionID) - const key = event.type === "session.status" ? `session.status.${{properties.status?.type}}` : event.type - const action = ACTION[key] - if (action) await emit(action) - }}, - }} -}} +// OpenCode 1.x: a factory returning hooks, run in the TUI's own process. +const server = async () => { + if (!process.env["TTY7"]) return {} + const pane = presence() + return { + dispose: async () => { + await pane.emit("session-end") + }, + "tool.execute.before": async (input) => { + await pane.capture(input?.sessionID) + await pane.emit("prompt-submit") + }, + "permission.ask": async (input) => { + await pane.capture(input?.sessionID) + await pane.emit("permission-request") + }, + event: async ({ event }) => { + await pane.observe(event.type, event.properties ?? {}) + }, + } +} + +// OpenCode 2.x: `setup` subscribes to the event stream and returns cleanup. +const setup = async (ctx) => { + if (!process.env["TTY7"]) return + // By default 2.x runs plugins in one background service shared by every + // opencode on the machine: its sessions are not this pane's, and the + // emitter it spawns has no way back to the pane's terminal. Only a server + // the TUI started for itself (`--standalone`, or `opencode serve` run in + // the pane) belongs to one pane. + if (process.argv.includes("--service")) return + if (typeof ctx?.event?.subscribe !== "function") return + const pane = presence() + const abort = new AbortController() + // Not awaited: setup has to return for the plugin to finish loading. + ;(async () => { + try { + for await (const event of ctx.event.subscribe({ signal: abort.signal })) { + if (event?.type) await pane.observe(event.type, event.data ?? {}) + } + } catch {} + })() + return async () => { + abort.abort() + await pane.emit("session-end") + } +} + +export const Tty7Presence = server + +export default { id: "tty7", server, setup } "# - )) + .replace("__EXE__", &exe), + ) } /// The Pi extension bridge, shared with Oh My Pi. @@ -3035,36 +3114,80 @@ mod tests { let opencode = opencode_plugin_js(&target).expect("opencode content builds"); assert!(opencode.contains("agent-hook opencode")); - assert!(opencode.contains(hook_exe)); + assert!(opencode.contains(&format!("const EXE = {exe_json}"))); assert!(opencode.contains(r#"process.env["TTY7"]"#)); for (needle, message) in [ ( - "properties.sessionID", - "opencode captures the session id from event properties", + r#"export default { id: "tty7", server, setup }"#, + "opencode default-exports one definition both plugin loaders accept (#999)", + ), + ( + "export const Tty7Presence = server", + "opencode keeps the named factory for 1.x loaders that call every export", + ), + ( + r#"spawn(EXE, ["agent-hook", "opencode", event]"#, + "opencode spawns the emitter without a shell, which Windows lacks", + ), + ( + "event.properties ?? {}", + "opencode 1.x reads event fields from properties", + ), + ( + "ctx.event.subscribe(", + "opencode 2.x reads events from the subscribe stream", + ), + ( + "event.data ?? {}", + "opencode 2.x reads event fields from data", + ), + ( + r#"process.argv.includes("--service")"#, + "opencode 2.x stays inert in the machine-wide background service", ), ( r#"session_id: sessionId"#, "opencode forwards the session id to the emitter", ), ( - "session.status", - "opencode maps session.status busy/idle to prompt-submit/stop", + r#""session.status.busy": "prompt-submit""#, + "opencode maps session.status busy to prompt-submit", ), ( - "session.idle", + r#""session.status.idle": "stop""#, + "opencode maps session.status idle to stop", + ), + ( + r#""session.idle": "stop""#, "opencode still maps the session.idle event to stop", ), ( - "info.parentID", - "opencode tells a subagent's child session apart from the pane's own", + r#""permission.asked": "permission-request""#, + "opencode 2.x reports its permission.asked event as a permission request", ), ( - "children.has(properties.sessionID)", + r#""permission.ask": async"#, + "opencode 1.x keeps the permission.ask hook", + ), + ( + "info.parentID", + "opencode 1.x tells a subagent's child session apart from the pane's own", + ), + ( + "fields.parentID", + "opencode 2.x tells a subagent's child session apart from the pane's own", + ), + ( + "children.has(fields.sessionID)", "opencode lets a child session's events pass without touching the pane", ), ] { assert!(opencode.contains(needle), "{message}"); } + assert!( + !opencode.contains("sh -c"), + "opencode no longer routes the emitter through sh" + ); for (agent, slug, package) in [ (HookAgent::Pi, "pi", "@mariozechner/pi-coding-agent"),