Files
platform/src/servers/sidecar-registry.ts
T
pastilhasandClaude Opus 5 36c7b1f3fd delete api routes with no consumers
from a full audit of all 179 route definitions under src/servers/api, tracing
consumers through useClient, raw fetch, EventSource, the capabilities repo and
the mobile monorepo. only routes with zero consumers anywhere are removed.

  server-settings/applications.ts   whole file — an app install/update registry
                                    with no settings section to drive it
  server-settings/claude-code.ts    whole file — the ai settings screen talks
                                    to chat-providers/* exclusively
  GET  browser/extension-download   superseded by a static asset; BrowserRelay
                                    links at /browser-relay-extension.zip
  GET  integrations/                a stub returning []
  GET  chat-providers/auth          /api-keys says the same thing with more detail
  GET  desktop/vnc-status           and with it the vnc:status command and reply,
                                    which existed only to serve this route.
                                    docs/sidecar-audit-2026-07.md called this
                                    one dead months ago

deliberately KEPT, because "no caller" turned out not to mean "dead":

  POST activity/announce      not orphaned — it is the missing PRODUCER for the
                              detached[] list GET activity/tasks already returns
                              and ActivityScreen already renders. an unbuilt
                              feature, not dead code, and finishing or dropping
                              it is a product decision.
  GET  agents/runs            three days old. part of agent grounds, still being
                              built. "not yet consumed" is not "dead".
  PUT/GET vault/unlock-key    six days old, storage half of a feature whose
                              client half is unwritten. the vault is off limits.
  DELETE integrations/google/connection   caller exists but is deliberately
                              commented out of the tree. dormant on purpose.

vnc-manager's getSession is now orphaned too, but it is sidecar-internal and
was not in scope; noted rather than chased.

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

353 lines
12 KiB
TypeScript

import type { ServerWebSocket } from 'bun';
import type {
SidecarCommand,
SidecarEvent,
ClaudeState,
ClaudeSpawnParams,
ClaudeSpawnStreamingParams,
ClaudeCodeResult,
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(', ')}])`,
);
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 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 });
}
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;
}