Context
PR #2383 gives a session a way out — clicking Agents lands on the conversation list, and a session gets an explicit escape. Both paths clear selectedSessionId, and AgentsSurface.tsx is a ternary, so the entire AgentPanes tree unmounts rather than being hidden. That unmounts XtermTerminal, whose cleanup emits shell:disconnect (apps/web/src/components/agents/shell/XtermTerminal.tsx:390).
Raised as a P1 on #2383 by chatgpt-codex-connector. It was not a blocker for that PR, for the reasons below, but it is worth doing deliberately now that leaving a session is a routine action rather than a rare one.
What is NOT the problem
Worth stating so this doesn't get over-fixed. Detaching removes a viewer, not the session:
disconnectConnection → removeViewer(...) (apps/realtime/src/terminal/shell-handler.ts:1529-1537). The PTY keeps running.
- Scrollback is buffered server-side and persisted on teardown (
shell-handler.ts:188-203, MAX_SCROLLBACK_BYTES in terminal-session-map.ts:4).
- Reattach is a built-in fast path: "JOIN this connection to an ALREADY-RUNNING session and hand the viewer its buffered scrollback: cancel any pending idle reap…" (
shell-handler.ts:1552-1561).
- The idle reap is quiet-based, not detachment-based, and agent input into a viewer-less session re-arms it —
armIdleReap's own docblock: "an agent driving a headless shell is activity, and reaping mid-command thirty minutes after it started would kill work in progress" (shell-handler.ts:481-500).
So navigating away does not lose terminal data, and a headless agent-driven shell is explicitly protected.
What IS the exposure
DETACHED_IDLE_MS = 30 * 60 * 1000 (apps/realtime/src/terminal/terminal-session-map.ts:5). Once the last viewer leaves, the timer arms and, if it fires, kills the session (session.command.kill('idle-reap') + endShellSession, shell-handler.ts:508-519).
Two real cases:
- A human-started long-running command, with no agent input, left unattended for >30 minutes → reaped. Nothing re-arms the timer for it.
- A command that finishes while nobody is attached — the process is fine and the tail is persisted, but the live
shell:closed exit code has no viewer to reach, so the exit status can be missed.
Why it is worth doing now
This cost is not new — AgentPanes is key={selectedSessionId} (apps/web/src/components/agents/AgentsSurface.tsx:176), so switching between sessions has always paid exactly the same price. What changed is frequency: leaving a session used to be nearly impossible (the only routes out destroyed it), and is now the default landing behaviour. The same mechanism moved onto the happy path.
Fixing it would also make session switching non-destructive, which is the larger win and is not achievable by special-casing the back button.
Proposed approach
Keep the session's panes mounted while showing the conversation list — e.g. an invisible keep-alive container, the same technique SessionPanes already uses within a session (apps/web/src/components/agents/panes/SessionPanes.tsx:86,108 — invisible/visibility:hidden, deliberately not hidden/display:none, for this exact reason).
Note this is effectively the MachineKeepAliveHost pattern, which the agents surface's docblock retired on the grounds that it "has no successor here because it has no problem to solve." That justification no longer holds, so reintroducing it is a deliberate design change and should carry its own review — which is why it was kept out of #2383 rather than bolted on.
Open questions for whoever picks this up:
- Scope: keep-alive only the most recent session, or an LRU of N? Unbounded keep-alive holds concurrency slots and billing heartbeats open.
- Interaction with
forgetWorkspace and the GC path at AgentsSurface.tsx:70-74 (a session that no longer exists server-side must not be kept alive).
- Whether a kept-alive-but-hidden session should still count as "attached" for the reap — if it does, the timer never arms, which is the point, but it also means a backgrounded session holds its slot indefinitely.
References
Context
PR #2383 gives a session a way out — clicking Agents lands on the conversation list, and a session gets an explicit escape. Both paths clear
selectedSessionId, andAgentsSurface.tsxis a ternary, so the entireAgentPanestree unmounts rather than being hidden. That unmountsXtermTerminal, whose cleanup emitsshell:disconnect(apps/web/src/components/agents/shell/XtermTerminal.tsx:390).Raised as a P1 on #2383 by
chatgpt-codex-connector. It was not a blocker for that PR, for the reasons below, but it is worth doing deliberately now that leaving a session is a routine action rather than a rare one.What is NOT the problem
Worth stating so this doesn't get over-fixed. Detaching removes a viewer, not the session:
disconnectConnection→removeViewer(...)(apps/realtime/src/terminal/shell-handler.ts:1529-1537). The PTY keeps running.shell-handler.ts:188-203,MAX_SCROLLBACK_BYTESinterminal-session-map.ts:4).shell-handler.ts:1552-1561).armIdleReap's own docblock: "an agent driving a headless shell is activity, and reaping mid-command thirty minutes after it started would kill work in progress" (shell-handler.ts:481-500).So navigating away does not lose terminal data, and a headless agent-driven shell is explicitly protected.
What IS the exposure
DETACHED_IDLE_MS = 30 * 60 * 1000(apps/realtime/src/terminal/terminal-session-map.ts:5). Once the last viewer leaves, the timer arms and, if it fires, kills the session (session.command.kill('idle-reap')+endShellSession,shell-handler.ts:508-519).Two real cases:
shell:closedexit code has no viewer to reach, so the exit status can be missed.Why it is worth doing now
This cost is not new —
AgentPanesiskey={selectedSessionId}(apps/web/src/components/agents/AgentsSurface.tsx:176), so switching between sessions has always paid exactly the same price. What changed is frequency: leaving a session used to be nearly impossible (the only routes out destroyed it), and is now the default landing behaviour. The same mechanism moved onto the happy path.Fixing it would also make session switching non-destructive, which is the larger win and is not achievable by special-casing the back button.
Proposed approach
Keep the session's panes mounted while showing the conversation list — e.g. an invisible keep-alive container, the same technique
SessionPanesalready uses within a session (apps/web/src/components/agents/panes/SessionPanes.tsx:86,108—invisible/visibility:hidden, deliberately nothidden/display:none, for this exact reason).Note this is effectively the
MachineKeepAliveHostpattern, which the agents surface's docblock retired on the grounds that it "has no successor here because it has no problem to solve." That justification no longer holds, so reintroducing it is a deliberate design change and should carry its own review — which is why it was kept out of #2383 rather than bolted on.Open questions for whoever picks this up:
forgetWorkspaceand the GC path atAgentsSurface.tsx:70-74(a session that no longer exists server-side must not be kept alive).References
Agents means "my conversations"(the review thread lives here)One writer for a pane close, and the last pane ends the session again