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>
355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import type { ServerWebSocket } from 'bun';
|
|
import type {
|
|
SidecarCommand,
|
|
SidecarEvent,
|
|
ClaudeState,
|
|
ClaudeSpawnParams,
|
|
ClaudeSpawnStreamingParams,
|
|
ClaudeCodeResult,
|
|
OpenCodeRunParams,
|
|
VncStartParams,
|
|
VncSessionInfo,
|
|
} from './sidecar/protocol';
|
|
import type { SidecarRegistration } from './sidecar/registration-protocol';
|
|
import type { TurnMessage } from './api/chat/types';
|
|
|
|
// ── Types ──
|
|
|
|
type RegisteredSidecar = {
|
|
id: string;
|
|
name: string;
|
|
capabilities: string[];
|
|
ws: ServerWebSocket<any>;
|
|
};
|
|
|
|
type PendingRequest = {
|
|
resolve: (value: any) => void;
|
|
reject: (error: Error) => void;
|
|
timer: Timer;
|
|
};
|
|
|
|
type EventHandler = (event: SidecarEvent) => void;
|
|
|
|
// ── State ──
|
|
|
|
const sidecars = new Map<string, RegisteredSidecar>();
|
|
const pending = new Map<string, PendingRequest>();
|
|
const eventHandlers = new Map<string, Set<EventHandler>>();
|
|
let cachedState: ClaudeState | null = null;
|
|
let idCounter = 0;
|
|
|
|
function nextId(): string {
|
|
return `sr_${Date.now()}_${++idCounter}`;
|
|
}
|
|
|
|
// ── Registration ──
|
|
|
|
export function registerSidecar(ws: ServerWebSocket<any>, registration: SidecarRegistration): string {
|
|
// If a sidecar with the same name is already registered, unregister it first
|
|
for (const [id, sc] of sidecars) {
|
|
if (sc.name === registration.name) {
|
|
console.log(`[registry] replacing existing sidecar "${registration.name}" (id=${id})`);
|
|
unregisterSidecar(id);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const id = `sc_${registration.name}_${Date.now()}`;
|
|
sidecars.set(id, {
|
|
id,
|
|
name: registration.name,
|
|
capabilities: registration.capabilities,
|
|
ws,
|
|
});
|
|
console.log(
|
|
`[registry] registered sidecar "${registration.name}" (id=${id}, capabilities=[${registration.capabilities.join(', ')}])`,
|
|
);
|
|
return id;
|
|
}
|
|
|
|
export function unregisterSidecar(id: string): void {
|
|
const sc = sidecars.get(id);
|
|
if (!sc) return;
|
|
sidecars.delete(id);
|
|
console.log(`[registry] unregistered sidecar "${sc.name}" (id=${id})`);
|
|
|
|
// Reject all pending requests for this sidecar
|
|
for (const [reqId, req] of pending) {
|
|
clearTimeout(req.timer);
|
|
req.reject(new Error(`Sidecar "${sc.name}" disconnected`));
|
|
pending.delete(reqId);
|
|
}
|
|
|
|
// Clear cached state if the claude sidecar disconnects
|
|
if (sc.capabilities.includes('proxy')) {
|
|
cachedState = null;
|
|
}
|
|
}
|
|
|
|
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)!;
|
|
pending.delete(msg.id);
|
|
clearTimeout(req.timer);
|
|
req.resolve(msg);
|
|
return;
|
|
}
|
|
|
|
// Otherwise dispatch as event
|
|
dispatchEvent(msg);
|
|
}
|
|
|
|
// ── Lookup ──
|
|
|
|
function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
|
|
for (const sc of sidecars.values()) {
|
|
if (sc.capabilities.includes(cap)) return sc;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// ── Event dispatch ──
|
|
|
|
function dispatchEvent(msg: SidecarEvent) {
|
|
const handlers = eventHandlers.get(msg.type);
|
|
if (handlers) {
|
|
for (const handler of handlers) {
|
|
try {
|
|
handler(msg);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function on(eventType: string, handler: EventHandler): () => void {
|
|
if (!eventHandlers.has(eventType)) {
|
|
eventHandlers.set(eventType, new Set());
|
|
}
|
|
eventHandlers.get(eventType)!.add(handler);
|
|
return () => {
|
|
eventHandlers.get(eventType)?.delete(handler);
|
|
};
|
|
}
|
|
|
|
// ── Command sending ──
|
|
|
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
const LONG_TIMEOUT_MS = 6 * 60 * 1000;
|
|
|
|
function sendCommand(cap: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const sc = findSidecarByCapability(cap);
|
|
if (!sc) {
|
|
reject(new Error(`No sidecar with capability "${cap}" is connected`));
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
pending.delete((cmd as any).id);
|
|
reject(new Error(`Sidecar command ${cmd.type} timed out`));
|
|
}, timeoutMs);
|
|
|
|
pending.set((cmd as any).id, { resolve, reject, timer });
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
});
|
|
}
|
|
|
|
function sendFire(cap: string, cmd: SidecarCommand): void {
|
|
const sc = findSidecarByCapability(cap);
|
|
if (sc) {
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
}
|
|
}
|
|
|
|
function sendCommandToSidecar(
|
|
sc: RegisteredSidecar,
|
|
cmd: SidecarCommand,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pending.delete((cmd as any).id);
|
|
reject(new Error(`Sidecar command ${cmd.type} timed out`));
|
|
}, timeoutMs);
|
|
|
|
pending.set((cmd as any).id, { resolve, reject, timer });
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
});
|
|
}
|
|
|
|
function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand): void {
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
}
|
|
|
|
// ── Waiting for a sidecar to appear ──
|
|
|
|
// Officer no longer spawns any sidecar; PM2 owns every one of them. The only thing left to handle is
|
|
// startup order — PM2 brings `officer` and its peers up together, so the first request after a boot can
|
|
// arrive a beat before the sidecar has finished dialling in. Wait briefly rather than failing the
|
|
// request. (This replaces ~77 lines of spawn-and-poll: `ensureClaudeSidecar`,
|
|
// `spawnAndWaitForRegistration`, and the per-email `claudeProcs`/`claudeSpawnWaiters` maps.)
|
|
const CAPABILITY_WAIT_MS = 15_000;
|
|
const CAPABILITY_POLL_MS = 100;
|
|
|
|
async function waitForCapability(cap: string, timeoutMs = CAPABILITY_WAIT_MS): Promise<RegisteredSidecar> {
|
|
const existing = findSidecarByCapability(cap);
|
|
if (existing) return existing;
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
await Bun.sleep(CAPABILITY_POLL_MS);
|
|
const sc = findSidecarByCapability(cap);
|
|
if (sc) return sc;
|
|
}
|
|
throw new Error(`No sidecar with capability "${cap}" registered within ${timeoutMs}ms`);
|
|
}
|
|
|
|
// ── Public API ──
|
|
|
|
export function isConnected(): boolean {
|
|
return findSidecarByCapability('proxy') !== undefined;
|
|
}
|
|
|
|
export function getCachedState(): ClaudeState | null {
|
|
return cachedState;
|
|
}
|
|
|
|
export async function syncState(): Promise<ClaudeState> {
|
|
const res = await sendCommand('proxy', { type: 'state:sync', id: nextId() });
|
|
if (res.type === 'state:sync') {
|
|
cachedState = res.state;
|
|
return res.state;
|
|
}
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export async function getProxySecret(): Promise<string> {
|
|
if (cachedState?.proxySecret) return cachedState.proxySecret;
|
|
const res = await sendCommand('proxy', { type: 'proxy:secret', id: nextId() });
|
|
if (res.type === 'proxy:secret') return res.secret;
|
|
throw new Error('Failed to get proxy secret');
|
|
}
|
|
|
|
export function getProxySecretSync(): string {
|
|
return cachedState?.proxySecret ?? '';
|
|
}
|
|
|
|
// ── Claude Code (the `officer-agent` sidecar, capability 'claude') ──
|
|
|
|
// Single-user platform, so there is exactly one agent sidecar and it is found by capability like every
|
|
// other one. The `email` on the params is still passed through to the sidecar — it needs it to resolve
|
|
// paths — but officer no longer uses it to *locate* anything.
|
|
|
|
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
|
const sc = await waitForCapability('claude');
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
|
|
if (res.type === 'claude:result') return res.result;
|
|
if (res.type === 'claude:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
|
|
const sc = await waitForCapability('claude');
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn-streaming', id: nextId(), params });
|
|
if (res.type === 'claude:spawned') return;
|
|
if (res.type === 'claude:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export function killClaude(sessionKey: string): void {
|
|
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey });
|
|
}
|
|
|
|
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
|
|
export function interruptClaude(sessionKey: string): void {
|
|
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey });
|
|
}
|
|
|
|
export function clearClaudeSession(sessionKey: string): void {
|
|
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
|
|
}
|
|
|
|
// Turn output arrives finished and already durable: the agent translated it and committed it to
|
|
// chat_session_events, and `seq` is its cursor id there. Officer relays it — it no longer builds or
|
|
// persists chat messages for this harness.
|
|
export function onClaudeMessage(handler: (sessionKey: string, msg: TurnMessage, seq?: number) => void): () => void {
|
|
return on('claude:message', (ev) => {
|
|
if (ev.type !== 'claude:message') return;
|
|
const msg = ev as SidecarEvent & { type: 'claude:message' };
|
|
handler(msg.sessionKey, msg.msg, msg.seq);
|
|
});
|
|
}
|
|
|
|
// ── OpenCode Code (single sidecar, capability 'opencode') ──
|
|
|
|
export async function spawnOpenCodeStreaming(params: OpenCodeRunParams): Promise<void> {
|
|
const res = await sendCommand('opencode', { type: 'opencode:run-streaming', id: nextId(), params });
|
|
if (res.type === 'opencode:spawned') return;
|
|
if (res.type === 'opencode:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export function killOpenCode(sessionKey: string): void {
|
|
sendFire('opencode', { type: 'opencode:kill', id: nextId(), sessionKey });
|
|
}
|
|
|
|
export function onOpenCodeMessage(handler: (sessionKey: string, msg: TurnMessage, seq?: number) => void): () => void {
|
|
return on('opencode:message', (ev) => {
|
|
if (ev.type !== 'opencode:message') return;
|
|
const msg = ev as SidecarEvent & { type: 'opencode:message' };
|
|
handler(msg.sessionKey, msg.msg, msg.seq);
|
|
});
|
|
}
|
|
|
|
export function onOpenCodeSession(handler: (sessionKey: string, sessionId: string) => void): () => void {
|
|
return on('opencode:session', (msg) => {
|
|
if (msg.type === 'opencode:session') {
|
|
handler(
|
|
(msg as SidecarEvent & { type: 'opencode:session' }).sessionKey,
|
|
(msg as SidecarEvent & { type: 'opencode:session' }).sessionId,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 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.
|
|
|
|
|
|
// ── VNC ──
|
|
|
|
export async function startVnc(params: VncStartParams): Promise<{ port: number; display: number }> {
|
|
const res = await sendCommand('vnc', { type: 'vnc:start', id: nextId(), params });
|
|
if (res.type === 'vnc:started') return { port: res.port, display: res.display };
|
|
if (res.type === 'vnc:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
// Provision the VNC password without starting a server. The desktop UI needs it before it can open
|
|
// the WebSocket that would start one, so asking vnc:start here would be circular.
|
|
export async function ensureVncPassword(email: string): Promise<string> {
|
|
const res = await sendCommand('vnc', { type: 'vnc:ensure-password', id: nextId(), email });
|
|
if (res.type === 'vnc:password') return res.password;
|
|
if (res.type === 'vnc:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response from VNC sidecar');
|
|
}
|
|
|
|
export function stopVnc(email: string): void {
|
|
sendFire('vnc', { type: 'vnc:stop', id: nextId(), email });
|
|
}
|
|
|
|
export async function getVncStatus(email: string): Promise<VncSessionInfo | null> {
|
|
const res = await sendCommand('vnc', { type: 'vnc:status', id: nextId(), email });
|
|
if (res.type === 'vnc:status') return res.session;
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export function isVncConnected(): boolean {
|
|
return findSidecarByCapability('vnc') !== undefined;
|
|
}
|