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>
95 lines
3.4 KiB
JavaScript
95 lines
3.4 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
}
|