import type { ServerWebSocket } from 'bun'; import type { SidecarCommand, SidecarEvent, ClaudeState, ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession, LiveOpenCodeSession, OpenCodeRunParams, } from './sidecar/protocol'; import type { SidecarRegistration } from './sidecar/registration-protocol'; import type { TurnMessage } from './api/chat/types'; // ── Types ── type RegisteredSidecar = { id: string; name: string; handles: string[]; ws: ServerWebSocket; }; type PendingRequest = { /** Which sidecar this request was sent to, so a disconnect only fails that sidecar's own work. */ sidecarId: string; resolve: (value: any) => void; reject: (error: Error) => void; timer: Timer; }; type EventHandler = (event: SidecarEvent) => void; // ── State ── const sidecars = new Map(); const pending = new Map(); const eventHandlers = new Map>(); let cachedState: ClaudeState | null = null; let idCounter = 0; function nextId(): string { return `sr_${Date.now()}_${++idCounter}`; } // ── Registration ── /** The live socket for a sidecar registered under this name, or null. For pushes that are not commands. */ export function socketForSidecar(name: string): ServerWebSocket | null { for (const sc of sidecars.values()) if (sc.name === name) return sc.ws; return null; } export function registerSidecar(ws: ServerWebSocket, 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, handles: registration.handles, ws, }); console.log( `[registry] registered sidecar "${registration.name}" (id=${id}, handles=[${registration.handles.join(', ')}])`, ); // A registration socket lives and dies with its process, so an agent showing up here is an agent that // has just started — and whatever it was running before is gone. Announced rather than inferred from // the *disconnect*, which is the wrong signal entirely: every `pm2 restart officer` drops these sockets // while the sidecars, and their turns, carry on perfectly well. if (registration.handles.includes('claude')) { for (const handler of claudeRestartHandlers) { try { handler(); } catch (err) { console.error('[registry] claude-restart handler failed', err); } } } return id; } const claudeRestartHandlers = new Set<() => void>(); /** Notified when the agent sidecar registers — i.e. when a new agent process has come up. */ export function onClaudeSidecarStarted(handler: () => void): () => void { claudeRestartHandlers.add(handler); return () => claudeRestartHandlers.delete(handler); } 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 the pending requests belonging to THIS sidecar, and only those. Until the pending entries // carried a sidecarId this loop rejected the whole map, so restarting any one sidecar failed in-flight // work on every other — `pm2 restart officer-music` could kill a running agent turn with the message // `Sidecar "music" disconnected`, which is the sort of failure nobody traces back to its cause. for (const [reqId, req] of pending) { if (req.sidecarId !== id) continue; clearTimeout(req.timer); req.reject(new Error(`Sidecar "${sc.name}" disconnected`)); pending.delete(reqId); } // Clear cached state if the claude sidecar disconnects if (sc.handles.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 findSidecarHandling(kind: string): RegisteredSidecar | undefined { for (const sc of sidecars.values()) { if (sc.handles.includes(kind)) 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 ── // // ── The generic door, for plugins ── // // `sendCommand` below is private and its `SidecarCommand` union names every message the PLATFORM knows // about. That was fine while every sidecar was the platform's. A plugin's sidecar speaks a vocabulary // this file must not learn — the desktop extraction is what made that concrete: `startVnc`, // `ensureVncPassword` and a dozen `vnc:*` variants lived here, in the platform, describing a plugin. // // So a plugin drives its own sidecar through `sendSidecarCommand`, which knows only that a command has a // type and an id and that a reply carries the same id. The vocabulary stays in the plugin, and nothing // here has to be edited when a plugin adds a message. const DEFAULT_TIMEOUT_MS = 30_000; const LONG_TIMEOUT_MS = 6 * 60 * 1000; /** A message to a sidecar. `id` is what the reply is matched on; everything else is the plugin's. */ export type PluginSidecarCommand = { type: string; id: string } & Record; /** * Send a command to whichever sidecar handles `kind`, and wait for the reply with the same id. * * The plugin-facing half of `sendCommand`. Rejects when no sidecar handles that kind — which for a * plugin means its own process is not running, and is a better error than a timeout. */ export function sendSidecarCommand( kind: string, cmd: PluginSidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS, ): Promise { return sendCommand(kind, cmd as unknown as SidecarCommand, timeoutMs) as Promise; } /** Is a sidecar handling `kind` connected right now? */ export function isSidecarConnected(kind: string): boolean { return !!findSidecarHandling(kind); } function sendCommand(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { const sc = findSidecarHandling(kind); if (!sc) { reject(new Error(`No sidecar handling "${kind}" 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, { sidecarId: sc.id, resolve, reject, timer }); sc.ws.send(JSON.stringify(cmd)); }); } /** * Fire a command at whichever sidecar handles this kind. Returns whether one was there to take it. * * It used to return void and drop the command when no sidecar was registered — no log, no error, nothing. * That window is real and predictable: every `pm2 restart officer` has one while the sidecars reconnect, and * a person clicking a button has no idea they are in it. * * The cost landed on `claude:question-answer`, whose own handler documents this exact failure and cannot * report it, because the message never arrives to be reported. From the person's side the question card just * kept spinning — indistinguishable from a slow agent, so the only move left was to send another message. * * Callers that can tell somebody are now able to. The boolean is the whole mechanism; the warning is for * whoever reads the log afterwards wondering why one click did nothing. */ function sendFire(kind: string, cmd: SidecarCommand): boolean { const sc = findSidecarHandling(kind); if (!sc) { console.warn(`[sidecar] dropped ${cmd.type}: no sidecar registered for "${kind}"`); return false; } sc.ws.send(JSON.stringify(cmd)); return true; } function sendCommandToSidecar( sc: RegisteredSidecar, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS, ): Promise { 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, { sidecarId: sc.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 HANDLER_WAIT_MS = 15_000; const HANDLER_POLL_MS = 100; async function waitForHandler(kind: string, timeoutMs = HANDLER_WAIT_MS): Promise { const existing = findSidecarHandling(kind); if (existing) return existing; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await Bun.sleep(HANDLER_POLL_MS); const sc = findSidecarHandling(kind); if (sc) return sc; } throw new Error(`No sidecar handling "${kind}" registered within ${timeoutMs}ms`); } // ── Public API ── export function isConnected(): boolean { return findSidecarHandling('proxy') !== undefined; } export function getCachedState(): ClaudeState | null { return cachedState; } export async function syncState(): Promise { 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 { 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, handling 'claude') ── // Single-user platform, so there is exactly one agent sidecar and it is found by what it handles, 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 { const sc = await waitForHandler('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 { const sc = await waitForHandler('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, userId: number): void { sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey, userId }); } /** * Kill a session and wait for the agent to confirm its child process is gone. * * For callers that then delete the session's FILES. The transcript belongs to the `claude` CLI, not to * officer, so removing it while the CLI is still unwinding deletes a file that is written straight back — * which resurrects a conversation the user deleted, as an untitled ordinary chat that nothing filters out. * * Never fatal: a sidecar that is down or slow must not block a delete. The caller proceeds and accepts the * race it was already running, which is no worse than before this existed. */ export async function killClaudeAndWait(sessionKey: string, userId: number): Promise { try { const sc = await waitForHandler('claude'); const res = await sendCommandToSidecar(sc, { type: 'claude:kill', id: nextId(), sessionKey, userId }); return res.type === 'claude:killed'; } catch { return false; } } // Interrupt the current turn but keep the persistent session warm (the "stop" button). export function interruptClaude(sessionKey: string, userId: number): void { sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey, userId }); } // Plan mode, both directions. Fire-and-forget on purpose: the agent's answer to either one comes back // through the session log as a `plan:mode` / `plan:decided` message, which is durable, ordered with the // rest of the turn, and delivered to every socket watching — none of which a command reply would be. export function setClaudePlanMode(sessionKey: string, userId: number, enabled: boolean): void { sendFire('claude', { type: 'claude:set-plan-mode', id: nextId(), sessionKey, userId, enabled }); } export function decideClaudePlan(params: { sessionKey: string; userId: number; requestId: string; approve: boolean; feedback?: string; }): boolean { return sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params }); } // The user's answer to an AskUserQuestion. Fire-and-forget for the same reason as the plan decision: the // visible result is the tool's own output arriving in the durable log, where every socket watching this // conversation sees it — including the one that did not click. export function answerClaudeQuestion(params: { sessionKey: string; userId: number; toolCallId: string; answer: string; }): boolean { return sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params }); } /** * Is the agent still running a turn for this session? * * Asked of the sidecar because it is the only party that can answer: officer's own memory of a turn dies * with `pm2 restart officer` while the turn itself carries on, so "we don't remember it" is not evidence * of anything. Used on reconnect to tell those two apart — a turn that outlived a restart from one whose * process is gone and is never going to produce another token. * * Fails *toward alive*: no answer means we don't know, and wrongly telling someone their turn died while * it is still typing is worse than leaving a spinner up a little longer. Only a registered agent that * says "no", or no agent at all, counts as dead. */ /** * Every session the agent is holding IN MEMORY right now — distinct from `listClaudeSessions` in * `api/chat/claude-sessions`, which lists conversations from transcripts on disk. Those are the history; * these are the ones with a process behind them. * * Officer's own session records live in memory and die with `pm2 restart officer`, while the agent — a * PM2 peer, not a child — keeps running and keeps committing to chat_session_events. Until this existed * a surviving session was invisible: `adoptOrphanedSession` only fires when a browser reconnects to one * *by id*, so nothing could answer "what is still running". * * Fails *toward empty*, unlike `isClaudeGenerating` which fails toward alive. The asymmetry is * deliberate: there, not knowing means leaving a spinner up; here, not knowing would mean inventing * sessions, and an enumeration that reports things that may not exist is worse than a short one. */ export async function listLiveClaudeSessions(userId: number): Promise { const sc = findSidecarHandling('claude'); if (!sc) return []; try { const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId(), userId }); return res.type === 'claude:sessions' ? res.sessions : []; } catch { return []; } } /** * The OpenCode turns running right now, from the sidecar's own map. * * Fails toward EMPTY, matching `listLiveClaudeSessions` and for the same reason: an enumeration that * invents sessions is worse than a short one. A sidecar that is down or does not understand the verb * (an older build) simply contributes nothing to the Live panel rather than breaking it. */ export async function listLiveOpenCodeSessions(): Promise { const sc = findSidecarHandling('opencode'); if (!sc) return []; try { const res = await sendCommandToSidecar(sc, { type: 'opencode:list', id: nextId() }); return res.type === 'opencode:sessions' ? res.sessions : []; } catch { return []; } } export async function isClaudeGenerating(sessionKey: string, userId: number): Promise { const sc = findSidecarHandling('claude'); if (!sc) return false; try { const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey, userId }); return res.type === 'claude:generating' ? res.generating : true; } catch { return true; } } /** * Officer's session key for a Claude transcript uuid, or null if the agent has never seen it. * * The browser only ever has the uuid after a refresh — it is what the URL carries — and officer's own * key is not derivable from it. The agent's on-disk map is the single record that relates them, so this * is the hinge the whole reattach path turns on. * * Fails toward null: no agent, no answer, or a timeout all mean "cannot re-bind", and the caller falls * back to today's behaviour of leaving the socket unattached rather than binding it to a guess. */ export async function findClaudeSessionKey(claudeSessionId: string, userId: number): Promise { const sc = findSidecarHandling('claude'); if (!sc) return null; try { const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId, userId }); return res.type === 'claude:session-key' ? res.sessionKey : null; } catch { return null; } } export function clearClaudeSession(sessionKey: string, userId: number): void { sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey, userId }); } // 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); }); } // See `PersistentSession.notify` in claude-manager.ts — a separate, non-durable ping, not part of the // transcript the message above relays. export function onClaudeNotify( handler: (sessionKey: string, userId: number, kind: 'turn-ended' | 'input-needed') => void, ): () => void { return on('claude:notify', (ev) => { if (ev.type !== 'claude:notify') return; handler(ev.sessionKey, ev.userId, ev.kind); }); } // ── OpenCode Code (single sidecar, handling 'opencode') ── export async function spawnOpenCodeStreaming(params: OpenCodeRunParams): Promise { 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. // The VNC helpers that were here — startVnc, ensureVncPassword, stopVnc, isVncConnected — left with the // desktop plugin on 2026-08-21. They were the clearest example of the platform knowing what a plugin was // about: four functions and a dozen `vnc:*` message types describing a feature that is not the // platform's. What replaced them is `sendSidecarCommand` above, which is generic.