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
+32
View File
@@ -0,0 +1,32 @@
import { createRouter } from '../../create-router';
import { sendPtyCommandAsync, sendPtyCommand, isTerminalConnected } from '@@/sidecar-registry';
// Same shape as the bridge's own counter next door — correlation ids only need to be unique per process.
let idCounter = 0;
const nextId = (): string => `pty_${Date.now()}_${++idCounter}`;
// Live shells, and a way to kill one.
//
// A terminal panel keeps its session id across unmounts so reopening it re-attaches to the shell you left
// running (TerminalWrapper). The cost of that is a panel deleted for good leaves its shell alive with
// nothing pointing at it — `pty:close` has a handler but, until this router, had no sender at all. These
// two endpoints are how an orphan becomes visible and killable instead of just leaking.
export const terminalRouter = createRouter();
// GET /terminal/sessions → { sessions: PtySessionInfo[] }
terminalRouter.get('/sessions', async (ctx) => {
if (!isTerminalConnected()) return ctx.json({ sessions: [] });
const res = await sendPtyCommandAsync({ type: 'pty:list', id: nextId() });
if (res.type !== 'pty:sessions') return ctx.json({ error: 'unexpected response' }, 502);
return ctx.json({ sessions: res.sessions });
});
// DELETE /terminal/sessions/:sessionId — kills the shell. Fire-and-forget: the sidecar answers the death
// on the `pty:exit` broadcast that any attached client is already listening to, not as a reply here.
terminalRouter.delete('/sessions/:sessionId', (ctx) => {
if (!isTerminalConnected()) return ctx.json({ error: 'terminal sidecar not available' }, 503);
sendPtyCommand({ type: 'pty:close', id: nextId(), sessionId: ctx.req.param('sessionId') });
return ctx.json({ ok: true });
});
+2
View File
@@ -27,6 +27,7 @@ import { transmissionRouter } from './api/transmission/router';
import { invoiceshelfRouter } from './api/invoiceshelf/router'; import { invoiceshelfRouter } from './api/invoiceshelf/router';
import { walletRouter } from './api/wallet/router'; import { walletRouter } from './api/wallet/router';
import { vpnRouter } from './api/vpn/router'; import { vpnRouter } from './api/vpn/router';
import { terminalRouter } from './api/terminal/router';
import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { systemMonitorRouter } from './api/system-monitor/system-monitor';
import { activityRouter } from './api/activity/router'; import { activityRouter } from './api/activity/router';
import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port
@@ -113,6 +114,7 @@ protectedRouter.route('/task-logs', taskLogsRouter);
protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/file-browser', fileBrowserRouter);
protectedRouter.route('/music', musicRouter); protectedRouter.route('/music', musicRouter);
protectedRouter.route('/slskd', slskdRouter); protectedRouter.route('/slskd', slskdRouter);
protectedRouter.route('/terminal', terminalRouter);
protectedRouter.route('/headscale', headscaleRouter); protectedRouter.route('/headscale', headscaleRouter);
protectedRouter.route('/transmission', transmissionRouter); protectedRouter.route('/transmission', transmissionRouter);
protectedRouter.route('/invoiceshelf', invoiceshelfRouter); protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
+18 -2
View File
@@ -165,7 +165,10 @@ export type PtyCommand =
| { type: 'pty:init'; id: string; sessionId: string; config: PtyInitConfig } | { type: 'pty:init'; id: string; sessionId: string; config: PtyInitConfig }
| { type: 'pty:input'; id: string; sessionId: string; data: string } | { type: 'pty:input'; id: string; sessionId: string; data: string }
| { type: 'pty:resize'; id: string; sessionId: string; cols: number; rows: number } | { 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) // PTY events (PTY sidecar → API)
export type PtyEvent = 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 // 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. // `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: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 API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
const REGISTER_URL = `${API_URL}/api/sidecar/register`; 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]; const RECONNECT_DELAYS = [200, 500, 1000, 2000, 4000, 8000, 15000];
// ── What kind of shell this process runs ── // ── What kind of shell this process runs ──
@@ -72,7 +74,13 @@ const sendJson = (msg) => {
const appendBuffer = (session, data) => { const appendBuffer = (session, data) => {
session.buffer += data; session.buffer += data;
if (session.buffer.length > BUFFER_MAX) { 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; 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); sessions.set(sessionId, session);
term.onData((output) => { term.onData((output) => {
appendBuffer(session, 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 }); sendJson({ type: 'pty:output', sessionId, data: output });
}); });
@@ -174,6 +188,20 @@ async function handleCommand(msg) {
break; 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': { case 'pty:close': {
const session = sessions.get(msg.sessionId); const session = sessions.get(msg.sessionId);
if (session) { if (session) {
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect } from 'react';
import { useWorkspace } from '../../components/Workspace'; import { useWorkspace } from '../../components/Workspace';
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
@@ -18,9 +18,6 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
stateKey, stateKey,
EMPTY_TERMINALS, EMPTY_TERMINALS,
); );
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
const sessionId = terminals[panelId]; const sessionId = terminals[panelId];
useEffect(() => { useEffect(() => {
@@ -30,14 +27,15 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
} }
}, [panelId, sessionId, setTerminals]); }, [panelId, sessionId, setTerminals]);
useEffect(() => { // The panel → session mapping deliberately OUTLIVES the mount. It used to be deleted on unmount, so any
return () => { // layout or route change generated a fresh uuid on the way back and abandoned the previous shell — alive,
setTerminalsRef.current((prev) => { // unreachable, and never killed, because nothing sends pty:close. Keeping it means reopening a terminal
const { [panelId]: _, ...rest } = prev; // panel re-attaches to the shell you left running, which is also what makes the sidecar's replay buffer
return rest; // worth having. The map is persisted dashboard state, so this survives a reload too.
}); //
}; // The cost is that a panel deleted for good leaves its entry behind; the running-shells list is how you
}, [panelId]); // find and kill those. Killing on unmount is not an option until the panel system can tell a real close
// from an incidental remount.
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected'); const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]); const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);