From 5452414a20630b74c20c2cc3924ebc2daef874c4 Mon Sep 17 00:00:00 2001 From: cwatanab Date: Tue, 11 Aug 2026 23:52:13 +0900 Subject: [PATCH] feat(agent-hooks): report the opencode session id (#481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `. 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. --- crates/tty7-core/src/core/agent_hooks.rs | 82 ++++++++++++++++++++---- 1 file changed, 71 insertions(+), 11 deletions(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 7ebb0ca9..93f3837a 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -922,27 +922,59 @@ fn opencode_plugin_js(target: &HookTarget) -> Option { 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"),