pty: the sidecar owns its own transport

Terminals were a set of commands the platform drove. Officer sent pty:init / pty:input /
pty:resize / pty:close / pty:list over the registration socket, subscribed to ONE global
output stream, filtered every frame down to a session and rewrapped it — double
JSON-encoded — on the way out. That is terminal knowledge living in the process whose job
is authentication, and it made officer part of the data path for every keystroke.

The sidecar now serves its own loopback HTTP + WebSocket listener and announces the port
as `pty:server`, like every other HTTP sidecar. Officer authenticates the upgrade and
relays frames without reading them.

Split into three files, because "the sidecar" was one:
- sessions.mjs — the shell store. Spawn, attach, detach, resize, kill, scrollback, the
  OSC-title scrape. Clients are a Set per session, so two panels can watch one shell.
- server.mjs — the listener. /ws speaks the browser's existing contract unchanged
  ({input,resize} in, {output,replay,exit,panel-refresh} out), plus /_officer/sessions,
  DELETE /_officer/sessions/:id and POST /_officer/panel-refresh.
- index.mjs — the registration socket, and nothing else. It carries a port now.

On the platform side /api/terminal/* becomes createSidecarProxy, deleting the hand-rolled
router from two days ago, and websocket.ts drops from a translating bridge to a byte relay
modelled on the vault one. The whole PtyCommand/PtyEvent/PtyInitConfig/PtySessionInfo
vocabulary is gone from protocol.ts, connect.ts and sidecar-registry.ts.

broadcastPanelRefresh is now a POST to the sidecar: officer no longer holds terminal
sockets to loop over. Fire-and-forget — a missed refresh is a stale panel, not a failure.

The frontend did not move. The sidecar speaks what the browser already spoke.

The integration test was rewritten against the new shape, and tests something stronger than
before: officer is stopped mid-session and the shell keeps streaming, because officer is
not in the path at all. It also covers re-attach replay, the session list and kill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 10:54:35 +00:00
co-authored by Claude Opus 5
parent fccf212fe5
commit 7129cd82e6
11 changed files with 534 additions and 477 deletions
-32
View File
@@ -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 });
});
@@ -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;
+72 -106
View File
@@ -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<WSData>;
sessionId: string;
unsubs: Array<() => void>;
type UpstreamState = {
ws: WebSocket | null;
queue: (string | Uint8Array<ArrayBuffer>)[];
closed: boolean;
};
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
const upstreams = new Map<ServerWebSocket<TerminalWSData>, UpstreamState>();
let idCounter = 0;
function nextId(): string {
return `pty_${Date.now()}_${++idCounter}`;
}
const asPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
const closeClient = (ws: ServerWebSocket<TerminalWSData>, 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<WSData>) {
const { email, username } = ws.data;
open(ws: ServerWebSocket<TerminalWSData>) {
// 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
upstream.onmessage = (ev) => {
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<WSData>, 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 <path>\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.
}
ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer));
} catch {
// ignore malformed messages
/* client gone */
}
};
upstream.onclose = () => closeClient(ws, 1000, 'upstream closed');
upstream.onerror = () => closeClient(ws, 1011, 'upstream error');
},
close(ws: ServerWebSocket<WSData>) {
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<TerminalWSData>, 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<TerminalWSData>) {
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(() => {});
};
+1 -1
View File
@@ -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
+10 -20
View File
@@ -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<any> {
function sendCommand(cap: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
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<any> {
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<PtyEvent> {
return sendCommand('terminal', cmd, timeoutMs);
}
export function isTerminalConnected(): boolean {
return findSidecarByCapability('terminal') !== undefined;
}
// ── VNC ──
+3 -3
View File
@@ -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
+9 -44
View File
@@ -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.
+24 -201
View File
@@ -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<string, { term: import('node-pty').IPty, buffer: string, cols: number, rows: number }>} */
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
// Listener first, so the port is known before the first registration goes out.
startServer().then((port) => {
serverPort = port;
connect();
});
+118 -67
View File
@@ -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<string, any>;
// One fake officer. `stop()` drops the socket; a new instance on the same port is the restart.
function fakeOfficer(port?: number) {
let socket: ServerWebSocket<unknown> | 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<Frame>((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<unknown> | 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<Frame>((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<void>((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);
});
+94
View File
@@ -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);
});
});
}
+187
View File
@@ -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<string, Session>} */
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();
}