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>
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user