Files
platform/src/servers/sidecar-client.ts
T
pastilhasandClaude Opus 4.6 86c2d6333a process sidecar: independent process manager for long-running work
Introduces a separate Bun process (port 5100) that owns all spawned
processes and long-running work, so the API server can restart freely
without disrupting active sessions.

The sidecar owns:
- Anthropic proxy (port 5051) with persisted secret across restarts
- Claude Code process spawning and session tracking (--resume support)
- Pi agent spawning and RPC lifecycle (prompt/abort/thinking)
- Job queue engine (lane processing, retries, notifications)

The API server becomes a thin client that forwards commands over a
single WebSocket connection with auto-reconnect. send-claude-code.ts
goes from 550 lines of spawn logic to 73 lines of sidecar delegation.

State persisted to data/sidecar/state.json every 30s and on shutdown.
Lockfile prevents duplicate instances. See SIDECAR.md for full docs
and manual testing procedures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:36:47 +00:00

304 lines
8.6 KiB
TypeScript

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<string, PendingRequest>();
const eventHandlers = new Map<string, Set<EventHandler>>();
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<SidecarEvent> {
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<SidecarState> {
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<string> {
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<ClaudeCodeResult> {
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<void> {
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<void> {
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<Job> {
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<Job | null> {
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<Job[]> {
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<Job | null> {
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<boolean> {
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();
}