diff --git a/src/servers/api/terminal/router.ts b/src/servers/api/terminal/router.ts deleted file mode 100644 index 9a7ac19a..00000000 --- a/src/servers/api/terminal/router.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/api/terminal/sidecar-server.ts b/src/servers/api/terminal/sidecar-server.ts new file mode 100644 index 00000000..8e500838 --- /dev/null +++ b/src/servers/api/terminal/sidecar-server.ts @@ -0,0 +1,13 @@ +import { createSidecarProxy } from '../../sidecar/create-proxy'; + +// `/api/terminal/*` is a plain auth-and-forward proxy onto the pty sidecar's own listener, like every +// other HTTP sidecar. The sidecar owns `/_officer/sessions` (list), `DELETE /_officer/sessions/:id` (kill) +// and `POST /_officer/panel-refresh`; the platform knows none of those contracts, only where to send them. +// +// `/api/terminal/ws` does NOT come through here — Bun's route table takes it first and hands it to the +// byte relay in `websocket.ts`. +const proxy = createSidecarProxy({ name: 'pty', prefix: '/api/terminal' }); + +export const terminalRouter = proxy.router; +export const getTerminalHttpUrl = proxy.getHttpUrl; +export const getTerminalWsUrl = proxy.getWsUrl; diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index 97dcb95e..491c35ab 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -1,138 +1,104 @@ import type { ServerWebSocket } from 'bun'; -import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry'; -import type { PtyInitConfig } from '../../sidecar/protocol'; +import { getTerminalWsUrl, getTerminalHttpUrl } from './sidecar-server'; -type WSData = { - userId: number; - email: string; - username: string; - sessionId?: string; - cwd?: string; - cols?: number; - rows?: number; +// Byte relay between the browser and the pty sidecar's own socket. +// +// This used to be a translating bridge: officer sent pty:init / pty:input / pty:resize over the +// registration socket, subscribed to ONE global output stream, filtered every frame down to this session +// and rewrapped it — double-JSON-encoded — on the way out. All of that was terminal knowledge living in a +// process whose job is authentication. The sidecar serves its own socket now; this file authenticates the +// upgrade (upgradeWs, in server.tsx) and moves frames without reading them. +// +// The frame contract with the browser is unchanged, because the sidecar speaks it directly. + +export type TerminalWSData = { + provider: 'terminal'; + /** Raw query string from the upgrade — sessionId, cwd, cols, rows — passed through untouched. */ + search: string; }; -type BridgeSession = { - client: ServerWebSocket; - sessionId: string; - unsubs: Array<() => void>; +type UpstreamState = { + ws: WebSocket | null; + queue: (string | Uint8Array)[]; + closed: boolean; }; -const sessions = new Map, BridgeSession>(); +const upstreams = new Map, UpstreamState>(); -let idCounter = 0; -function nextId(): string { - return `pty_${Date.now()}_${++idCounter}`; -} +const asPayload = (raw: string | Buffer): string | Uint8Array => + typeof raw === 'string' ? raw : (raw as Uint8Array); -const sendOutput = (ws: ServerWebSocket, data: string) => { +const closeClient = (ws: ServerWebSocket, code: number, reason: string) => { + upstreams.delete(ws); try { - ws.send(JSON.stringify({ type: 'output', data })); + ws.close(code, reason); } catch { - // ws already closed + /* already closed */ } }; export const terminalWebsocket = { - async open(ws: ServerWebSocket) { - const { email, username } = ws.data; + open(ws: ServerWebSocket) { + // Registered synchronously so frames that arrive before the upstream is ready are queued, not dropped. + const state: UpstreamState = { ws: null, queue: [], closed: false }; + upstreams.set(ws, state); - console.log(`[terminal] open: email=${email} username=${username}`); - - if (!isTerminalConnected()) { - sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n'); + const base = getTerminalWsUrl(); + if (!base) { + try { + ws.send(JSON.stringify({ type: 'output', data: '\r\n[Terminal error] PTY sidecar is not connected\r\n' })); + } catch { + /* ignore */ + } + closeClient(ws, 1011, 'pty sidecar unavailable'); return; } - const sessionId = ws.data.sessionId ?? `host-${ws.data.userId}`; + const upstream = new WebSocket(`${base}/ws${state.closed ? '' : ws.data.search}`); + state.ws = upstream; - // Everything this bridge knows: which session, which folder the panel was opened on, and how big the - // client's viewport is. The shell, its arguments and the home directory are the sidecar's — it is the - // process that spawns them, and officer has no business reading the owner's SHELL and HOME to guess. - const config: PtyInitConfig = { sessionId, cwd: ws.data.cwd, cols: ws.data.cols, rows: ws.data.rows }; - - // The sidecar emits one global stream, so each frame is filtered down to this session and relabelled. - const relay = (event: 'pty:output' | 'pty:replay' | 'pty:exit', clientType: string) => - on(event, (msg) => { - if (msg.type !== event || msg.sessionId !== sessionId) return; - try { - ws.send(JSON.stringify({ type: clientType, data: 'data' in msg ? msg.data : undefined })); - } catch { - // ws already closed - } - }); - - const session: BridgeSession = { - client: ws, - sessionId, - unsubs: [relay('pty:output', 'output'), relay('pty:replay', 'replay'), relay('pty:exit', 'exit')], + upstream.onopen = () => { + for (const frame of state.queue) upstream.send(frame); + state.queue.length = 0; }; - sessions.set(ws, session); - - // Send init command to PTY sidecar - try { - await sendPtyCommandAsync({ type: 'pty:init', id: nextId(), sessionId, config }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to initialize terminal'; - sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`); - for (const unsub of session.unsubs) unsub(); - sessions.delete(ws); - } - }, - - message(ws: ServerWebSocket, raw: string | Buffer) { - const session = sessions.get(ws); - if (!session) return; - - try { - const payload = typeof raw === 'string' ? raw : raw.toString(); - const msg = JSON.parse(payload); - - switch (msg.type) { - case 'input': - sendPtyCommand({ type: 'pty:input', id: nextId(), sessionId: session.sessionId, data: msg.data ?? '' }); - break; - case 'resize': - if (msg.cols > 0 && msg.rows > 0) { - sendPtyCommand({ - type: 'pty:resize', - id: nextId(), - sessionId: session.sessionId, - cols: msg.cols, - rows: msg.rows, - }); - } - break; - // There was a 'cwd' case here that typed `cd \r` into the user's shell. No frontend sends - // that message — the browser composes its own `cd` (Terminal.tsx / CommandTerminalWrapper.tsx) — - // so it was unreachable, and synthesizing keystrokes is not a thing a proxy should do. + upstream.onmessage = (ev) => { + try { + ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer)); + } catch { + /* client gone */ } - } catch { - // ignore malformed messages - } + }; + upstream.onclose = () => closeClient(ws, 1000, 'upstream closed'); + upstream.onerror = () => closeClient(ws, 1011, 'upstream error'); }, - close(ws: ServerWebSocket) { - const session = sessions.get(ws); - if (session) { - for (const unsub of session.unsubs) unsub(); - // Don't kill PTY — it can be reattached - sessions.delete(ws); + message(ws: ServerWebSocket, raw: string | Buffer) { + const state = upstreams.get(ws); + if (!state) return; + const payload = asPayload(raw); + if (state.ws && state.ws.readyState === WebSocket.OPEN) state.ws.send(payload); + else state.queue.push(payload); + }, + + close(ws: ServerWebSocket) { + const state = upstreams.get(ws); + upstreams.delete(ws); + // Closing the upstream detaches this viewer; the shell stays alive in the sidecar, re-attachable. + try { + state?.ws?.close(); + } catch { + /* ignore */ } }, drain() {}, }; -export const broadcastPanelRefresh = (email: string) => { - const msg = JSON.stringify({ type: 'panel-refresh' }); - for (const [ws] of sessions) { - if (ws.data.email === email) { - try { - ws.send(msg); - } catch { - /* ignore */ - } - } - } +// The claude-done hook asks attached terminals to refresh their panel. The sidecar holds those sockets +// now, so this is a POST to it rather than a loop over sockets officer used to own. Fire-and-forget: a +// missed refresh is a stale panel, not a failure worth surfacing. +export const broadcastPanelRefresh = (_email: string): void => { + const base = getTerminalHttpUrl(); + if (!base) return; + fetch(`${base}/_officer/panel-refresh`, { method: 'POST' }).catch(() => {}); }; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 0f26e433..de2d3c6f 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -27,7 +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 { terminalRouter } from './api/terminal/sidecar-server'; 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 diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index 28bc55f1..62b078ef 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -7,8 +7,6 @@ import type { ClaudeSpawnStreamingParams, ClaudeCodeResult, OpenCodeRunParams, - PtyCommand, - PtyEvent, VncStartParams, VncSessionInfo, } from './sidecar/protocol'; @@ -30,7 +28,7 @@ type PendingRequest = { timer: Timer; }; -type EventHandler = (event: SidecarEvent | PtyEvent) => void; +type EventHandler = (event: SidecarEvent) => void; // ── State ── @@ -88,7 +86,7 @@ export function unregisterSidecar(id: string): void { } } -export function handleSidecarMessage(id: string, msg: SidecarEvent | PtyEvent): void { +export function handleSidecarMessage(id: string, msg: SidecarEvent): void { // Check if this is a response to a pending request if ('id' in msg && msg.id && pending.has(msg.id)) { const req = pending.get(msg.id)!; @@ -113,7 +111,7 @@ function findSidecarByCapability(cap: string): RegisteredSidecar | undefined { // ── Event dispatch ── -function dispatchEvent(msg: SidecarEvent | PtyEvent) { +function dispatchEvent(msg: SidecarEvent) { const handlers = eventHandlers.get(msg.type); if (handlers) { for (const handler of handlers) { @@ -141,7 +139,7 @@ export function on(eventType: string, handler: EventHandler): () => void { const DEFAULT_TIMEOUT_MS = 30_000; const LONG_TIMEOUT_MS = 6 * 60 * 1000; -function sendCommand(cap: string, cmd: SidecarCommand | PtyCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { +function sendCommand(cap: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { const sc = findSidecarByCapability(cap); if (!sc) { @@ -159,7 +157,7 @@ function sendCommand(cap: string, cmd: SidecarCommand | PtyCommand, timeoutMs = }); } -function sendFire(cap: string, cmd: SidecarCommand | PtyCommand): void { +function sendFire(cap: string, cmd: SidecarCommand): void { const sc = findSidecarByCapability(cap); if (sc) { sc.ws.send(JSON.stringify(cmd)); @@ -168,7 +166,7 @@ function sendFire(cap: string, cmd: SidecarCommand | PtyCommand): void { function sendCommandToSidecar( sc: RegisteredSidecar, - cmd: SidecarCommand | PtyCommand, + cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS, ): Promise { return new Promise((resolve, reject) => { @@ -182,7 +180,7 @@ function sendCommandToSidecar( }); } -function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand | PtyCommand): void { +function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand): void { sc.ws.send(JSON.stringify(cmd)); } @@ -318,18 +316,10 @@ export function onOpenCodeSession(handler: (sessionKey: string, sessionId: strin } // ── Terminal (PTY sidecar) ── +// +// Nothing here any more. The pty sidecar serves its own listener; `/api/terminal/*` is a proxy and +// `/api/terminal/ws` a byte relay, both keyed off the `pty:server` port like every other HTTP sidecar. -export function sendPtyCommand(cmd: PtyCommand): void { - sendFire('terminal', cmd); -} - -export async function sendPtyCommandAsync(cmd: PtyCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { - return sendCommand('terminal', cmd, timeoutMs); -} - -export function isTerminalConnected(): boolean { - return findSidecarByCapability('terminal') !== undefined; -} // ── VNC ── diff --git a/src/servers/sidecar/connect.ts b/src/servers/sidecar/connect.ts index 81edaf63..2632c289 100644 --- a/src/servers/sidecar/connect.ts +++ b/src/servers/sidecar/connect.ts @@ -1,8 +1,8 @@ -import type { SidecarCommand, SidecarEvent, PtyCommand, PtyEvent } from './protocol'; +import type { SidecarCommand, SidecarEvent } from './protocol'; import type { SidecarRegistration, RegistrationAck } from './registration-protocol'; -type AnyCommand = SidecarCommand | PtyCommand; -type AnyEvent = SidecarEvent | PtyEvent; +type AnyCommand = SidecarCommand; +type AnyEvent = SidecarEvent; type SidecarConnectorConfig = { apiUrl: string; // ws://127.0.0.1:5000/api/sidecar/register diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 8a4c9b2e..c21dd01c 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -76,6 +76,8 @@ export type SidecarEvent = | { type: 'invoiceshelf:server'; port: number } // Wallet — the sidecar reports where its HTTP server is listening (random port) on connect | { type: 'wallet:server'; port: number } + // PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect + | { type: 'pty:server'; port: number } // Generic | { type: 'error'; id?: string; error: string }; @@ -147,47 +149,10 @@ export type VncSessionInfo = { alive: boolean; }; -// ── PTY types ── - -// What officer knows about a terminal, and nothing more. The shell, its arguments, the home directory and -// whether the shell is sandboxed are the sidecar's own decisions — they used to travel in here, which is -// how officer ended up reading the owner's SHELL and HOME and hardcoding `host: true`. -export type PtyInitConfig = { - sessionId: string; - /** The folder the panel was opened on. `~`, `~/x` and absolute paths only; resolved by the sidecar. */ - cwd?: string; - cols?: number; - rows?: number; -}; - -// PTY commands (API → PTY sidecar) -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 } - // 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 = - | { type: 'pty:ready'; id: string; sessionId: string } - | { type: 'pty:output'; sessionId: string; data: string } - // 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: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; -}; +// ── PTY ── +// +// Nothing but a port crosses this socket now. The pty sidecar serves its own HTTP + WebSocket listener and +// the browser reaches it through a byte relay, so there is no command vocabulary left: pty:init, :input, +// :resize, :close and :list all lived here until the sidecar owned its own transport, and officer filtered +// one global output stream per session to feed them. The port arrives as the shared `pty:server` event +// that createSidecarProxy already listens for. diff --git a/src/servers/sidecar/pty/index.mjs b/src/servers/sidecar/pty/index.mjs index 78980134..46f99fb1 100644 --- a/src/servers/sidecar/pty/index.mjs +++ b/src/servers/sidecar/pty/index.mjs @@ -1,225 +1,45 @@ // Graceful shutdown process.on('SIGINT', () => { console.log('[pty-sidecar] shutting down...'); - for (const [id, session] of sessions) { - try { session.term.kill(); } catch { /* ignore */ } + store.killAll(); + if (ws) { + try { + ws.close(); + } catch { + /* ignore */ + } } - sessions.clear(); - if (ws) { try { ws.close(); } catch { /* ignore */ } } process.exit(0); }); process.on('SIGTERM', () => process.emit('SIGINT')); -import { join } from 'node:path'; import WebSocket from 'ws'; -import * as pty from 'node-pty'; +import * as store from './sessions.mjs'; +import { startServer } from './server.mjs'; 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`; - -// 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 ── +// This file is now only the registration socket. // -// These used to arrive inside every pty:init, which meant officer chose the owner's shell and read the -// owner's HOME to do it. They are this process's business: it is the one that spawns the thing. -// -// HOME_DIR mirrors `data-path.ts:getOwnerHomeDir` — on a host where the owner's real login home differs -// from this process's HOME, the shell should open in the former, like every other host-executing surface. -const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? process.cwd(); -const SHELL = { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }; -// A terminal is always a plain host shell: the owner is the only account and it is their own machine -// (`platform/CLAUDE.md` — do not add a jail without being asked). There used to be a second branch here -// for a bwrap sandbox, selected by `config.host`, which officer hardcoded to true. Nothing ever built the -// bwrap command it expected, on either side, so it could not have run — it is in git history if the -// decision is ever revisited, and the `ensureUserFiles` half of it duplicated -// `api/users/provision.ts:seedShellConfigs`, which is the live seeder of those templates. +// It used to be the whole sidecar: the platform sent pty:init / pty:input / pty:resize / pty:close, and +// officer filtered one global output stream per session and rewrapped every frame, double-JSON-encoded. +// Terminals live in `sessions.mjs` and are served over this process's own listener (`server.mjs`), which +// the browser reaches through a byte relay. All that crosses this socket now is the port number. -const resolveCwd = (cwd) => { - if (!cwd || cwd === '~') return HOME_DIR; - if (cwd.startsWith('~/')) return join(HOME_DIR, cwd.slice(2)); - if (cwd.startsWith('/')) return cwd; - // Relative paths have no meaning here — this process's cwd is the repo, not the user's folder. - return HOME_DIR; -}; +let serverPort = null; -/** @type {Map} */ -const sessions = new Map(); - -// ── Helpers ── - -// Always resolve the CURRENT registration socket, never one captured in a closure. -// -// `term.onData` used to close over the socket that was live when the session was created. Officer is a -// PM2 peer that restarts often, and each restart gives this process a brand new socket — so every -// pre-existing session went on writing to a closed one, where the readyState check below dropped it -// silently. The shell stayed alive and kept accepting input (that arrives on the new socket), but its -// output never came back: the terminal looked frozen until you closed the panel. Reading the module -// variable at send time is the whole fix. const sendJson = (msg) => { try { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(msg)); - } + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); } catch { // ignore } }; -const appendBuffer = (session, data) => { - session.buffer += data; - if (session.buffer.length > 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); - } -}; - -// ── Command handler ── - -async function handleCommand(msg) { - switch (msg.type) { - case 'pty:init': { - const { sessionId, config } = msg; - if (!sessionId) return; - - const existing = sessions.get(sessionId); - console.log(`[pty-sidecar] init sessionId=${sessionId} existing=${!!existing} total=${sessions.size}`); - - if (existing) { - // Re-attach. The scrollback goes out as `pty:replay`, not as ordinary output, because the client - // may already be showing some of it: after an officer restart the browser keeps its terminal and - // reconnects, so replaying blind appended a second copy of everything on screen. Marked as - // history, the client can reset and rebuild from it instead. - if (existing.buffer.length > 0) { - sendJson({ type: 'pty:replay', sessionId, data: existing.buffer }); - } - - // Resize PTY to new client dimensions - const cols = config.cols ?? existing.cols; - const rows = config.rows ?? existing.rows; - if (cols > 0 && rows > 0 && (cols !== existing.cols || rows !== existing.rows)) { - existing.cols = cols; - existing.rows = rows; - try { - existing.term.resize(cols, rows); - } catch { - // ignore - } - } - - sendJson({ type: 'pty:ready', id: msg.id, sessionId }); - return; - } - - // New session — spawn PTY - const cwd = resolveCwd(config.cwd); - const cols = config.cols ?? 80; - const rows = config.rows ?? 24; - - let term; - try { - term = pty.spawn(SHELL.command, SHELL.args, { - name: 'xterm-256color', - cols, - rows, - cwd, - // COLORTERM is how programs decide they may emit 24-bit colour — TERM only advertises 256. - // xterm.js renders truecolor fine, so without this we were throwing away colour depth for - // anything that checks (tmux with `*:RGB`, neovim, bat, delta, modern TUIs). - env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, - }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to start terminal'; - sendJson({ type: 'pty:output', sessionId, data: `\r\n[Terminal error] ${message}\r\n` }); - sendJson({ type: 'pty:exit', sessionId, exitCode: 1 }); - return; - } - - 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 }); - }); - - term.onExit(({ exitCode, signal }) => { - console.log(`[pty-sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`); - sendJson({ type: 'pty:exit', sessionId, exitCode, signal }); - sessions.delete(sessionId); - }); - - sendJson({ type: 'pty:ready', id: msg.id, sessionId }); - return; - } - - case 'pty:input': { - const session = sessions.get(msg.sessionId); - if (session) { - session.term.write(msg.data ?? ''); - } - break; - } - - case 'pty:resize': { - const session = sessions.get(msg.sessionId); - if (session && msg.cols > 0 && msg.rows > 0) { - session.cols = msg.cols; - session.rows = msg.rows; - try { - session.term.resize(msg.cols, msg.rows); - } catch { - // PTY may have already exited - } - } - 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) { - try { - session.term.kill(); - } catch { - // ignore - } - sessions.delete(msg.sessionId); - } - break; - } - } -} - // ── Connect to API server with reconnect ── let ws = null; @@ -243,18 +63,18 @@ function connect() { reconnectAttempt = 0; console.log('[pty-sidecar] connected, sending registration...'); sendJson({ type: 'register', name: 'pty', capabilities: ['terminal'] }); + // Re-announce on every reconnect: officer forgets the port when the socket drops, and this process + // keeps the same listener across officer restarts. + if (serverPort) sendJson({ type: 'pty:server', port: serverPort }); }); ws.on('message', (data) => { try { const msg = JSON.parse(typeof data === 'string' ? data : data.toString()); - if (msg.type === 'registered') { console.log(`[pty-sidecar] registered with API server (id=${msg.id})`); - return; + if (serverPort) sendJson({ type: 'pty:server', port: serverPort }); } - - handleCommand(msg); } catch { // skip malformed messages } @@ -284,5 +104,8 @@ function scheduleReconnect() { }, delay); } -// Start connecting -connect(); +// Listener first, so the port is known before the first registration goes out. +startServer().then((port) => { + serverPort = port; + connect(); +}); diff --git a/src/servers/sidecar/pty/index.test.ts b/src/servers/sidecar/pty/index.test.ts index 8ed7af62..8fdac1c9 100644 --- a/src/servers/sidecar/pty/index.test.ts +++ b/src/servers/sidecar/pty/index.test.ts @@ -1,23 +1,51 @@ import { describe, test, expect, afterAll } from 'bun:test'; import type { ServerWebSocket, Subprocess } from 'bun'; -// Integration test for the one thing about the pty sidecar that cannot be reasoned about from the code -// alone: what happens to a live shell when officer goes away and comes back. It stands up a fake -// registration socket, runs the real sidecar against it, then kills the socket and rebinds the same port -// the way `pm2 restart officer` does. +// Integration test for the things about the pty sidecar that cannot be reasoned about from the code alone: +// that it serves its own transport, that a shell outlives both its viewer and officer, and that re-attach +// replays rather than re-spawns. // -// Nothing here touches the running officer — the sidecar dials API_URL, which is overridden per spawn. +// The fake officer exists only to receive the announced port — officer is not in the data path any more, +// so the browser side is spoken directly to the sidecar's own listener. Nothing here touches the running +// officer: the sidecar dials API_URL, overridden per spawn. const SIDECAR = 'src/servers/sidecar/pty/index.mjs'; type Frame = Record; -// One fake officer. `stop()` drops the socket; a new instance on the same port is the restart. -function fakeOfficer(port?: number) { - let socket: ServerWebSocket | null = null; +function collector() { const frames: Frame[] = []; const waiters: Array<{ match: (f: Frame) => boolean; resolve: (f: Frame) => void }> = []; + const push = (frame: Frame) => { + frames.push(frame); + for (let i = waiters.length - 1; i >= 0; i--) { + if (waiters[i]!.match(frame)) waiters.splice(i, 1)[0]!.resolve(frame); + } + }; + + const await_ = (match: (f: Frame) => boolean, what: string, timeoutMs = 15_000) => + new Promise((resolve, reject) => { + const seen = frames.find(match); + if (seen) return resolve(seen); + const timer = setTimeout(() => reject(new Error(`timed out waiting for ${what}`)), timeoutMs); + waiters.push({ + match, + resolve: (f) => { + clearTimeout(timer); + resolve(f); + }, + }); + }); + + return { frames, push, await_ }; +} + +/** A fake officer: just enough registration socket to receive `pty:server`. */ +function fakeOfficer(port?: number) { + let socket: ServerWebSocket | null = null; + const { push, await_ } = collector(); + const server = Bun.serve({ port: port ?? 0, fetch(req, srv) { @@ -27,13 +55,10 @@ function fakeOfficer(port?: number) { websocket: { open(ws) { socket = ws; + ws.send(JSON.stringify({ type: 'registered', id: 'sc_test' })); }, message(_ws, raw) { - const frame = JSON.parse(String(raw)) as Frame; - frames.push(frame); - for (let i = waiters.length - 1; i >= 0; i--) { - if (waiters[i]!.match(frame)) waiters.splice(i, 1)[0]!.resolve(frame); - } + push(JSON.parse(String(raw)) as Frame); }, close() { socket = null; @@ -43,49 +68,54 @@ function fakeOfficer(port?: number) { return { port: server.port, - send: (msg: Frame) => socket?.send(JSON.stringify(msg)), - /** Resolve on the first frame matching `match`, including ones already received. */ - await: (match: (f: Frame) => boolean, timeoutMs = 15_000) => - new Promise((resolve, reject) => { - const seen = frames.find(match); - if (seen) return resolve(seen); - const timer = setTimeout(() => reject(new Error(`timed out waiting for a frame`)), timeoutMs); - waiters.push({ - match, - resolve: (f) => { - clearTimeout(timer); - resolve(f); - }, - }); - }), - /** Output frames for one session, concatenated — the terminal's visible text. */ - outputFor: (sessionId: string) => - frames - .filter((f) => f.type === 'pty:output' && f.sessionId === sessionId) - .map((f) => f.data as string) - .join(''), + await: (match: (f: Frame) => boolean, what: string) => await_(match, what), stop: () => server.stop(true), + get connected() { + return socket !== null; + }, }; } -const isOutput = (sessionId: string, needle: string) => (f: Frame) => - f.type === 'pty:output' && f.sessionId === sessionId && String(f.data).includes(needle); +/** A fake browser. The sidecar's socket contract IS the one the browser already speaks. */ +function client(wsBase: string, query: string) { + const ws = new WebSocket(`${wsBase}/ws?${query}`); + const { frames, push, await_ } = collector(); + + ws.addEventListener('message', (ev) => push(JSON.parse(String(ev.data)) as Frame)); + + return { + open: () => + new Promise((resolve) => + ws.readyState === WebSocket.OPEN ? resolve() : ws.addEventListener('open', () => resolve()), + ), + send: (msg: Frame) => ws.send(JSON.stringify(msg)), + await: (match: (f: Frame) => boolean, what: string) => await_(match, what), + outputSoFar: () => + frames + .filter((f) => f.type === 'output') + .map((f) => String(f.data)) + .join(''), + close: () => ws.close(), + }; +} + +const sawOutput = (needle: string) => (f: Frame) => f.type === 'output' && String(f.data).includes(needle); let child: Subprocess | null = null; afterAll(() => child?.kill()); describe('pty sidecar', () => { - test('a shell keeps streaming output after officer restarts under it', async () => { - const sessionId = 'test-restart'; + test('serves its own transport; a shell outlives both its viewer and officer', async () => { + const sessionId = 'test-transport'; let officer = fakeOfficer(); - const port = officer.port; + const officerPort = officer.port; child = Bun.spawn(['node', SIDECAR], { env: { ...process.env, - API_URL: `ws://127.0.0.1:${port}`, - // The sidecar now chooses the shell and the home itself, so the test pins both rather than - // spawning the owner's interactive zsh (which would read their rc files and their history). + API_URL: `ws://127.0.0.1:${officerPort}`, + // The sidecar chooses the shell and the home itself, so pin both rather than spawning the owner's + // interactive zsh, which would read their rc files and their history. SHELL: '/bin/sh', HOME_DIR: '/tmp', ENV: '/dev/null', @@ -94,41 +124,62 @@ describe('pty sidecar', () => { stderr: 'ignore', }); - await officer.await((f) => f.type === 'register' && f.capabilities?.includes('terminal')); + // The only thing that crosses the registration socket now. + const announced = await officer.await((f) => f.type === 'pty:server' && typeof f.port === 'number', 'pty:server'); + const httpBase = `http://127.0.0.1:${announced.port}`; + const wsBase = `ws://127.0.0.1:${announced.port}`; - // `~` is resolved by the sidecar against its own HOME_DIR, not by officer. - officer.send({ type: 'pty:init', id: 'i1', sessionId, config: { sessionId, cwd: '~', cols: 80, rows: 24 } }); - await officer.await((f) => f.type === 'pty:ready' && f.sessionId === sessionId); + const a = client(wsBase, `sessionId=${sessionId}&cwd=~&cols=80&rows=24`); + await a.open(); - officer.send({ type: 'pty:input', id: 'in0', sessionId, data: 'pwd\n' }); - await officer.await(isOutput(sessionId, '/tmp')); + // `~` is resolved by the sidecar against its own HOME_DIR. + a.send({ type: 'input', data: 'pwd\n' }); + await a.await(sawOutput('/tmp'), 'pwd output'); - officer.send({ type: 'pty:input', id: 'in1', sessionId, data: 'echo before-restart\n' }); - await officer.await(isOutput(sessionId, 'before-restart')); + a.send({ type: 'input', data: 'echo before-restart\n' }); + await a.await(sawOutput('before-restart'), 'before-restart'); - // ── the restart ── + // ── officer restarts ── + // It is not in the data path, so this is a non-event for the shell. Under the old translating bridge + // every keystroke and every byte of output crossed the registration socket. officer.stop(); - officer = fakeOfficer(port); - await officer.await((f) => f.type === 'register'); + a.send({ type: 'input', data: 'echo during-outage\n' }); + await a.await(sawOutput('during-outage'), 'output while officer is down'); - // The shell is the same process; only officer changed. Before the sendJson fix this input was - // accepted and executed, but its output went to the socket captured at init time and vanished. - officer.send({ type: 'pty:input', id: 'in2', sessionId, data: 'echo after-restart\n' }); - await officer.await(isOutput(sessionId, 'after-restart')); + officer = fakeOfficer(officerPort); + await officer.await((f) => f.type === 'pty:server', 're-announced port'); - // Re-attaching replays the scrollback, marked as history so the client can rebuild rather than - // append — and it contains what happened on both sides of the restart. - officer.send({ type: 'pty:init', id: 'i2', sessionId, config: { sessionId, cols: 80, rows: 24 } }); - const replay = await officer.await((f) => f.type === 'pty:replay' && f.sessionId === sessionId); + // ── the viewer goes away, a new one attaches ── + a.close(); + const b = client(wsBase, `sessionId=${sessionId}&cols=80&rows=24`); + await b.open(); + + // Re-attach replays scrollback as history rather than ordinary output, so the client rebuilds instead + // of appending a second copy — and it spans everything, including what happened while officer was down. + const replay = await b.await((f) => f.type === 'replay', 'replay'); expect(String(replay.data)).toContain('before-restart'); - expect(String(replay.data)).toContain('after-restart'); + expect(String(replay.data)).toContain('during-outage'); + expect(b.outputSoFar()).not.toContain('before-restart'); - // Re-attach must not spawn a second shell, and must not replay as ordinary output. - await officer.await((f) => f.type === 'pty:ready' && f.id === 'i2'); - expect(officer.outputFor(sessionId)).not.toContain('before-restart'); + // Same shell, not a second one. + b.send({ type: 'input', data: 'echo after-reattach\n' }); + await b.await(sawOutput('after-reattach'), 'after-reattach'); - officer.send({ type: 'pty:close', id: 'c1', sessionId }); - await officer.await((f) => f.type === 'pty:exit' && f.sessionId === sessionId); + // ── the sidecar's own HTTP surface ── + const listed = (await fetch(`${httpBase}/_officer/sessions`).then((r) => r.json())) as { sessions: any[] }; + const entry = listed.sessions.find((s) => s.sessionId === sessionId); + expect(entry).toBeTruthy(); + expect(entry.pid).toBeGreaterThan(0); + expect(entry.clients).toBe(1); + + const killed = await fetch(`${httpBase}/_officer/sessions/${sessionId}`, { method: 'DELETE' }); + expect(killed.status).toBe(200); + await b.await((f) => f.type === 'exit', 'exit after kill'); + + const after = (await fetch(`${httpBase}/_officer/sessions`).then((r) => r.json())) as { sessions: any[] }; + expect(after.sessions.find((s) => s.sessionId === sessionId)).toBeUndefined(); + + b.close(); officer.stop(); }, 30_000); }); diff --git a/src/servers/sidecar/pty/server.mjs b/src/servers/sidecar/pty/server.mjs new file mode 100644 index 00000000..c59de87c --- /dev/null +++ b/src/servers/sidecar/pty/server.mjs @@ -0,0 +1,94 @@ +import http from 'node:http'; +import { WebSocketServer } from 'ws'; +import * as store from './sessions.mjs'; + +// The sidecar's own listener — the thing that makes this a real sidecar rather than a set of commands the +// platform drives. It binds an ephemeral loopback port and announces it; the platform authenticates the +// browser and relays bytes here without reading them. +// +// The socket protocol is the one the browser already speaks, unchanged: {input,resize} in, +// {output,replay,exit,panel-refresh} out. That is deliberate — the frontend did not have to move for the +// transport to. + +const wsSend = (socket) => ({ + send: (msg) => { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg)); + }, +}); + +const json = (res, status, body) => { + const payload = JSON.stringify(body); + res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }); + res.end(payload); +}; + +export function startServer() { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + + // Officer-owned routes, reached through the platform's authenticated proxy. + if (url.pathname === '/_officer/sessions' && req.method === 'GET') { + return json(res, 200, { sessions: store.list() }); + } + + const killMatch = url.pathname.match(/^\/_officer\/sessions\/([^/]+)$/); + if (killMatch && req.method === 'DELETE') { + const killed = store.kill(decodeURIComponent(killMatch[1])); + return json(res, killed ? 200 : 404, { ok: killed }); + } + + // The claude-done hook: tell attached terminals to refresh their panel. + if (url.pathname === '/_officer/panel-refresh' && req.method === 'POST') { + store.broadcastPanelRefresh(); + return json(res, 200, { ok: true }); + } + + json(res, 404, { error: 'not found' }); + }); + + const wss = new WebSocketServer({ server, path: '/ws' }); + + wss.on('connection', (socket, req) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const sessionId = url.searchParams.get('sessionId'); + if (!sessionId) { + socket.close(1008, 'sessionId required'); + return; + } + + const client = wsSend(socket); + const session = store.attach(sessionId, client, { + cwd: url.searchParams.get('cwd') ?? undefined, + cols: Number(url.searchParams.get('cols')) || 0, + rows: Number(url.searchParams.get('rows')) || 0, + }); + if (!session) { + socket.close(1011, 'failed to start terminal'); + return; + } + + socket.on('message', (raw) => { + let msg; + try { + msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); + } catch { + return; // malformed frame + } + if (msg.type === 'input') store.write(sessionId, msg.data); + else if (msg.type === 'resize') store.resize(sessionId, msg.cols, msg.rows); + }); + + // Detach, never kill: the shell outlives the viewer, which is what makes re-attach work at all. + socket.on('close', () => store.detach(sessionId, client)); + socket.on('error', () => store.detach(sessionId, client)); + }); + + return new Promise((resolve) => { + // Port 0 — the OS picks, and the platform learns it from the registration socket. Loopback only. + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + console.log(`[pty-sidecar] listening on http://127.0.0.1:${port}`); + resolve(port); + }); + }); +} diff --git a/src/servers/sidecar/pty/sessions.mjs b/src/servers/sidecar/pty/sessions.mjs new file mode 100644 index 00000000..aaaf46f7 --- /dev/null +++ b/src/servers/sidecar/pty/sessions.mjs @@ -0,0 +1,187 @@ +import { join } from 'node:path'; +import * as pty from 'node-pty'; + +// The shell store. Everything about running a terminal lives here: what shell, where it opens, how much +// scrollback is kept, who is watching. The platform holds none of it — it authenticates a browser and +// relays bytes to this process, and that is the whole of its involvement. + +// 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; + +// HOME_DIR mirrors `data-path.ts:getOwnerHomeDir` — on a host where the owner's real login home differs +// from this process's HOME, the shell should open in the former, like every other host-executing surface. +const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? process.cwd(); +const SHELL = { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }; + +// A terminal is always a plain host shell: the owner is the only account and it is their own machine +// (`platform/CLAUDE.md` — do not add a jail without being asked). + +/** + * @typedef {{ + * term: import('node-pty').IPty, buffer: string, cols: number, rows: number, + * createdAt: number, lastActivityAt: number, title: string, pid: number|undefined, + * clients: Set<{ send: (msg: object) => void }>, + * }} Session + */ + +/** @type {Map} */ +const sessions = new Map(); + +const resolveCwd = (cwd) => { + if (!cwd || cwd === '~') return HOME_DIR; + if (cwd.startsWith('~/')) return join(HOME_DIR, cwd.slice(2)); + if (cwd.startsWith('/')) return cwd; + // Relative paths have no meaning here — this process's cwd is the repo, not the user's folder. + return HOME_DIR; +}; + +const appendBuffer = (session, data) => { + session.buffer += data; + if (session.buffer.length > 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 when a single line is enormous. + 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); + } +}; + +const broadcast = (session, msg) => { + for (const client of session.clients) { + try { + client.send(msg); + } catch { + // client went away between the check and the write + } + } +}; + +/** Attach a client to a session, spawning the shell if this is the first time we've seen the id. */ +export function attach(sessionId, client, { cwd, cols, rows } = {}) { + let session = sessions.get(sessionId); + + if (session) { + session.clients.add(client); + // Scrollback goes out as `replay`, not as ordinary output: the client may already be showing some of + // it, so it resets and rebuilds from this rather than appending a second copy. + if (session.buffer.length > 0) client.send({ type: 'replay', data: session.buffer }); + if (cols > 0 && rows > 0 && (cols !== session.cols || rows !== session.rows)) resize(sessionId, cols, rows); + return session; + } + + const spawnCols = cols > 0 ? cols : 80; + const spawnRows = rows > 0 ? rows : 24; + + let term; + try { + term = pty.spawn(SHELL.command, SHELL.args, { + name: 'xterm-256color', + cols: spawnCols, + rows: spawnRows, + cwd: resolveCwd(cwd), + // COLORTERM is how programs decide they may emit 24-bit colour — TERM only advertises 256. + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to start terminal'; + client.send({ type: 'output', data: `\r\n[Terminal error] ${message}\r\n` }); + client.send({ type: 'exit', exitCode: 1 }); + return null; + } + + const now = Date.now(); + session = { + term, + buffer: '', + cols: spawnCols, + rows: spawnRows, + createdAt: now, + lastActivityAt: now, + title: '', + pid: term.pid, + clients: new Set([client]), + }; + sessions.set(sessionId, session); + + term.onData((output) => { + appendBuffer(session, output); + session.lastActivityAt = Date.now(); + // The title the shell sets for itself (OSC 0/2 — usually the running command). 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]; + broadcast(session, { type: 'output', data: output }); + }); + + term.onExit(({ exitCode, signal }) => { + console.log(`[pty-sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`); + broadcast(session, { type: 'exit', exitCode, signal }); + sessions.delete(sessionId); + }); + + return session; +} + +/** A client went away. The shell stays: it is re-attachable, and killing it is an explicit act. */ +export function detach(sessionId, client) { + sessions.get(sessionId)?.clients.delete(client); +} + +export function write(sessionId, data) { + sessions.get(sessionId)?.term.write(data ?? ''); +} + +export function resize(sessionId, cols, rows) { + const session = sessions.get(sessionId); + if (!session || !(cols > 0) || !(rows > 0)) return; + session.cols = cols; + session.rows = rows; + try { + session.term.resize(cols, rows); + } catch { + // the shell died between the lookup and the resize + } +} + +export function kill(sessionId) { + const session = sessions.get(sessionId); + if (!session) return false; + try { + session.term.kill(); + } catch { + // already gone + } + sessions.delete(sessionId); + return true; +} + +export function list() { + return [...sessions.entries()].map(([sessionId, s]) => ({ + sessionId, + cols: s.cols, + rows: s.rows, + createdAt: s.createdAt, + lastActivityAt: s.lastActivityAt, + title: s.title || undefined, + pid: s.pid, + clients: s.clients.size, + })); +} + +/** Tell every attached client to refresh its panel — fired by the platform's claude-done hook. */ +export function broadcastPanelRefresh() { + for (const session of sessions.values()) broadcast(session, { type: 'panel-refresh' }); +} + +export function killAll() { + for (const session of sessions.values()) { + try { + session.term.kill(); + } catch { + // ignore + } + } + sessions.clear(); +}