Files
platform/src/servers/sidecar-registry.ts
T
pastilhasandClaude Opus 5 e8bd946272 show running opencode turns in the live panel
The Live panel asked `claude:list` and nothing else, so a running OpenCode turn was invisible — the panel
claimed to show what the agent is doing and silently omitted half of it.

Adds `opencode:list` / `opencode:sessions` and merges both harnesses in `/chat/live`, asked in parallel,
each failing toward empty so one sidecar being down contributes nothing rather than breaking the panel.

The OpenCode row is deliberately thinner than the Claude one rather than faked into parity:

  isGenerating  always true — a subprocess exists only while it generates, so there is no "merely open"
  pendingTasks  always 0    — `opencode run` has no background-task concept; reporting a number would
                              suggest a capability that does not exist
  title / cwd   null        — the session store is keyed on the `ses_…` id the runner reports, not on
                              our sessionKey, so an unreported turn shows unnamed rather than guessed

This is the incremental option from docs/opencode-serve-path.md — enumeration without moving turns onto
the serve, so it buys the Live panel with no warm sessions, no SSE loop and no lifetime questions.

NOT verified end to end: no OpenCode turn was running to enumerate, so the verb is wired and typechecked
but has never returned a non-empty list. See COMMS/BLOCKERS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 03:00:43 +00:00

464 lines
17 KiB
TypeScript

import type { ServerWebSocket } from 'bun';
import type {
SidecarCommand,
SidecarEvent,
ClaudeState,
ClaudeSpawnParams,
ClaudeSpawnStreamingParams,
ClaudeCodeResult,
LiveClaudeSession,
LiveOpenCodeSession,
OpenCodeRunParams,
VncStartParams,
} 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 = {
/** 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<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(', ')}])`,
);
// 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.capabilities.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.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, { sidecarId: sc.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, { 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 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 });
}
/**
* 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(): Promise<LiveClaudeSession[]> {
const sc = findSidecarByCapability('claude');
if (!sc) return [];
try {
const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId() });
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<LiveOpenCodeSession[]> {
const sc = findSidecarByCapability('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): Promise<boolean> {
const sc = findSidecarByCapability('claude');
if (!sc) return false;
try {
const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey });
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): Promise<string | null> {
const sc = findSidecarByCapability('claude');
if (!sc) return null;
try {
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId });
return res.type === 'claude:session-key' ? res.sessionKey : null;
} catch {
return null;
}
}
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 function isVncConnected(): boolean {
return findSidecarByCapability('vnc') !== undefined;
}