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
@@ -4,7 +4,7 @@ import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-awai
import { consumePairingCode } from '../pairing';
import { chunkMessage } from './chunker';
import { listPiModels } from '@@/api/pi/list-models';
import { enqueue } from '@@/queue/engine';
import { enqueueJob } from '../../sidecar-client';
import { readJob } from '@@/queue/storage';
import { openEmailDb } from '@@/api/email/email-db';
import type { ModelInfo } from '@@/api/pi/types';
@@ -60,7 +60,7 @@ async function handleEmailSync(ctx: CommandContext): Promise<void> {
await channel.send('Syncing emails...');
const job = await enqueue({ lane: 'google-api', type: 'gmail-sync', userId: email });
const job = await enqueueJob({ lane: 'google-api', type: 'gmail-sync', userId: email });
// Poll until done
const poll = async (): Promise<'completed' | 'failed' | 'cancelled'> => {
+23 -528
View File
@@ -1,37 +1,6 @@
import { join } from 'node:path';
import {
getHomeDir,
getNativeToolsDir,
getGlobalToolsDir,
getUserToolsDir,
getNativeSkillsDir,
getGlobalSkillsDir,
getUserSkillsDir,
toShellUsername,
} from '@@/data-path';
import { readToolDirs, parseFrontmatter as parseToolFrontmatter } from '@@/api/tools/tools';
import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills';
import { buildHostToolEnv } from '@@/api/pi/pi-bridge';
import { logger } from '@@/api/pi/logger';
import { proxySecret } from '@@/api/anthropic-proxy';
import type { MessageCost, PiEvent } from '@@/api/pi/types';
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
async function hasOwnCredentials(homeDir: string): Promise<boolean> {
try {
return await Bun.file(join(homeDir, '.claude', '.credentials.json')).exists();
} catch {
return false;
}
}
// Resolve absolute path to claude binary so sudo -u can find it regardless of target user's PATH
const CLAUDE_BIN = (() => {
const result = Bun.spawnSync({ cmd: ['which', 'claude'], stdout: 'pipe', stderr: 'ignore' });
return result.stdout.toString().trim() || 'claude';
})();
import * as sidecar from '@@/sidecar-client';
type ClaudeCodeParams = {
userId: number;
@@ -49,235 +18,13 @@ type ClaudeCodeResult = {
cost: MessageCost;
};
type ClaudeCodeOutput = {
result: string;
session_id: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;
is_error: boolean;
};
// Map channel session key → Claude Code session ID for --resume
const claudeCodeSessions = new Map<string, string>();
export function clearClaudeCodeSession(sessionKey: string): void {
claudeCodeSessions.delete(sessionKey);
}
// ── Build dynamic system prompt from available tools & skills ──
async function buildToolsSystemPrompt(email: string): Promise<string | null> {
const [nativeTools, globalTools, userTools, nativeSkills, globalSkills, userSkills] = await Promise.all([
readToolDirs(getNativeToolsDir()),
readToolDirs(getGlobalToolsDir()),
readToolDirs(getUserToolsDir(email)),
readSkillDirs(getNativeSkillsDir()),
readSkillDirs(getGlobalSkillsDir()),
readSkillDirs(getUserSkillsDir(email)),
]);
// Merge tools (user overrides global overrides native)
const mergedTools = new Map(nativeTools);
for (const [name, path] of globalTools) mergedTools.set(name, path);
for (const [name, path] of userTools) mergedTools.set(name, path);
// Merge skills
const mergedSkills = new Map(nativeSkills);
for (const [name, path] of globalSkills) mergedSkills.set(name, path);
for (const [name, path] of userSkills) mergedSkills.set(name, path);
if (mergedTools.size === 0 && mergedSkills.size === 0) return null;
const sections: string[] = [
'# Officer Automation System',
'',
'You are running inside the Officer platform. Officer has its own automation concepts that are DIFFERENT from your built-in tools. When the user or a task references these, use the definitions below — do NOT map them to your own built-in concepts.',
'',
'- **Task**: A markdown file (TASK.md) with instructions for you to execute. When asked to "run a task", read the TASK.md file and follow its instructions step by step.',
'- **Skill**: A knowledge document (SKILL.md) that describes HOW to do something — APIs, commands, patterns. When a task says "use the X skill", follow the instructions from the matching skill section below.',
'- **Tool**: A capability defined by a TOOL.md and implemented in an index.ts file. Tools are NOT shell commands — do NOT try to call them by name. If the tool has a "Run" line below, execute it using that command, passing inputs as a JSON string argument. Only if there is no Run command should you replicate the behavior manually using the TOOL.md documentation.',
'',
'Skills and tools listed here are AVAILABLE to you. Follow their documentation directly.',
'',
];
if (mergedTools.size > 0) {
const toolLines: string[] = ['# Tools', ''];
const entries = await Promise.all(
Array.from(mergedTools.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter, body } = parseToolFrontmatter(raw);
const toolDir = join(filePath, '..');
const hasImpl = await Bun.file(`${toolDir}/index.ts`).exists();
return {
dirName,
name: frontmatter.name || dirName,
description: frontmatter.description,
body,
toolDir,
hasImpl,
};
}),
);
const runnerPath = `${getNativeToolsDir()}/run.ts`;
for (const tool of entries) {
toolLines.push(`## ${tool.name}`);
if (tool.hasImpl) toolLines.push(`Run: \`bun run ${runnerPath} ${tool.toolDir} '{"param":"value"}'\``);
if (tool.description) toolLines.push(tool.description);
if (tool.body.trim()) toolLines.push('', tool.body.trim());
toolLines.push('');
}
sections.push(toolLines.join('\n'));
}
if (mergedSkills.size > 0) {
const skillLines: string[] = ['# Skills', ''];
const entries = await Promise.all(
Array.from(mergedSkills.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter, body } = parseSkillFrontmatter(raw);
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body };
}),
);
for (const skill of entries) {
skillLines.push(`## ${skill.name}`);
if (skill.description) skillLines.push(skill.description);
if (skill.body.trim()) skillLines.push('', skill.body.trim());
skillLines.push('');
}
sections.push(skillLines.join('\n'));
}
return sections.join('\n\n');
sidecar.clearClaudeSession(sessionKey);
}
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
const { userId, email, username, prompt, sessionKey } = params;
const homeDir = getHomeDir(email);
const shellUsername = toShellUsername(username, email);
const toolEnv = await buildHostToolEnv(userId, email);
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
const subModel = params.model?.split('/')[1];
if (subModel) claudeArgs.push('--model', subModel);
const existingSession = claudeCodeSessions.get(sessionKey);
if (existingSession) {
claudeArgs.push('--resume', existingSession);
}
const isServiceUser = shellUsername === (process.env.USER ?? '');
const userHasCredentials = !isServiceUser && (await hasOwnCredentials(homeDir));
// Service user or users with own credentials use their HOME directly.
// Other users route through the local Anthropic proxy.
const authEnv = isServiceUser
? { HOME: process.env.HOME ?? '' }
: userHasCredentials
? { HOME: homeDir }
: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: proxySecret };
const env: Record<string, string> = {
...toolEnv,
...authEnv,
PATH: process.env.PATH ?? '',
TERM: 'xterm-256color',
};
logger.info('Claude Code exec', {
sessionKey,
username: shellUsername,
isServiceUser,
userHasCredentials,
resume: existingSession ?? null,
});
const proc = isServiceUser
? Bun.spawn(claudeArgs, {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...env },
})
: Bun.spawn(
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
{
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
},
);
const timeout = setTimeout(() => {
try {
proc.kill();
} catch {
/* already dead */
}
}, SEND_TIMEOUT_MS);
try {
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const exitCode = await proc.exited;
clearTimeout(timeout);
if (stderr.trim()) {
logger.info('Claude Code stderr', { text: stderr.trim().slice(0, 500) });
}
if (exitCode !== 0 && !stdout.trim()) {
throw new Error(`claude exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`);
}
// Parse JSON output
let output: ClaudeCodeOutput;
try {
output = JSON.parse(stdout) as ClaudeCodeOutput;
} catch {
// Non-JSON output — treat raw stdout as result text
return {
text: stdout.trim() || '(no response)',
sessionId: sessionKey,
model: 'claude-code',
cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 },
};
}
if (output.is_error) {
throw new Error(output.result || 'Claude Code returned an error');
}
// Store session for --resume on next message
if (output.session_id) {
claudeCodeSessions.set(sessionKey, output.session_id);
}
const cost: MessageCost = {
inputTokens: output.input_tokens ?? 0,
outputTokens: output.output_tokens ?? 0,
totalUSD: output.cost_usd ?? 0,
};
logger.info('Claude Code result', {
sessionKey,
sessionId: output.session_id,
cost: cost.totalUSD,
tokens: cost.inputTokens + cost.outputTokens,
});
return {
text: output.result || '(no response)',
sessionId: sessionKey,
model: 'claude-code',
cost,
};
} catch (err) {
clearTimeout(timeout);
throw err;
}
logger.info('Claude Code exec (via sidecar)', { sessionKey: params.sessionKey });
return sidecar.spawnClaude(params);
}
// ── Streaming variant for Chat Panel WebSocket ──
@@ -294,284 +41,32 @@ type ClaudeCodeStreamingParams = {
};
type ClaudeCodeStreamingHandle = {
proc: ReturnType<typeof Bun.spawn>;
kill: () => void;
};
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
const { userId, email, username, prompt, sessionKey, cwd, onEvent } = params;
const { onEvent, ...spawnParams } = params;
const shellUsername = toShellUsername(username, email);
const homeDir = getHomeDir(email);
const workDir = cwd ?? homeDir;
logger.info('Claude Code streaming exec (via sidecar)', { sessionKey: params.sessionKey });
const claudeArgs = [
CLAUDE_BIN,
'-p',
prompt,
'--dangerously-skip-permissions',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
];
// Subscribe to events for this session
const unsub = sidecar.onClaudeEvent((sessionKey, event) => {
if (sessionKey === params.sessionKey) {
onEvent(event);
const subModel = params.model?.split('/')[1];
if (subModel) claudeArgs.push('--model', subModel);
const existingSession = claudeCodeSessions.get(sessionKey);
if (existingSession) {
claudeArgs.push('--resume', existingSession);
}
// Append dynamic system prompt with available tools & skills
const systemPrompt = await buildToolsSystemPrompt(email);
if (systemPrompt) {
claudeArgs.push('--append-system-prompt', systemPrompt);
}
const toolEnv = await buildHostToolEnv(userId, email);
const { CLAUDECODE: _, ...cleanEnv } = process.env;
const isServiceUser = shellUsername === (process.env.USER ?? '');
const userHasCredentials = !isServiceUser && (await hasOwnCredentials(homeDir));
// Service user or users with own credentials use their HOME directly.
// Other users route through the local Anthropic proxy.
const authEnv = isServiceUser
? { HOME: cleanEnv.HOME ?? '' }
: userHasCredentials
? { HOME: homeDir }
: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: proxySecret };
const env: Record<string, string> = {
...toolEnv,
...authEnv,
PATH: cleanEnv.PATH ?? '',
TERM: 'xterm-256color',
};
logger.info('Claude Code streaming exec', {
sessionKey,
username: shellUsername,
isServiceUser,
userHasCredentials,
cwd: workDir,
resume: existingSession ?? null,
// Unsubscribe when we get a terminal event
if (event.type === 'result' || event.type === 'error') {
unsub();
}
}
});
const proc = isServiceUser
? Bun.spawn(claudeArgs, {
cwd: workDir,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
env: { ...cleanEnv, ...env },
})
: Bun.spawn(
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
{
cwd: workDir,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
},
);
await sidecar.spawnClaudeStreaming(spawnParams);
const timeout = setTimeout(() => {
try {
proc.kill();
} catch {
/* already dead */
}
onEvent({ type: 'error', message: 'Claude Code timed out after 5 minutes' });
}, SEND_TIMEOUT_MS);
// Process NDJSON stream in background
(async () => {
try {
const stdout = proc.stdout as ReadableStream<Uint8Array>;
const reader = stdout.getReader();
const decoder = new TextDecoder();
let buffer = '';
let textBuffer = '';
let gotResult = false;
let lineCount = 0;
const processLine = (line: string) => {
if (!line.trim()) return;
lineCount++;
try {
const msg = JSON.parse(line) as Record<string, unknown>;
const type = msg.type as string;
if (lineCount <= 5 || type === 'result') {
logger.info('Claude Code NDJSON', { sessionKey, lineCount, type, subtype: msg.subtype ?? null });
}
// stream_event — partial streaming (text deltas)
if (type === 'stream_event') {
const event = msg.event as Record<string, unknown> | undefined;
if (event?.type === 'content_block_delta') {
const delta = event.delta as Record<string, unknown> | undefined;
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
textBuffer += delta.text;
onEvent({ type: 'delta', text: delta.text });
}
}
}
// assistant — complete message with text and tool_use blocks
else if (type === 'assistant') {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
// Full text block — emit as text event, reset streaming buffer
onEvent({ type: 'text', text: block.text });
textBuffer = '';
} else if (block.type === 'tool_use') {
// Flush any pending streamed text before tool
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
onEvent({
type: 'tool:start',
toolCallId: (block.id as string) ?? '',
toolName: (block.name as string) ?? 'unknown',
toolInput: (block.input as Record<string, unknown>) ?? {},
});
}
}
}
}
// user — tool results
else if (type === 'user') {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result') {
let output = '';
if (typeof block.content === 'string') {
output = block.content;
} else if (Array.isArray(block.content)) {
output = (block.content as Array<Record<string, unknown>>)
.filter((c) => c.type === 'text')
.map((c) => c.text as string)
.join('\n');
}
onEvent({
type: 'tool:result',
toolCallId: (block.tool_use_id as string) ?? '',
output,
isError: (block.is_error as boolean) ?? false,
});
}
}
}
}
// system init — extract session_id for --resume
else if (type === 'system' && msg.subtype === 'init') {
const sessionId = msg.session_id as string | undefined;
if (sessionId) {
claudeCodeSessions.set(sessionKey, sessionId);
}
}
// result — final
else if (type === 'result') {
gotResult = true;
clearTimeout(timeout);
const isError = (msg.is_error as boolean) ?? false;
const resultText = (msg.result as string) ?? '';
if (isError) {
logger.info('Claude Code streaming error result', { sessionKey, error: resultText.slice(0, 500) });
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
return;
}
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
const usage = msg.usage as Record<string, number> | undefined;
const cost: MessageCost = {
inputTokens: usage?.input_tokens ?? 0,
outputTokens: usage?.output_tokens ?? 0,
totalUSD: (msg.total_cost_usd as number) ?? 0,
};
const sessionId = msg.session_id as string | undefined;
if (sessionId) {
claudeCodeSessions.set(sessionKey, sessionId);
}
logger.info('Claude Code streaming result', { sessionKey, cost: cost.totalUSD });
onEvent({ type: 'result', cost });
}
} catch {
// Skip malformed JSON lines
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
processLine(line);
}
}
// Process any remaining buffer
if (buffer.trim()) {
processLine(buffer);
}
logger.info('Claude Code stream ended', { sessionKey, totalLines: lineCount, gotResult });
clearTimeout(timeout);
// If process exited without a result event, emit error or synthetic result
if (!gotResult) {
const exitCode = await proc.exited;
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
logger.info('Claude Code exited without result event', {
sessionKey,
exitCode,
stderr: stderr.trim().slice(0, 500),
});
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
}
if (exitCode !== 0) {
onEvent({
type: 'error',
message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`,
});
} else {
onEvent({ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } });
}
}
} catch (err) {
clearTimeout(timeout);
onEvent({ type: 'error', message: String(err) });
}
})();
return { proc };
return {
kill: () => {
sidecar.killClaude(params.sessionKey);
unsub();
},
};
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { consumePairingCode } from '../pairing';
import { chunkMessage } from './chunker';
import { getTelegramBot } from './bot';
import { listPiModels } from '@@/api/pi/list-models';
import { enqueue } from '@@/queue/engine';
import { enqueueJob } from '../../sidecar-client';
import { readJob } from '@@/queue/storage';
import { openEmailDb } from '@@/api/email/email-db';
import type { ModelInfo } from '@@/api/pi/types';
@@ -60,7 +60,7 @@ async function handleEmailSync(ctx: CommandContext): Promise<void> {
await send('Syncing emails...');
const job = await enqueue({ lane: 'google-api', type: 'gmail-sync', userId: email });
const job = await enqueueJob({ lane: 'google-api', type: 'gmail-sync', userId: email });
const poll = async (): Promise<'completed' | 'failed' | 'cancelled'> => {
for (let i = 0; i < 120; i++) {
+2 -2
View File
@@ -4,7 +4,7 @@ import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-awai
import { consumePairingCode } from '../pairing';
import { getWhatsAppClient } from './bot';
import { listPiModels } from '@@/api/pi/list-models';
import { enqueue } from '@@/queue/engine';
import { enqueueJob } from '../../sidecar-client';
import { readJob } from '@@/queue/storage';
import { openEmailDb } from '@@/api/email/email-db';
import type { ModelInfo } from '@@/api/pi/types';
@@ -64,7 +64,7 @@ async function handleEmailSync(ctx: CommandContext): Promise<void> {
await send('Syncing emails...');
const job = await enqueue({ lane: 'google-api', type: 'gmail-sync', userId: email });
const job = await enqueueJob({ lane: 'google-api', type: 'gmail-sync', userId: email });
const poll = async (): Promise<'completed' | 'failed' | 'cancelled'> => {
for (let i = 0; i < 120; i++) {