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