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;
};
+31 -3
View File
@@ -19,7 +19,9 @@ import 'dotenv/config';
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
const REGISTER_URL = `${API_URL}/api/sidecar/register`;
const BUFFER_MAX = 50 * 1024;
// What a re-attaching client gets back. 50KB was about one long agent turn, so reconnecting mid-task
// showed you the tail and nothing else. Per session, so ten live shells is ~5MB — cheap next to node-pty.
const BUFFER_MAX = 512 * 1024;
const RECONNECT_DELAYS = [200, 500, 1000, 2000, 4000, 8000, 15000];
// ── What kind of shell this process runs ──
@@ -72,7 +74,13 @@ const sendJson = (msg) => {
const appendBuffer = (session, data) => {
session.buffer += data;
if (session.buffer.length > BUFFER_MAX) {
session.buffer = session.buffer.slice(-BUFFER_MAX);
// Cut on a line boundary, not 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 worse, applies as a real instruction. Fall back to the raw cut if there is no newline
// in the last 4KB (a single enormous line), where a truncated sequence is the lesser problem.
const cut = session.buffer.length - BUFFER_MAX;
const nl = session.buffer.indexOf('\n', cut);
session.buffer = nl !== -1 && nl - cut < 4096 ? session.buffer.slice(nl + 1) : session.buffer.slice(cut);
}
};
@@ -134,11 +142,17 @@ async function handleCommand(msg) {
return;
}
const session = { term, buffer: '', cols, rows };
const now = Date.now();
const session = { term, buffer: '', cols, rows, createdAt: now, lastActivityAt: now, title: '', pid: term.pid };
sessions.set(sessionId, session);
term.onData((output) => {
appendBuffer(session, output);
session.lastActivityAt = Date.now();
// Track the title the shell sets for itself (OSC 0/2 — usually the running command). Cheap to
// scan for, and it is what turns "some uuid" into "the one running claude" in the session list.
const titleMatch = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/.exec(output);
if (titleMatch) session.title = titleMatch[1];
sendJson({ type: 'pty:output', sessionId, data: output });
});
@@ -174,6 +188,20 @@ async function handleCommand(msg) {
break;
}
case 'pty:list': {
const list = [...sessions.entries()].map(([id, s]) => ({
sessionId: id,
cols: s.cols,
rows: s.rows,
createdAt: s.createdAt ?? 0,
lastActivityAt: s.lastActivityAt ?? 0,
title: s.title || undefined,
pid: s.pid,
}));
sendJson({ type: 'pty:sessions', id: msg.id, sessions: list });
return;
}
case 'pty:close': {
const session = sessions.get(msg.sessionId);
if (session) {