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:
2026-03-05 07:36:47 +00:00
co-authored by Claude Opus 4.6
parent 726c77e346
commit 86c2d6333a
21 changed files with 2415 additions and 610 deletions
+2 -2
View File
@@ -6,8 +6,8 @@ const ANTHROPIC_API_BASE = 'https://api.anthropic.com';
const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
// Generate a random proxy secret at startup — shared with Claude Code spawns
// Prefix with sk-ant- so Claude Code accepts it as a valid API key format
export const proxySecret = `sk-ant-proxy01-${crypto.randomUUID()}`;
// Prefix must match a real Anthropic API key format (sk-ant-api03-*) so Claude Code accepts it
export const proxySecret = `sk-ant-api03-${crypto.randomUUID()}`;
type CredentialsFile = {
claudeAiOauth?: {
+6 -2
View File
@@ -80,8 +80,12 @@ class SessionManager {
clearTimeout(session.idleTimer);
}
if (session.piProcess) {
session.piProcess.kill();
// Clean up sidecar subscriptions
if (session._sidecarUnsub) {
session._sidecarUnsub();
}
if (session._claudeKill) {
session._claudeKill();
}
this.sessions.delete(sessionId);
+2
View File
@@ -157,6 +157,8 @@ export type UserSession = {
systemContextSent: boolean;
messages: Message[];
meta: SessionMeta;
_sidecarUnsub?: () => void;
_claudeKill?: () => void;
};
export type ModelInfo = {
+54 -57
View File
@@ -5,6 +5,7 @@ import { sessionManager } from './session-manager';
import * as storage from './storage';
import * as piBridge from './pi-bridge';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import * as sidecar from '@@/sidecar-client';
import { join } from 'path';
import { getHomeDirForRole } from '../../../servers/data-path';
import { getUserSettings } from 'officerdb';
@@ -300,32 +301,38 @@ async function handleChat(
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
// If session has history, save to disk and pass --session for context replay
let spawnOptions: { sessionFile?: string; username?: string; role?: string } | undefined;
let sessionFile: string | undefined;
if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) {
spawnOptions = { sessionFile: hostPath, username, role: ws.data.role };
}
if (hostPath) sessionFile = hostPath;
}
if (!spawnOptions) spawnOptions = { username, role: ws.data.role };
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, spawnOptions);
// Null out piProcess when the process dies so next message triggers respawn
const proc = session.piProcess;
proc.exited.then(() => {
if (session.piProcess === proc) {
session.piProcess = null;
logger.info('Pi process exited, nulled reference', { sessionId });
}
// Subscribe to sidecar events for this session
const unsub = sidecar.onPiEvent((evtSessionId, event) => {
if (evtSessionId === sessionId) onEvent(event);
});
logger.info('Spawned Pi process for session', {
await sidecar.spawnPi({
sessionId,
email,
userId,
username,
role: ws.data.role,
cwd,
model,
sessionFile,
});
// Mark session as having a live process (use sessionId as sentinel)
session.piProcess = sessionId as any;
session._sidecarUnsub = unsub;
logger.info('Spawned Pi process via sidecar', {
sessionId,
model,
cwd,
hasSessionFile: !!spawnOptions?.sessionFile,
hasSessionFile: !!sessionFile,
});
} catch (err) {
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
@@ -352,13 +359,13 @@ async function handleChat(
// Set thinking level if provided
console.log(`[pi] model: ${msg.model ?? 'default'}, thinking: ${msg.thinking ?? 'not set'}`);
if (msg.thinking) {
piBridge.setThinkingLevel(session.piProcess, msg.thinking);
sidecar.setPiThinking(sessionId, msg.thinking);
}
// Send prompt to Pi
// Send prompt to Pi via sidecar
const requestId = randomUUID();
session.isGenerating = true;
piBridge.sendPrompt(session.piProcess, msg.prompt, requestId);
sidecar.sendPiPrompt(sessionId, msg.prompt, requestId);
}
async function handleClaudeCodeChat(
@@ -428,15 +435,9 @@ async function handleClaudeCodeChat(
onEvent,
});
// Store proc as piProcess so handleStop can kill it
session.piProcess = handle.proc;
// Null out when process exits so next message spawns a new one
handle.proc.exited.then(() => {
if (session.piProcess === handle.proc) {
session.piProcess = null;
}
});
// Store sentinel so handleStop can kill it via sidecar
session.piProcess = sessionId as any;
session._claudeKill = handle.kill;
} catch (err) {
logger.error('Failed to start Claude Code streaming', { sessionId, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start Claude Code' });
@@ -490,39 +491,36 @@ async function handleResume(
const homeDir = getHomeDirForRole(email, ws.data.role);
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
// If session has history, save to disk and pass --session for context replay
let spawnOptions: { sessionFile?: string; username?: string; role?: string } | undefined;
let sessionFile: string | undefined;
if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) {
spawnOptions = { sessionFile: hostPath, username: ws.data.username, role: ws.data.role };
}
if (hostPath) sessionFile = hostPath;
}
if (!spawnOptions) spawnOptions = { username: ws.data.username, role: ws.data.role };
session.piProcess = await piBridge.spawnPi(
session.cwd,
session.model,
session.userId!,
email,
onEvent,
spawnOptions,
);
// Null out piProcess when the process dies so next message triggers respawn
const proc = session.piProcess;
proc.exited.then(() => {
if (session.piProcess === proc) {
session.piProcess = null;
logger.info('Pi process exited, nulled reference', { sessionId });
}
// Subscribe to sidecar events for this session
const unsub = sidecar.onPiEvent((evtSessionId, event) => {
if (evtSessionId === sessionId) onEvent(event);
});
logger.info('Spawned fresh Pi process for resumed session', {
await sidecar.spawnPi({
sessionId,
email,
userId: session.userId!,
username: ws.data.username,
role: ws.data.role,
cwd: session.cwd,
model: session.model,
sessionFile,
});
session.piProcess = sessionId as any;
session._sidecarUnsub = unsub;
logger.info('Spawned fresh Pi process via sidecar for resumed session', {
sessionId,
model: session.model,
hasSessionFile: !!spawnOptions?.sessionFile,
hasSessionFile: !!sessionFile,
});
} catch (err) {
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
@@ -555,12 +553,11 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
if (session?.piProcess) {
try {
if (session.model.startsWith('claude-code')) {
// Claude Code: kill the process directly
session.piProcess.kill();
logger.info('Killed Claude Code process', { sessionId });
sidecar.killClaude(sessionId);
logger.info('Killed Claude Code process via sidecar', { sessionId });
} else {
piBridge.abort(session.piProcess, randomUUID());
logger.info('Sent abort to Pi process', { sessionId });
sidecar.abortPi(sessionId, randomUUID());
logger.info('Sent abort to Pi process via sidecar', { sessionId });
}
session.isGenerating = false;
} catch (err) {
+5 -5
View File
@@ -1,5 +1,5 @@
import { createRouter } from '../../create-router';
import { enqueue, cancelJob, readJob, listAllJobs } from '../../queue';
import * as sidecar from '../../sidecar-client';
import { NOT_FOUND } from '../../custom-errors';
export const queueRouter = createRouter();
@@ -10,7 +10,7 @@ queueRouter.get('/jobs', async (ctx) => {
const type = ctx.req.query('type');
const status = ctx.req.query('status');
let jobs = await listAllJobs();
let jobs = await sidecar.listJobs();
jobs = jobs.filter((j) => j.userId === user.email);
if (lane) jobs = jobs.filter((j) => j.lane === lane);
@@ -21,7 +21,7 @@ queueRouter.get('/jobs', async (ctx) => {
});
queueRouter.get('/jobs/:id', async (ctx) => {
const job = await readJob(ctx.req.param('id'));
const job = await sidecar.getJob(ctx.req.param('id'));
if (!job) throw NOT_FOUND('Job not found');
return ctx.json(job);
});
@@ -36,12 +36,12 @@ queueRouter.post('/jobs', async (ctx) => {
notify?: boolean;
};
const job = await enqueue({ lane, type, userId: user.email, meta, notify });
const job = await sidecar.enqueueJob({ lane, type, userId: user.email, meta, notify });
return ctx.json(job, 201);
});
queueRouter.delete('/jobs/:id', async (ctx) => {
const job = await cancelJob(ctx.req.param('id'));
const job = await sidecar.cancelJob(ctx.req.param('id'));
if (!job) throw NOT_FOUND('Job not found');
return ctx.json(job);
});