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:
@@ -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);
|
||||
|
||||
@@ -157,6 +157,8 @@ export type UserSession = {
|
||||
systemContextSent: boolean;
|
||||
messages: Message[];
|
||||
meta: SessionMeta;
|
||||
_sidecarUnsub?: () => void;
|
||||
_claudeKill?: () => void;
|
||||
};
|
||||
|
||||
export type ModelInfo = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user