Repository navigation
Terminal-mode Claude agents are killed 30s after any pane remount (Spotlight, Reader, Settings, tab switch) #596
Description
Report
Running an agent in terminal mode (agentViewMode === 'terminal'), then opening Spotlight mode or the fullscreen global editor, kills the agent.
Mechanism (traced)
Every AgentTerminalLeaf mount unconditionally wakes the session, and the wake path kills a live backend if the provider is not "input ready" within 30 seconds.
src/renderer/src/app/shell/MainSurface.tsx:85— Spotlight/Reader/Settings render outsideGlobalEditorShell, soTileTreeand everyAgentTerminalLeafunmount.src/renderer/src/features/spotlight/ui/SpotlightView.tsx:62— Spotlight immediately re-renders the focused session, so a newAgentTerminalLeafmounts for the samesessionId.src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx:190— the mount effect callsensureSessionLive(sessionId)unconditionally, with no "is this already live?" guard. Attach is chained behind it, so the pane renders nothing until it resolves.src/renderer/src/workspace/hook/actions/session.ts:490—ensureSessionLivealways issuesrecoverSession.src/main/sessionManager.ts:641— main sees the live entry and adopts it (disposition: 'adopted'), returningsnapshot.inputfromlastInputReadiness.session.ts:553— ifsnapshot.input.ready === false, the renderer pollswaitForSessionInputReady(30s), and on timeout callskillSessionBackendIfOwned.killOwned→kill()→session.stop(). The provider process is terminated. The kind+cwd ownership proof passes trivially — it is the same entry that was just adopted.
Why readiness is false for a perfectly healthy agent
derivePromptGateState() (src/providers/claude/runtime/claudeSession.ts:744) reports ready: false when:
- a provider condition is on screen (permission / trust prompt), or
- the raw composer holds any text (
occupied,reason: 'human-draft').
In terminal mode the user types directly into the Claude TUI and answers permission prompts there, so "draft present" is the normal state. setInputReadiness is edge-triggered (sessionManager.ts:452), so no further event arrives to unblock the poll. And during those 30s the pane is blank because attach never fired — so the user cannot clear the draft or answer the prompt, which makes the timeout guaranteed, not merely possible.
Provider split
Claude only. Codex latches composerReady true after the first composer sighting and never re-emits ready: false except on exit (src/providers/codex/runtime/codexSession.ts:368), so the 30s wait is skipped entirely for a warmed Codex agent.
Provenance
The mount-time wake is old (d184575d, 2026-06-23). The kill-on-timeout was added 2026-07-16 in 99f52138 (PR #548, "Make session recovery deterministic across restarts"):
"A provider that started but never reached its real composer boundary must be replaced, not repeatedly re-adopted on Retry."
That reasoning is correct for the caller it was written for — a cold-boot dead pane that just spawned a process. It is wrong for a caller that adopted an already-live, busy agent.
Blast radius — wider than the report
| Surface | Exposed? |
|---|---|
| Spotlight | Yes — on entry (one leaf) and on exit (every terminal-mode leaf in the tab, each arming its own 30s kill timer) |
| Reader mode | Yes on exit |
| Settings page | Yes on exit |
| Tab switching | Yes — no focus surface involved at all |
| Editor fullscreen | No remount — GlobalEditorShell.tsx:1218 keeps children mounted under display:none |
Parsed-feed (agent) view mode |
No — TileLeaf only wakes from send(), and only when already not-ready/failed |
| Hybrid mode | Only via its terminal branch; partly self-protects because a visible condition promotes the pane to the rendered surface |
The fullscreen-editor half of the report is most likely misattribution: the Spotlight-armed kill fires 30 seconds later, very plausibly right after the user has moved on to opening the editor. The distinguishing evidence is the pane toast text — the kill path sets processError = 'Timed out waiting for agent to become ready for input'.
Secondary effects (real, lower severity)
- During the 30s wait the terminal is blank and keystrokes queue into
pendingInput(capped at 256) — indistinguishable from a dead agent. - Even on the healthy path,
detachAgentPty(sessionManager.ts:1481) resizes the PTY back torestoreSize, which for a terminal-mode agent is the spawn default 120×40 (no renderer caller ever passescols/rows). So every Spotlight enter/exit snaps Claude's TUI to 120×40 and back, and the 512KB replay buffer ends up interleaved at three widths — garbled repaint on remount. - Refcount leak: detach is fire-and-forget while attach is chained behind an
await, so a fast mount/unmount runsdetachAgentPtyat count 0 and the late attach then pins the count at 1 forever.
Fix direction
- Don't kill from a caller that adopted a live backend.
SessionRecoverResultalready carriesdisposition;ensureSessionLivecurrently discards it. OnlykillSessionBackendIfOwnedwhendisposition === 'spawned', and skip the readiness wait entirely foradopted+lifecycle === 'live'(which also removes the 30s blank-terminal window). - Make the mount-time wake conditional. Mirror
TileLeaf.send— onlyensureSessionLivewhen the runtime is not already started, so a remount is a pure re-attach.
Neither touches the MainSurface full-bleed contract; Spotlight/Reader can keep rendering outside GlobalEditorShell, because the bug is the wake semantics, not the unmount.
Activity
fix(lifecycle): resolve two-reviewer adversarial review of PR #627