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:
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user