diff --git a/src/servers/api/terminal/router.ts b/src/servers/api/terminal/router.ts new file mode 100644 index 00000000..9a7ac19a --- /dev/null +++ b/src/servers/api/terminal/router.ts @@ -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 }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index f97f6cbb..0f26e433 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -27,6 +27,7 @@ import { transmissionRouter } from './api/transmission/router'; import { invoiceshelfRouter } from './api/invoiceshelf/router'; import { walletRouter } from './api/wallet/router'; import { vpnRouter } from './api/vpn/router'; +import { terminalRouter } from './api/terminal/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; 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('/music', musicRouter); protectedRouter.route('/slskd', slskdRouter); +protectedRouter.route('/terminal', terminalRouter); protectedRouter.route('/headscale', headscaleRouter); protectedRouter.route('/transmission', transmissionRouter); protectedRouter.route('/invoiceshelf', invoiceshelfRouter); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index cf7f08ec..8a4c9b2e 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -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; +}; diff --git a/src/servers/sidecar/pty/index.mjs b/src/servers/sidecar/pty/index.mjs index 72cc72d6..05d2cb37 100644 --- a/src/servers/sidecar/pty/index.mjs +++ b/src/servers/sidecar/pty/index.mjs @@ -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) { diff --git a/src/workspaces/officerdev/src/apps/Terminal/TerminalWrapper.tsx b/src/workspaces/officerdev/src/apps/Terminal/TerminalWrapper.tsx index 6a4d04a2..ed4ecf2b 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/TerminalWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/TerminalWrapper.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import { useWorkspace } from '../../components/Workspace'; import { useDashboardState } from 'state/useDashboardState'; import { useGlobal } from 'hooks/useGlobal'; @@ -18,9 +18,6 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => { stateKey, EMPTY_TERMINALS, ); - const setTerminalsRef = useRef(setTerminals); - setTerminalsRef.current = setTerminals; - const sessionId = terminals[panelId]; useEffect(() => { @@ -30,14 +27,15 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => { } }, [panelId, sessionId, setTerminals]); - useEffect(() => { - return () => { - setTerminalsRef.current((prev) => { - const { [panelId]: _, ...rest } = prev; - return rest; - }); - }; - }, [panelId]); + // The panel → session mapping deliberately OUTLIVES the mount. It used to be deleted on unmount, so any + // layout or route change generated a fresh uuid on the way back and abandoned the previous shell — alive, + // unreachable, and never killed, because nothing sends pty:close. Keeping it means reopening a terminal + // panel re-attaches to the shell you left running, which is also what makes the sidecar's replay buffer + // 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 + // 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(`terminal-conn-${panelId}`, 'disconnected'); const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);