terminals: re-attach on reopen, and make orphaned shells findable

Closing a terminal panel abandoned its shell. TerminalWrapper deleted the panel -> session
mapping on *unmount*, so any layout or route change generated a fresh uuid on the way back
and left the old shell running: alive, unreachable, and never killed, because nothing has
ever sent pty:close. The mapping now outlives the mount, so reopening a panel re-attaches
to the shell you left — which is also what finally makes the sidecar's replay buffer worth
having. It is persisted dashboard state, so this survives a reload too.

That trades an invisible leak for a visible one: a panel deleted for good still leaves its
shell behind. So `pty:list` now enumerates live sessions, and GET /api/terminal/sessions +
DELETE /api/terminal/sessions/:id expose them. pty:close finally has a sender.

Each session carries createdAt, lastActivityAt, pid, and the title the shell sets for
itself via OSC 0/2 — usually the running command, which is what turns "some uuid" into
"the one running claude" when you are deciding what to kill.

Killing on unmount is still not an option: it needs the panel system to distinguish a real
close from an incidental remount, which it cannot currently do.

Also raises the sidecar replay buffer from 50KB to 512KB — 50KB was about one long agent
turn, so reconnecting mid-task showed you the tail and nothing before it — and cuts the
buffer on a line boundary rather than a byte offset. A blind slice can land inside an
escape sequence, and the replay then opens with the tail of a colour or cursor-move code,
which xterm renders as garbage or applies as a real instruction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 10:30:02 +00:00
co-authored by Claude Opus 5
parent dfb20a734e
commit 875f240e1c
5 changed files with 93 additions and 17 deletions
+18 -2
View File
@@ -165,7 +165,10 @@ export type PtyCommand =
| { type: 'pty:init'; id: string; sessionId: string; config: PtyInitConfig }
| { type: 'pty:input'; id: string; sessionId: string; data: string }
| { type: 'pty:resize'; id: string; sessionId: string; cols: number; rows: number }
| { type: 'pty:close'; id: string; sessionId: string };
| { type: 'pty:close'; id: string; sessionId: string }
// Enumerate live shells. A panel keeps its session id across unmounts so it can re-attach, which means a
// panel deleted for good leaves its shell running with nothing pointing at it. This is how you find one.
| { type: 'pty:list'; id: string };
// PTY events (PTY sidecar → API)
export type PtyEvent =
@@ -174,4 +177,17 @@ export type PtyEvent =
// Scrollback sent on re-attach, which the client may already be showing in part — distinct from
// `pty:output` so it can rebuild the screen rather than append a second copy of it.
| { type: 'pty:replay'; sessionId: string; data: string }
| { type: 'pty:exit'; sessionId: string; exitCode: number; signal?: number };
| { type: 'pty:exit'; sessionId: string; exitCode: number; signal?: number }
| { type: 'pty:sessions'; id: string; sessions: PtySessionInfo[] };
/** A live shell, as reported by `pty:list`. `title` is whatever the shell set via OSC 0/2 — usually the
* running command — which is what makes an orphan identifiable rather than just a uuid. */
export type PtySessionInfo = {
sessionId: string;
cols: number;
rows: number;
createdAt: number;
lastActivityAt: number;
title?: string;
pid?: number;
};