import type { SidecarCommand, SidecarEvent, SidecarState, ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, PiSpawnParams, } from './sidecar/protocol'; import type { PiEvent } from './api/pi/types'; import type { Job, EnqueueParams } from './queue/types'; const SIDECAR_PORT = Number(process.env.SIDECAR_PORT ?? '5100'); const SIDECAR_URL = `ws://127.0.0.1:${SIDECAR_PORT}`; const RECONNECT_DELAYS = [200, 500, 1000, 2000, 4000, 8000, 15000]; type PendingRequest = { resolve: (value: SidecarEvent) => void; reject: (error: Error) => void; timer: Timer; }; type EventHandler = (event: SidecarEvent) => void; let ws: WebSocket | null = null; let connected = false; let reconnectAttempt = 0; let reconnectTimer: Timer | null = null; const pending = new Map(); const eventHandlers = new Map>(); let cachedState: SidecarState | null = null; // ── Connection management ── function connect() { if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return; try { ws = new WebSocket(SIDECAR_URL); } catch { scheduleReconnect(); return; } ws.onopen = () => { connected = true; reconnectAttempt = 0; console.log('[sidecar-client] connected'); // Sync state on connect sendCommand({ type: 'state:sync', id: nextId() }).then((res) => { if (res.type === 'state:sync') { cachedState = res.state; console.log('[sidecar-client] state synced'); } }).catch(() => { /* best effort */ }); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data as string) as SidecarEvent; // 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); } catch { // skip malformed messages } }; ws.onclose = () => { connected = false; ws = null; rejectAllPending('WebSocket disconnected'); scheduleReconnect(); }; ws.onerror = () => { // onclose will fire after this }; } function scheduleReconnect() { if (reconnectTimer) return; const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!; reconnectAttempt++; reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); } function rejectAllPending(reason: string) { for (const [id, req] of pending) { clearTimeout(req.timer); req.reject(new Error(reason)); } pending.clear(); } // ── 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 ── let idCounter = 0; function nextId(): string { return `sc_${Date.now()}_${++idCounter}`; } const DEFAULT_TIMEOUT_MS = 30_000; const LONG_TIMEOUT_MS = 6 * 60 * 1000; // 6 min for claude spawn function sendCommand(cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { if (!ws || ws.readyState !== WebSocket.OPEN) { reject(new Error('Sidecar not connected')); return; } const timer = setTimeout(() => { pending.delete(cmd.id); reject(new Error(`Sidecar command ${cmd.type} timed out`)); }, timeoutMs); pending.set(cmd.id, { resolve, reject, timer }); ws.send(JSON.stringify(cmd)); }); } function sendFire(cmd: SidecarCommand): void { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(cmd)); } } // ── Public API ── export function isConnected(): boolean { return connected; } export function getCachedState(): SidecarState | null { return cachedState; } export async function syncState(): Promise { const res = await sendCommand({ 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({ 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 ── export async function spawnClaude(params: ClaudeSpawnParams): Promise { const res = await sendCommand({ 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 res = await sendCommand({ 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({ type: 'claude:kill', id: nextId(), sessionKey }); } export function clearClaudeSession(sessionKey: string): void { sendFire({ type: 'claude:clear-session', id: nextId(), sessionKey }); } export function onClaudeEvent(handler: (sessionKey: string, event: PiEvent) => void): () => void { return on('claude:event', (msg) => { if (msg.type === 'claude:event') { handler(msg.sessionKey, msg.event); } }); } // ── Pi ── export async function spawnPi(params: PiSpawnParams): Promise { const res = await sendCommand({ type: 'pi:spawn', id: nextId(), params }); if (res.type === 'pi:spawned') return; if (res.type === 'pi:error') throw new Error(res.error); throw new Error('Unexpected response'); } export function sendPiPrompt(sessionId: string, prompt: string, requestId: string): void { sendFire({ type: 'pi:prompt', id: nextId(), sessionId, prompt, requestId }); } export function abortPi(sessionId: string, requestId: string): void { sendFire({ type: 'pi:abort', id: nextId(), sessionId, requestId }); } export function killPi(sessionId: string): void { sendFire({ type: 'pi:kill', id: nextId(), sessionId }); } export function setPiThinking(sessionId: string, level: string): void { sendFire({ type: 'pi:set-thinking', id: nextId(), sessionId, level }); } export function onPiEvent(handler: (sessionId: string, event: PiEvent) => void): () => void { return on('pi:event', (msg) => { if (msg.type === 'pi:event') { handler(msg.sessionId, msg.event); } }); } // ── Queue ── export async function enqueueJob(params: EnqueueParams): Promise { const res = await sendCommand({ type: 'queue:enqueue', id: nextId(), params }); if (res.type === 'queue:enqueued') return res.job; if (res.type === 'queue:error') throw new Error(res.error); throw new Error('Unexpected response'); } export async function cancelJob(jobId: string): Promise { const res = await sendCommand({ type: 'queue:cancel', id: nextId(), jobId }); if (res.type === 'queue:cancelled') return res.job; if (res.type === 'queue:error') throw new Error(res.error); throw new Error('Unexpected response'); } export async function listJobs(): Promise { const res = await sendCommand({ type: 'queue:list', id: nextId() }); if (res.type === 'queue:list') return res.jobs; throw new Error('Unexpected response'); } export async function getJob(jobId: string): Promise { const res = await sendCommand({ type: 'queue:get', id: nextId(), jobId }); if (res.type === 'queue:get') return res.job; throw new Error('Unexpected response'); } // ── Health check ── export async function isSidecarAlive(): Promise { try { const res = await fetch(`http://127.0.0.1:${SIDECAR_PORT}/`, { signal: AbortSignal.timeout(1000) }); const text = await res.text(); return text === 'process-sidecar'; } catch { return false; } } // ── Init ── export function initSidecarClient(): void { connect(); }