Skip to content

Terminal-mode Claude agents are killed 30s after any pane remount (Spotlight, Reader, Settings, tab switch) #596

Closed

Description

@Juliusolsson05

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.

  1. src/renderer/src/app/shell/MainSurface.tsx:85 — Spotlight/Reader/Settings render outside GlobalEditorShell, so TileTree and every AgentTerminalLeaf unmount.
  2. src/renderer/src/features/spotlight/ui/SpotlightView.tsx:62 — Spotlight immediately re-renders the focused session, so a new AgentTerminalLeaf mounts for the same sessionId.
  3. src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx:190 — the mount effect calls ensureSessionLive(sessionId) unconditionally, with no "is this already live?" guard. Attach is chained behind it, so the pane renders nothing until it resolves.
  4. src/renderer/src/workspace/hook/actions/session.ts:490 — ensureSessionLive always issues recoverSession.
  5. src/main/sessionManager.ts:641 — main sees the live entry and adopts it (disposition: 'adopted'), returning snapshot.input from lastInputReadiness.
  6. session.ts:553 — if snapshot.input.ready === false, the renderer polls waitForSessionInputReady (30s), and on timeout calls killSessionBackendIfOwned.
  7. 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 to restoreSize, which for a terminal-mode agent is the spawn default 120×40 (no renderer caller ever passes cols/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 runs detachAgentPty at count 0 and the late attach then pins the count at 1 forever.

Fix direction

  1. Don't kill from a caller that adopted a live backend. SessionRecoverResult already carries disposition; ensureSessionLive currently discards it. Only killSessionBackendIfOwned when disposition === 'spawned', and skip the readiness wait entirely for adopted + lifecycle === 'live' (which also removes the 30s blank-terminal window).
  2. Make the mount-time wake conditional. Mirror TileLeaf.send — only ensureSessionLive when 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions