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
+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 ──