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>
304 lines
8.6 KiB
TypeScript
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();
|
|
}
|