Skip to content

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
stablyai:mainfrom
BrianDai22:fix/claude-daemon-pane-attribution
Draft

fix(agent-status): follow daemon-hosted Claude sessions to their true pane (spawn pin + fork lineage + prompt-time rebind)#9237
BrianDai22 wants to merge 2 commits into
stablyai:mainfrom
BrianDai22:fix/claude-daemon-pane-attribution

Conversation

@BrianDai22

@BrianDai22 BrianDai22 commented Jul 17, 2026

Copy link
Copy Markdown

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/claude post carries the ORCA_PANE_KEY of 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 legacyPaneKeyAliases pattern:

  1. Spawn-time pinning (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 the sessionId → paneKey binding with the hook server. Only lone claude invocations 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.
  2. Fork lineage (src/main/agent-hooks/server.ts): --resume/--continue FORK 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.sessionId names the parent transcript), and the payload's transcript_path locates the config dir — so an unknown session id deterministically inherits its parent's pane, with no knowledge of the user's CLAUDE_CONFIG_DIR needed.
  3. Prompt-time rebind (server.ts + a probe injected from pty.ts): the agents/fleet view and /resume picker swap the session inside an existing client — no spawn, no fork, no binding. On UserPromptSubmit only, an unbound session binds to the live Claude pane with the freshest keyboard input (5s window; lastInputAtByPty and 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_id resolves to a binding and the posted (env-derived) pane key differs, paneKey/tabId are rewritten to the bound pane — before legacyPaneKeyAliases normalization, so migrated-pane aliasing still applies on top. Session ids that resolve nowhere pass through unchanged, so user-typed claude in 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 lint
  • pnpm typecheck
  • pnpm test
  • pnpm build
  • Added or updated high-quality tests that would catch regressions, or explained why tests were not needed

Seven 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_KEY but is a thin client; the session worker (child of claude 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 a projects/ dir all pass the body through unchanged; (3) pane stealing — the input-rebind probe is consulted only for UserPromptSubmit on 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 uses node: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 via dirname×2 + a literal daemon/roster.json join, gated on the parent dir being named projects; 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 from randomUUID() (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

…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
BrianDai22 force-pushed the fix/claude-daemon-pane-attribution branch from 4371da2 to 5b9ba67 Compare July 19, 2026 00:55
@BrianDai22 BrianDai22 changed the title fix(agent-status): re-attribute daemon-hosted Claude hook posts to the spawn-pinned pane fix(agent-status): follow daemon-hosted Claude sessions to their true pane (spawn pin + fork lineage + prompt-time rebind) Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Claude Code >=2.1.206 shared daemon breaks agent-status pane attribution — hooks inherit the daemon's stale ORCA_PANE_KEY

1 participant