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.
This commit is contained in:
l0ng-ai
2026-09-28 23:15:31 +08:00
committed by GitHub
parent 7f66f69674
commit 9c1961de39
+186 -63
View File
@@ -1694,77 +1694,156 @@ fn goose_hooks_json(target: &HookTarget) -> Option<String> {
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<String> {
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 <event>`,
// 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"),