fix(agent-status): follow daemon-hosted Claude sessions to their true pane (spawn pin + fork lineage + prompt-time rebind)#9237
Draft
BrianDai22 wants to merge 2 commits into
Conversation
…e spawn-pinned pane Claude Code >=2.1.206 transparently hosts new TUI sessions as workers under one shared background daemon. Hook commands now execute inside those workers, which inherit the DAEMON's environment — so every hook post carries the ORCA_PANE_KEY of whichever pane first spawned the daemon. Result: new Claude sessions never register on their own pane (no working/waiting/done in the sidebar) and continuously clobber one stale pane's status entry, across worktrees. Fix, mirroring the legacyPaneKeyAliases pattern: - Spawn side (pty.ts): when a local Claude launch has a validated pane identity and no session lifecycle flags (--session-id/--resume/--continue/ --fork-session/-r/-c), append a minted --session-id and register the sessionId -> paneKey binding with the hook server. Only lone claude invocations whose first arg is a flag or quoted prompt are pinned; compound commands and bare-word subcommands pass through untouched. - Listener side (server.ts): before pane-key alias normalization, if a /hook/claude post's payload session_id matches a pinned binding and the posted (env-derived) pane key differs, rewrite paneKey/tabId to the recorded pane. Unknown session ids pass through unchanged, so plain-shell claude sessions keep today's behavior. - Bindings are FIFO-bounded (512), dropped when their spawning PTY exits, and re-registered on reattach. Verified against a live CC 2.1.210 daemon: worker env carries the stale pane key while the payload session_id names the pinned session; manual posts with the corrected pane key attribute properly.
…aches Spawn-time binding alone misses two real flows found in continued live testing against CC 2.1.210-2.1.212: - Resume forks: --resume/--continue FORK the session id, so the forked id arrives unbound and status falls back to the daemon's stale env pane key. Resolve lineage from Claude's own daemon roster (<configDir>/daemon/ roster.json, located via the payload's transcript_path): fork id -> parent transcript -> parent binding. Deterministic; runs first. - Fleet-view / /resume-picker attaches: the agents view swaps the session inside an existing client with no spawn and no fork, so no binding ever exists. At UserPromptSubmit only (the submit follows the user's Enter by moments), bind an unknown session to the live Claude pane with the freshest keyboard input, via a probe injected by the PTY layer (which owns lastInputAtByPty and the live-Claude-PTY set; injection avoids a module cycle). Mid-turn events never consult the probe; no candidate within the window keeps the posted key. Bindings remain FIFO-bounded and PTY-exit-cleaned. Adds isLiveClaudePty to the live-pty gate. Three new vitest cases cover roster lineage, prompt-time rebind, and the no-steal guarantees (mid-turn events, empty probe).
BrianDai22
force-pushed
the
fix/claude-daemon-pane-attribution
branch
from
July 19, 2026 00:55
4371da2 to
5b9ba67
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #9236.
Claude Code ≥ 2.1.206 transparently hosts new TUI sessions as workers under one shared background daemon. Hook commands now execute inside those workers, which inherit the daemon's environment — so every
/hook/claudepost carries theORCA_PANE_KEYof whichever pane first spawned the daemon. New Claude sessions never register on their own pane (no working/waiting/done in the sidebar) and continuously clobber one stale pane's status entry, across worktrees.Pane identity has to be established where Orca still knows the pane, then followed as the session moves. Continued live testing against CC 2.1.210–2.1.212 showed one binding moment isn't enough — sessions fork ids on resume and hop panes via the agents view — so this PR binds at three points, mirroring the existing
legacyPaneKeyAliasespattern:src/main/ipc/pty.ts): when a local Claude launch has a validated pane identity and no session lifecycle flags (--session-id/--resume/--continue/--fork-session/-r/-c), append a minted--session-id <uuid>and register thesessionId → paneKeybinding with the hook server. Only loneclaudeinvocations whose first argument is a flag or quoted prompt are pinned; compound commands (claude && …) and bare-word subcommands (claude daemon status,claude mcp list) pass through untouched.src/main/agent-hooks/server.ts):--resume/--continueFORK the session id, so the forked id arrives with no binding. Claude's daemon records each fork's parent in<configDir>/daemon/roster.json(dispatch.launch.sessionIdnames the parent transcript), and the payload'stranscript_pathlocates the config dir — so an unknown session id deterministically inherits its parent's pane, with no knowledge of the user'sCLAUDE_CONFIG_DIRneeded.server.ts+ a probe injected frompty.ts): the agents/fleet view and/resumepicker swap the session inside an existing client — no spawn, no fork, no binding. OnUserPromptSubmitonly, an unbound session binds to the live Claude pane with the freshest keyboard input (5s window;lastInputAtByPtyand the live-Claude-PTY set both live in the PTY layer, hence the injected probe — avoids a module cycle). Mid-turn events never consult the probe; an empty probe keeps the posted key.At ingest, if a post's payload
session_idresolves to a binding and the posted (env-derived) pane key differs,paneKey/tabIdare rewritten to the bound pane — beforelegacyPaneKeyAliasesnormalization, so migrated-pane aliasing still applies on top. Session ids that resolve nowhere pass through unchanged, so user-typedclaudein a plain shell keeps today's behavior. Bindings are FIFO-bounded (512), dropped when their spawning PTY exits, and re-registered on reattach.Screenshots
No visual change (attribution fix — the sidebar simply shows status on the correct pane again for CC ≥ 2.1.206, and keeps following the session through resume forks and fleet-view attaches).
Testing
pnpm lintpnpm typecheckpnpm testpnpm buildSeven vitest cases in
src/main/agent-hooks/server.test.ts(existing HTTP-driven harness): daemon-stale-key re-attribution to the pinned pane, unknown-session passthrough, binding cleanup on PTY exit, FIFO bound on the registry, roster-based fork lineage (temp config dir + real roster.json fixture), prompt-time rebind via the probe, and the no-steal guarantees (mid-turn events never probe; empty probe keeps the posted key). The spawn-side command classifier was validated standalone against 25 command shapes (pin vs. passthrough for flags/quoted prompts/env-prefixes vs. resume flows/compounds/subcommands/redirects/pipelines). I was unable to run the full pnpm suite in my sandboxed environment — CI should exercise the checklist above; happy to iterate on any failures.Root cause and both follow-on gaps were verified against live Claude Code 2.1.210–2.1.212 daemons on macOS: the pane's TUI process carries the correct
ORCA_PANE_KEYbut is a thin client; the session worker (child ofclaude daemon run) carries the daemon's stale pane key, and hooks fire in the worker. The daemon's dispatch env forwards only an allowlist (ANTHROPIC_*,AWS_*,CLAUDE_CONFIG_DIR, …), so no per-pane env can survive into workers. Resume forks and fleet-view attaches were both reproduced live and both mechanisms validated in a local shim before porting here.AI Review Report
Developed and reviewed with Claude Code. Main risks checked: (1) session pinning must never break non-TUI invocations — guarded by a conservative appendability classifier (flag/quoted-prompt first-arg only) tested against 25 command shapes; (2) hook-ingest overrides must fail open — unknown/malformed
session_id, unreadable/malformed roster.json, and transcript paths that don't sit under aprojects/dir all pass the body through unchanged; (3) pane stealing — the input-rebind probe is consulted only forUserPromptSubmiton sessions with no live binding, only within a 5s input window, and only over panes in the live-Claude-PTY set, so background/headless sessions and mid-turn events cannot displace a user's pane (the residual case — a headless prompt landing inside the typing window of an unrelated unbound session — self-corrects at that session's next interactive turn); (4) unbounded memory — registry FIFO-bounded at 512 and cleaned on PTY exit; roster reads are per-miss, not cached. Cross-platform: path handling usesnode:path(dirname/basename/join), no separators assumed; changes are local-spawn-only (!args.connectionId), so SSH flows are untouched.Security Audit
Reviewed with Claude Code. Untrusted inputs from the local hook HTTP endpoint:
session_id(Map key for exact lookup only),transcript_path(used to derive a roster path viadirname×2 + a literaldaemon/roster.jsonjoin, gated on the parent dir being namedprojects; the file is JSON-parsed read-only, never written or executed — a hostile poster can at most point the parse at a file the same user could already read, and a non-roster JSON yields no binding). No path contents are echoed back. The minted spawn UUID comes fromrandomUUID()(no user input interpolated into the command string). The PTY-layer probe exposes only{paneKey, ptyId}for panes Orca already tracks. Registry writes bounded (FIFO 512 + PTY-exit cleanup). Existing token auth (x-orca-agent-hook-token) on the hook endpoint is unchanged and still gates all posts.Notes
ORCA_AGENT_HOOK_PORTin daemon workers is survivable today because the generated hook script re-sourcesORCA_AGENT_HOOK_ENDPOINT; pane identity had no such refresh path — this PR adds one.claudeLivePtySessionIdsnot trackingagentCmdOverrideswrappers whose binary isn't literallyclaude; daemon-hosted sessions outliving their thin-client PTY) remain out of scope here.CLAUDE_CODE_DISABLE_AGENT_VIEW=1 claude.