feat(agent-hooks): report the opencode session id (#481)

The opencode plugin now captures the session id from event properties and
forwards it to the emitter on stdin, so a restarted pane can resume the same
session with `opencode --session <id>`. Also maps session.status busy/idle
for versions that no longer emit session.idle.

A task-tool subagent runs in a child session whose events are structurally
identical to the pane's own, so the bridge remembers child ids from
session.created/session.updated and lets their events pass without touching
the pane's session — otherwise the pane would report (and resume) the
subagent's session, and a subagent going idle would call the pane done.
This commit is contained in:
cwatanab
2026-08-11 22:52:13 +08:00
committed by GitHub
parent ba6e4432ab
commit 5452414a20
+71 -11
View File
@@ -922,27 +922,59 @@ fn opencode_plugin_js(target: &HookTarget) -> Option<String> {
export const Tty7Presence = async ({{ $ }}) => {{
if (!process.env["TTY7"]) return {{}}
const cmd = {prefix}
const emit = (event) => $`sh -c ${{cmd + event}}`.quiet().nothrow()
// Plugin load = the agent is running in this pane.
await emit("session-start")
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.
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) => {{
if (id && !children.has(id)) sessionId = id
if (sessionId && sessionId !== announced) {{
announced = sessionId
await emit("session-start")
}}
}}
const ACTION = {{
"session.status.busy": "prompt-submit",
"session.status.idle": "stop",
"session.idle": "stop",
"permission.replied": "prompt-submit",
}}
return {{
dispose: async () => {{
await emit("session-end")
}},
"tool.execute.before": async () => {{
"tool.execute.before": async (input) => {{
await capture(input?.sessionID)
await emit("prompt-submit")
}},
"permission.ask": async () => {{
"permission.ask": async (input) => {{
await capture(input?.sessionID)
await emit("permission-request")
}},
event: async ({{ event }}) => {{
if (event.type === "session.idle") {{
await emit("stop")
}} else if (event.type === "permission.replied") {{
await emit("prompt-submit")
}}
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)
}},
}}
}}
@@ -1386,6 +1418,34 @@ mod tests {
assert!(opencode.contains("agent-hook opencode"));
assert!(opencode.contains(hook_exe));
assert!(opencode.contains(r#"process.env["TTY7"]"#));
for (needle, message) in [
(
"properties.sessionID",
"opencode captures the session id from event properties",
),
(
r#"session_id: sessionId"#,
"opencode forwards the session id to the emitter",
),
(
"session.status",
"opencode maps session.status busy/idle to prompt-submit/stop",
),
(
"session.idle",
"opencode still maps the session.idle event to stop",
),
(
"info.parentID",
"opencode tells a subagent's child session apart from the pane's own",
),
(
"children.has(properties.sessionID)",
"opencode lets a child session's events pass without touching the pane",
),
] {
assert!(opencode.contains(needle), "{message}");
}
for (agent, slug, package) in [
(HookAgent::Pi, "pi", "@mariozechner/pi-coding-agent"),