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:
@@ -1,5 +1,11 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'officer-sidecar',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer',
|
||||
script: 'bun',
|
||||
|
||||
+3
-6
@@ -12,7 +12,7 @@ import { desktopWebsocket } from './servers/api/desktop/websocket';
|
||||
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
||||
import officerWeb from './apps/officer-web/index.html';
|
||||
import { startBrowserRelay } from './servers/api/browser/relay';
|
||||
import { startAnthropicProxy } from './servers/api/anthropic-proxy';
|
||||
import { initSidecarClient } from './servers/sidecar-client';
|
||||
import { toShellUsername } from './servers/data-path';
|
||||
|
||||
const { PORT = '5000' } = process.env;
|
||||
@@ -229,11 +229,8 @@ try {
|
||||
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
try {
|
||||
startAnthropicProxy();
|
||||
} catch (err) {
|
||||
console.error('[anthropic-proxy] failed to start:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
// Connect to process sidecar (owns proxy, Claude Code, Pi, queue)
|
||||
initSidecarClient();
|
||||
|
||||
|
||||
void initTerminalSidecars();
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { syncSeedExtensions } from './sync-extensions';
|
||||
import { syncSeedResources } from './sync-resources';
|
||||
import { migrateSettingsToResources } from './migrate-resources';
|
||||
import { generateResourceSkill } from './api/pi/pi-bridge';
|
||||
import { initQueue } from './queue';
|
||||
// Queue is now owned by the sidecar process
|
||||
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
||||
import { startTelegramBotIfConfigured } from './channels/telegram/bot';
|
||||
import { startWhatsAppBotIfConfigured } from './channels/whatsapp/bot';
|
||||
@@ -78,9 +78,7 @@ async function installPi(): Promise<boolean> {
|
||||
await migrateSettingsToResources();
|
||||
generateResourceSkill(DATA_PATH);
|
||||
|
||||
await initQueue().catch((err) => {
|
||||
console.error('[bootstrap] Failed to initialize queue:', err);
|
||||
});
|
||||
// Queue is initialized by the sidecar process
|
||||
|
||||
await startDiscordBotIfConfigured().catch((err) => {
|
||||
console.error('[channels] Failed to start Discord bot:', err);
|
||||
|
||||
@@ -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'> => {
|
||||
|
||||
@@ -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);
|
||||
// Unsubscribe when we get a terminal event
|
||||
if (event.type === 'result' || event.type === 'error') {
|
||||
unsub();
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
kill: () => {
|
||||
sidecar.killClaude(params.sessionKey);
|
||||
unsub();
|
||||
},
|
||||
);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
# Process Sidecar
|
||||
|
||||
Independent Bun process that owns all long-running work so the API server can restart without disrupting active sessions.
|
||||
|
||||
## Problem
|
||||
|
||||
The API server (port 5000) previously owned all spawned processes: Pi agents, Claude Code sessions, the Anthropic proxy, and the job queue. Restarting the API server would:
|
||||
|
||||
- Kill active Pi and Claude Code conversations mid-response
|
||||
- Regenerate the Anthropic proxy secret, breaking any Claude Code sessions using it
|
||||
- Lose in-flight job progress (queue engine ran in-process)
|
||||
|
||||
## Solution
|
||||
|
||||
A separate Bun process ("process sidecar") on port 5100 that owns all spawned processes. The API server communicates with it over a single WebSocket connection. The sidecar is managed by pm2 and starts before the API server.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ API Server (:5000) │ WS │ Process Sidecar (:5100) │
|
||||
│ │◄───────►│ │
|
||||
│ - Browser WS clients │ │ - Anthropic Proxy (:5051) │
|
||||
│ - REST API routes │ │ - Claude Code processes │
|
||||
│ - Channel bots │ │ - Pi agent processes │
|
||||
│ - sidecar-client.ts │ │ - Job queue engine │
|
||||
│ (auto-reconnect) │ │ - State persistence │
|
||||
└─────────────────────────┘ └──────────────────────────────┘
|
||||
```
|
||||
|
||||
### What the sidecar owns
|
||||
|
||||
| Concern | Previous location | Sidecar module |
|
||||
|---------|-------------------|----------------|
|
||||
| Anthropic proxy (port 5051) | `api/anthropic-proxy.ts` | `sidecar/proxy.ts` |
|
||||
| Claude Code spawn + session tracking | `channels/send-claude-code.ts` | `sidecar/claude-manager.ts` |
|
||||
| Pi agent spawn + RPC commands | `api/pi/pi-bridge.ts` | `sidecar/pi-manager.ts` |
|
||||
| Job queue engine + handlers | `queue/engine.ts` | `sidecar/queue-runner.ts` |
|
||||
|
||||
### What stays in the API server
|
||||
|
||||
- Browser WebSocket connections (ephemeral by nature)
|
||||
- REST API routes (now thin proxies to sidecar)
|
||||
- Channel bots (Discord/Telegram/WhatsApp — already reconnect gracefully)
|
||||
- Browser relay (CDP state is ephemeral)
|
||||
- PTY sidecar (already its own process, unchanged)
|
||||
|
||||
## Communication Protocol
|
||||
|
||||
Single WebSocket between API server and sidecar. JSON messages with `{ type, id?, ... }` envelopes.
|
||||
|
||||
**Request/response**: Commands include an `id` field. The sidecar responds with a message carrying the same `id`. The client correlates responses via this ID with configurable timeouts.
|
||||
|
||||
**Streaming events**: Pi and Claude Code output events are broadcast to all connected clients without a correlation ID. They carry a `sessionId` or `sessionKey` so the API server can route them to the correct browser WS.
|
||||
|
||||
### Command categories
|
||||
|
||||
```
|
||||
ping / pong — health check
|
||||
state:sync — full state snapshot on connect
|
||||
|
||||
proxy:secret — get persisted proxy secret
|
||||
|
||||
claude:spawn / claude:result — blocking Claude Code exec
|
||||
claude:spawn-streaming / claude:event — streaming Claude Code exec
|
||||
claude:kill / claude:clear-session — session management
|
||||
|
||||
pi:spawn / pi:prompt / pi:abort — Pi agent lifecycle
|
||||
pi:kill / pi:set-thinking — Pi session control
|
||||
|
||||
queue:enqueue / queue:cancel — job management
|
||||
queue:list / queue:get — job queries
|
||||
```
|
||||
|
||||
See `protocol.ts` for the full type definitions.
|
||||
|
||||
## State Persistence
|
||||
|
||||
File: `data/sidecar/state.json`
|
||||
|
||||
Written every 30 seconds (debounced) and on graceful shutdown (SIGTERM/SIGINT). Contains:
|
||||
|
||||
- **proxySecret** — generated once on first boot, reused forever. This is the key fix: the Anthropic proxy secret no longer changes on restart.
|
||||
- **claudeSessions** — map of `sessionKey → Claude Code session_id` for `--resume` support across restarts.
|
||||
- **piSessions** — session metadata with PIDs for liveness checking on restart.
|
||||
|
||||
### Lockfile
|
||||
|
||||
`data/sidecar/sidecar.lock` — contains the PID of the running sidecar. On startup, if the lock exists and the PID is alive, the sidecar exits. Stale locks (dead PID) are cleaned up automatically.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/servers/sidecar/
|
||||
index.ts — entry point: Bun.serve on :5100, WS dispatch, shutdown
|
||||
protocol.ts — shared message types (imported by both sides)
|
||||
proxy.ts — Anthropic proxy server (moved from anthropic-proxy.ts)
|
||||
claude-manager.ts — Claude Code blocking + streaming spawn, session map
|
||||
pi-manager.ts — Pi agent spawn, RPC (prompt/abort/thinking), event parsing
|
||||
queue-runner.ts — Job queue engine (lanes, retries, notifications)
|
||||
state.ts — File-backed state persistence + lockfile
|
||||
|
||||
src/servers/sidecar-client.ts — API server's WebSocket client (singleton)
|
||||
```
|
||||
|
||||
## API Server Integration
|
||||
|
||||
The API server connects to the sidecar on startup via `initSidecarClient()` in `server.tsx`. The client auto-reconnects with exponential backoff (200ms → 15s).
|
||||
|
||||
On connect, it sends `state:sync` to get the current proxy secret and live session info.
|
||||
|
||||
### Modified API server files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `server.tsx` | `startAnthropicProxy()` → `initSidecarClient()` |
|
||||
| `bootstrap.ts` | Removed `initQueue()` (sidecar owns it) |
|
||||
| `channels/send-claude-code.ts` | 550 lines → 73 lines thin client |
|
||||
| `api/pi/websocket.ts` | Spawn/prompt/abort go through sidecar |
|
||||
| `api/pi/session-manager.ts` | Cleanup sidecar subscriptions on delete |
|
||||
| `api/queue/queue.ts` | Routes use sidecar client |
|
||||
| `channels/discord/handler.ts` | `enqueue` → `enqueueJob` via sidecar |
|
||||
| `channels/telegram/handler.ts` | Same |
|
||||
| `channels/whatsapp/handler.ts` | Same |
|
||||
|
||||
## PM2 Configuration
|
||||
|
||||
```js
|
||||
// ecosystem.config.cjs
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'officer-sidecar', // starts first
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer',
|
||||
script: 'bun',
|
||||
args: 'start',
|
||||
watch: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
The sidecar is listed first so pm2 starts it before the API server. The API server's sidecar client handles the case where the sidecar isn't ready yet (auto-reconnect with backoff).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SIDECAR_PORT` | `5100` | Sidecar HTTP/WS port |
|
||||
| `ANTHROPIC_PROXY_PORT` | `5051` | Anthropic proxy port (owned by sidecar) |
|
||||
| `DATA_PATH` | `./data` | Shared data directory |
|
||||
|
||||
## Manual Testing
|
||||
|
||||
### 1. Start the sidecar standalone
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
bun run src/servers/sidecar/index.ts
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
[sidecar:proxy] listening on 127.0.0.1:5051
|
||||
[sidecar:queue] initialized
|
||||
[sidecar] listening on 127.0.0.1:5100
|
||||
```
|
||||
|
||||
### 2. Health check
|
||||
|
||||
```bash
|
||||
# Basic liveness
|
||||
curl http://127.0.0.1:5100/
|
||||
# → "process-sidecar"
|
||||
|
||||
# Detailed health
|
||||
curl http://127.0.0.1:5100/health
|
||||
# → {"status":"ok","uptime":12345,"piSessions":0,"claudeSessions":0}
|
||||
```
|
||||
|
||||
### 3. Verify Anthropic proxy is running
|
||||
|
||||
```bash
|
||||
# Should reject without valid secret
|
||||
curl -s http://127.0.0.1:5051/v1/messages
|
||||
# → {"error":"Unauthorized"}
|
||||
|
||||
# Check state file was created
|
||||
cat data/sidecar/state.json
|
||||
# → should show proxySecret, empty claudeSessions, empty piSessions
|
||||
```
|
||||
|
||||
### 4. Verify proxy secret persistence
|
||||
|
||||
```bash
|
||||
# Note the proxySecret from state.json
|
||||
cat data/sidecar/state.json | jq .proxySecret
|
||||
|
||||
# Stop and restart the sidecar
|
||||
# Kill the sidecar (Ctrl+C or kill)
|
||||
bun run src/servers/sidecar/index.ts
|
||||
|
||||
# Check the secret is the same
|
||||
cat data/sidecar/state.json | jq .proxySecret
|
||||
# → should be identical to before
|
||||
```
|
||||
|
||||
### 5. WebSocket communication test
|
||||
|
||||
```bash
|
||||
# In one terminal, start the sidecar
|
||||
bun run src/servers/sidecar/index.ts
|
||||
|
||||
# In another terminal, connect with websocat (or wscat)
|
||||
# Install: cargo install websocat OR npm install -g wscat
|
||||
websocat ws://127.0.0.1:5100
|
||||
|
||||
# Send a ping
|
||||
{"type":"ping","id":"test1"}
|
||||
# → should receive: {"type":"pong","id":"test1"}
|
||||
|
||||
# Send state sync
|
||||
{"type":"state:sync","id":"test2"}
|
||||
# → should receive: {"type":"state:sync","id":"test2","state":{...}}
|
||||
|
||||
# Get proxy secret
|
||||
{"type":"proxy:secret","id":"test3"}
|
||||
# → should receive: {"type":"proxy:secret","id":"test3","secret":"sk-ant-api03-..."}
|
||||
```
|
||||
|
||||
### 6. Test lockfile protection
|
||||
|
||||
```bash
|
||||
# Start sidecar in one terminal
|
||||
bun run src/servers/sidecar/index.ts
|
||||
|
||||
# Try starting another in a second terminal
|
||||
bun run src/servers/sidecar/index.ts
|
||||
# → should exit with: "[sidecar] another instance is already running"
|
||||
|
||||
# Check lockfile
|
||||
cat data/sidecar/sidecar.lock
|
||||
# → PID of the running sidecar
|
||||
```
|
||||
|
||||
### 7. Full integration test (sidecar + API server)
|
||||
|
||||
```bash
|
||||
# Start sidecar first
|
||||
bun run src/servers/sidecar/index.ts &
|
||||
|
||||
# Start API server
|
||||
bun start
|
||||
# → should see "[sidecar-client] connected" in logs
|
||||
|
||||
# Test via the dashboard:
|
||||
# 1. Open a chat panel, send a message with claude-code model
|
||||
# → should see streaming response (routed through sidecar)
|
||||
# 2. Open a chat with a Pi model
|
||||
# → should see streaming response (routed through sidecar)
|
||||
# 3. Restart the API server (kill + bun start)
|
||||
# → active Claude Code processes should NOT die
|
||||
# → sidecar should show "client disconnected" then "client connected"
|
||||
# → proxy secret should remain the same
|
||||
```
|
||||
|
||||
### 8. Test API server restart resilience
|
||||
|
||||
This is the key scenario that motivated the sidecar:
|
||||
|
||||
```bash
|
||||
# 1. Start sidecar + API server
|
||||
bun run src/servers/sidecar/index.ts &
|
||||
bun start &
|
||||
|
||||
# 2. Start a Claude Code streaming session in the dashboard
|
||||
|
||||
# 3. While Claude Code is running, kill the API server
|
||||
kill $(pgrep -f "bun start")
|
||||
|
||||
# 4. Restart the API server
|
||||
bun start
|
||||
|
||||
# 5. Check:
|
||||
# - The Claude Code process should still be running (check with ps)
|
||||
# - The proxy secret should be the same (check data/sidecar/state.json)
|
||||
# - The sidecar should show the reconnection in logs
|
||||
```
|
||||
|
||||
### 9. Test with pm2
|
||||
|
||||
```bash
|
||||
pm2 start ecosystem.config.cjs
|
||||
|
||||
# Check both are running
|
||||
pm2 status
|
||||
# → officer-sidecar: online
|
||||
# → officer: online
|
||||
|
||||
# Restart API server only
|
||||
pm2 restart officer
|
||||
|
||||
# Check sidecar is still running
|
||||
pm2 status
|
||||
curl http://127.0.0.1:5100/health
|
||||
|
||||
# Stop everything
|
||||
pm2 stop all
|
||||
```
|
||||
|
||||
### 10. Queue test
|
||||
|
||||
```bash
|
||||
# With sidecar running, queue a job via the API
|
||||
curl -X POST http://localhost:5000/api/queue/jobs \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-d '{"lane":"test","type":"gmail-sync"}'
|
||||
|
||||
# List jobs
|
||||
curl http://localhost:5000/api/queue/jobs \
|
||||
-H "Authorization: Bearer <your-token>"
|
||||
```
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
On SIGTERM or SIGINT, the sidecar:
|
||||
1. Flushes pending state to `data/sidecar/state.json`
|
||||
2. Releases the lockfile
|
||||
3. Does **NOT** kill spawned processes — they are independent OS processes
|
||||
|
||||
On restart, the sidecar reads the persisted state and checks which PIDs are still alive.
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Sidecar dies with live sessions | pm2 auto-restart + state.json + processes are independent OS processes |
|
||||
| API↔sidecar WebSocket drops | Auto-reconnect with exponential backoff (200ms → 15s) |
|
||||
| Two sidecar instances running | Lockfile with PID liveness check on startup |
|
||||
| Sidecar needs DB access | Imports DB modules directly (same user, same filesystem) |
|
||||
| Queue handlers need server context | Handlers are self-contained modules imported by the sidecar |
|
||||
@@ -0,0 +1,355 @@
|
||||
import { join } from 'node:path';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { PiEvent, MessageCost } from '../api/pi/types';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from './protocol';
|
||||
import { getState, setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { getProxySecret } from './proxy';
|
||||
|
||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
|
||||
const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
||||
|
||||
const toShellUsername = (username: string, email: string): string => {
|
||||
const raw = username || email.split('@')[0]!;
|
||||
return raw.replace(/@.*$/, '').replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase().slice(0, 32) || 'officer';
|
||||
};
|
||||
|
||||
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
|
||||
const CLAUDE_BIN = (() => {
|
||||
const result = Bun.spawnSync({ cmd: ['which', 'claude'], stdout: 'pipe', stderr: 'ignore' });
|
||||
return result.stdout.toString().trim() || 'claude';
|
||||
})();
|
||||
|
||||
// Active streaming processes
|
||||
const activeProcs = new Map<string, Subprocess>();
|
||||
|
||||
function buildAuthEnv(shellUsername: string, homeDir: string, isServiceUser: boolean, userHasCredentials: boolean): Record<string, string> {
|
||||
if (isServiceUser) return { HOME: process.env.HOME ?? '' };
|
||||
if (userHasCredentials) return { HOME: homeDir };
|
||||
return { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: getProxySecret() };
|
||||
}
|
||||
|
||||
// ── Blocking send ──
|
||||
|
||||
type ClaudeCodeOutput = {
|
||||
result: string;
|
||||
session_id: string;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
is_error: boolean;
|
||||
};
|
||||
|
||||
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
||||
const { userId, email, username, prompt, sessionKey } = params;
|
||||
const homeDir = getHomeDir(email);
|
||||
const shellUsername = toShellUsername(username, 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 = getClaudeSession(sessionKey);
|
||||
if (existingSession) {
|
||||
claudeArgs.push('--resume', existingSession);
|
||||
}
|
||||
|
||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||
const userHasCredentials = !isServiceUser && (await hasOwnCredentials(homeDir));
|
||||
const authEnv = buildAuthEnv(shellUsername, homeDir, isServiceUser, userHasCredentials);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...authEnv,
|
||||
PATH: process.env.PATH ?? '',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
|
||||
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 (exitCode !== 0 && !stdout.trim()) {
|
||||
throw new Error(`claude exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`);
|
||||
}
|
||||
|
||||
let output: ClaudeCodeOutput;
|
||||
try {
|
||||
output = JSON.parse(stdout) as ClaudeCodeOutput;
|
||||
} catch {
|
||||
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');
|
||||
}
|
||||
|
||||
if (output.session_id) {
|
||||
setClaudeSession(sessionKey, output.session_id);
|
||||
}
|
||||
|
||||
return {
|
||||
text: output.result || '(no response)',
|
||||
sessionId: sessionKey,
|
||||
model: 'claude-code',
|
||||
cost: {
|
||||
inputTokens: output.input_tokens ?? 0,
|
||||
outputTokens: output.output_tokens ?? 0,
|
||||
totalUSD: output.cost_usd ?? 0,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming send ──
|
||||
|
||||
export async function spawnClaudeStreaming(
|
||||
params: ClaudeSpawnStreamingParams,
|
||||
onEvent: (event: PiEvent) => void,
|
||||
): Promise<void> {
|
||||
const { userId, email, username, prompt, sessionKey, cwd } = params;
|
||||
const shellUsername = toShellUsername(username, email);
|
||||
const homeDir = getHomeDir(email);
|
||||
const workDir = cwd ?? homeDir;
|
||||
|
||||
const claudeArgs = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format', 'stream-json',
|
||||
'--verbose', '--include-partial-messages',
|
||||
];
|
||||
|
||||
const subModel = params.model?.split('/')[1];
|
||||
if (subModel) claudeArgs.push('--model', subModel);
|
||||
|
||||
const existingSession = getClaudeSession(sessionKey);
|
||||
if (existingSession) {
|
||||
claudeArgs.push('--resume', existingSession);
|
||||
}
|
||||
|
||||
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
||||
|
||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||
const userHasCredentials = !isServiceUser && (await hasOwnCredentials(homeDir));
|
||||
const authEnv = buildAuthEnv(shellUsername, homeDir, isServiceUser, userHasCredentials);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...authEnv,
|
||||
PATH: cleanEnv.PATH ?? '',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
|
||||
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' },
|
||||
);
|
||||
|
||||
activeProcs.set(sessionKey, proc);
|
||||
|
||||
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
|
||||
try {
|
||||
const stdout = proc.stdout as ReadableStream<Uint8Array>;
|
||||
const reader = stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let textBuffer = '';
|
||||
let gotResult = false;
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const msg = JSON.parse(line) as Record<string, unknown>;
|
||||
const type = msg.type as string;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
} 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') {
|
||||
onEvent({ type: 'text', text: block.text });
|
||||
textBuffer = '';
|
||||
} else if (block.type === 'tool_use') {
|
||||
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>) ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type === 'system' && msg.subtype === 'init') {
|
||||
const sessionId = msg.session_id as string | undefined;
|
||||
if (sessionId) {
|
||||
setClaudeSession(sessionKey, sessionId);
|
||||
}
|
||||
} else if (type === 'result') {
|
||||
gotResult = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
const isError = (msg.is_error as boolean) ?? false;
|
||||
const resultText = (msg.result as string) ?? '';
|
||||
|
||||
if (isError) {
|
||||
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) {
|
||||
setClaudeSession(sessionKey, sessionId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
processLine(buffer);
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!gotResult) {
|
||||
const exitCode = await proc.exited;
|
||||
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
|
||||
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) });
|
||||
} finally {
|
||||
activeProcs.delete(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function killClaudeSession(sessionKey: string): boolean {
|
||||
const proc = activeProcs.get(sessionKey);
|
||||
if (proc) {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
activeProcs.delete(sessionKey);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function clearSession(sessionKey: string): void {
|
||||
clearClaudeSession(sessionKey);
|
||||
}
|
||||
|
||||
export function getActiveSessionKeys(): string[] {
|
||||
return Array.from(activeProcs.keys());
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import type { SidecarCommand, SidecarEvent, SidecarState } from './protocol';
|
||||
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
|
||||
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
|
||||
import * as claudeManager from './claude-manager';
|
||||
import * as piManager from './pi-manager';
|
||||
import * as queueRunner from './queue-runner';
|
||||
|
||||
const PORT = Number(process.env.SIDECAR_PORT ?? '5100');
|
||||
const startedAt = Date.now();
|
||||
|
||||
// ── Startup ──
|
||||
|
||||
if (!acquireLock()) {
|
||||
console.error('[sidecar] another instance is already running (lock file exists with live PID)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
loadState();
|
||||
ensureProxySecret();
|
||||
|
||||
// Start Anthropic proxy
|
||||
try {
|
||||
startAnthropicProxy();
|
||||
} catch (err) {
|
||||
console.error('[sidecar] failed to start Anthropic proxy:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// Initialize queue
|
||||
queueRunner.initQueue().catch((err) => {
|
||||
console.error('[sidecar] failed to initialize queue:', err);
|
||||
});
|
||||
|
||||
// ── WebSocket connections ──
|
||||
|
||||
const clients = new Set<ServerWebSocket<unknown>>();
|
||||
|
||||
function broadcast(msg: SidecarEvent) {
|
||||
const data = JSON.stringify(msg);
|
||||
for (const ws of clients) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reply(ws: ServerWebSocket<unknown>, msg: SidecarEvent) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
function buildState(): SidecarState {
|
||||
return {
|
||||
proxySecret: getProxySecret(),
|
||||
claudeSessions: { ...getState().claudeSessions },
|
||||
piSessions: piManager.getAllSessions(),
|
||||
uptime: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
async function handleCommand(ws: ServerWebSocket<unknown>, cmd: SidecarCommand) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply(ws, { type: 'pong', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'state:sync':
|
||||
reply(ws, { type: 'state:sync', id: cmd.id, state: buildState() });
|
||||
break;
|
||||
|
||||
case 'proxy:secret':
|
||||
reply(ws, { type: 'proxy:secret', id: cmd.id, secret: getProxySecret() });
|
||||
break;
|
||||
|
||||
// ── Claude Code ──
|
||||
|
||||
case 'claude:spawn': {
|
||||
try {
|
||||
const result = await claudeManager.spawnClaude(cmd.params);
|
||||
reply(ws, { type: 'claude:result', id: cmd.id, result });
|
||||
} catch (err) {
|
||||
reply(ws, { type: 'claude:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:spawn-streaming': {
|
||||
reply(ws, { type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
|
||||
|
||||
const onEvent = (event: import('../api/pi/types').PiEvent) => {
|
||||
broadcast({ type: 'claude:event', sessionKey: cmd.params.sessionKey, event });
|
||||
};
|
||||
|
||||
claudeManager.spawnClaudeStreaming(cmd.params, onEvent).catch((err) => {
|
||||
broadcast({
|
||||
type: 'claude:event',
|
||||
sessionKey: cmd.params.sessionKey,
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:kill':
|
||||
claudeManager.killClaudeSession(cmd.sessionKey);
|
||||
reply(ws, { type: 'claude:killed', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'claude:clear-session':
|
||||
claudeManager.clearSession(cmd.sessionKey);
|
||||
reply(ws, { type: 'claude:session-cleared', id: cmd.id });
|
||||
break;
|
||||
|
||||
// ── Pi ──
|
||||
|
||||
case 'pi:spawn': {
|
||||
try {
|
||||
const onEvent = (event: import('../api/pi/types').PiEvent) => {
|
||||
broadcast({ type: 'pi:event', sessionId: cmd.params.sessionId, event });
|
||||
};
|
||||
|
||||
await piManager.spawnPi({
|
||||
sessionId: cmd.params.sessionId,
|
||||
email: cmd.params.email,
|
||||
userId: cmd.params.userId,
|
||||
username: cmd.params.username,
|
||||
role: cmd.params.role,
|
||||
cwd: cmd.params.cwd,
|
||||
model: cmd.params.model,
|
||||
sessionFile: cmd.params.sessionFile,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
reply(ws, { type: 'pi:spawned', id: cmd.id, sessionId: cmd.params.sessionId });
|
||||
} catch (err) {
|
||||
reply(ws, { type: 'pi:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pi:prompt':
|
||||
piManager.sendPrompt(cmd.sessionId, cmd.prompt, cmd.requestId);
|
||||
break;
|
||||
|
||||
case 'pi:abort':
|
||||
piManager.abort(cmd.sessionId, cmd.requestId);
|
||||
break;
|
||||
|
||||
case 'pi:kill':
|
||||
piManager.killPiSession(cmd.sessionId);
|
||||
reply(ws, { type: 'pi:killed', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'pi:set-thinking':
|
||||
piManager.setThinkingLevel(cmd.sessionId, cmd.level);
|
||||
break;
|
||||
|
||||
// ── Queue ──
|
||||
|
||||
case 'queue:enqueue': {
|
||||
try {
|
||||
const job = await queueRunner.enqueue(cmd.params);
|
||||
reply(ws, { type: 'queue:enqueued', id: cmd.id, job });
|
||||
} catch (err) {
|
||||
reply(ws, { type: 'queue:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'queue:cancel': {
|
||||
try {
|
||||
const job = await queueRunner.cancelJob(cmd.jobId);
|
||||
reply(ws, { type: 'queue:cancelled', id: cmd.id, job });
|
||||
} catch (err) {
|
||||
reply(ws, { type: 'queue:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'queue:list': {
|
||||
const jobs = await queueRunner.listAllJobs();
|
||||
reply(ws, { type: 'queue:list', id: cmd.id, jobs });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'queue:get': {
|
||||
const job = await queueRunner.readJob(cmd.jobId);
|
||||
reply(ws, { type: 'queue:get', id: cmd.id, job });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
reply(ws, { type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record<string, unknown>).type}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server ──
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
hostname: '127.0.0.1',
|
||||
|
||||
routes: {
|
||||
'/': () => new Response('process-sidecar'),
|
||||
'/health': () => new Response(JSON.stringify({
|
||||
status: 'ok',
|
||||
uptime: Date.now() - startedAt,
|
||||
piSessions: piManager.getAllSessions().length,
|
||||
claudeSessions: claudeManager.getActiveSessionKeys().length,
|
||||
}), { headers: { 'Content-Type': 'application/json' } }),
|
||||
},
|
||||
|
||||
fetch(req, server) {
|
||||
if (req.headers.get('upgrade') === 'websocket') {
|
||||
const ok = server.upgrade(req);
|
||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||
return;
|
||||
}
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws) {
|
||||
clients.add(ws);
|
||||
console.log(`[sidecar] client connected (${clients.size} total)`);
|
||||
},
|
||||
message(ws, raw) {
|
||||
try {
|
||||
const data = typeof raw === 'string' ? raw : raw.toString();
|
||||
const cmd = JSON.parse(data) as SidecarCommand;
|
||||
handleCommand(ws, cmd);
|
||||
} catch (err) {
|
||||
reply(ws, { type: 'error', error: `Invalid message: ${err instanceof Error ? err.message : String(err)}` });
|
||||
}
|
||||
},
|
||||
close(ws) {
|
||||
clients.delete(ws);
|
||||
console.log(`[sidecar] client disconnected (${clients.size} total)`);
|
||||
},
|
||||
drain() {},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[sidecar] listening on 127.0.0.1:${PORT}`);
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[sidecar] ${signal} received, saving state...`);
|
||||
await flushAndSave();
|
||||
releaseLock();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,416 @@
|
||||
import { join } from 'node:path';
|
||||
import { readdirSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { PiEvent, MessageCost } from '../api/pi/types';
|
||||
import type { PiSpawnParams, PiSessionInfo } from './protocol';
|
||||
import { isPidAlive } from './state';
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
const PI_CONFIG_DIR = join(require('node:os').homedir(), '.pi', 'agent');
|
||||
const SEED_PATH = join(import.meta.dir, '../../../seed');
|
||||
|
||||
const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
||||
const getHomeDirForRole = (email: string, role: string | null): string =>
|
||||
role === 'Super Admin' && process.env.HOME_DIR ? process.env.HOME_DIR : getHomeDir(email);
|
||||
const getGlobalSkillsDir = () => join(DATA_PATH, 'skills');
|
||||
const getUserSkillsDir = (email: string) => join(DATA_PATH, email, 'skills');
|
||||
const getGlobalExtensionsDir = () => join(DATA_PATH, 'extensions');
|
||||
const getUserExtensionsDir = (email: string) => join(DATA_PATH, email, 'extensions');
|
||||
const getGlobalToolsDir = () => join(DATA_PATH, 'tools');
|
||||
const getUserToolsDir = (email: string) => join(DATA_PATH, email, 'tools');
|
||||
const getNativeResourcesDir = () => join(SEED_PATH, 'resources');
|
||||
const getGlobalResourcesDir = () => join(DATA_PATH, 'resources');
|
||||
|
||||
const toShellUsername = (username: string, email: string): string => {
|
||||
const raw = username || email.split('@')[0]!;
|
||||
return raw.replace(/@.*$/, '').replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase().slice(0, 32) || 'officer';
|
||||
};
|
||||
|
||||
// Resolve pi as [node, cli.js]
|
||||
const PI_CMD = (() => {
|
||||
const whichResult = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
|
||||
const piBin = whichResult.stdout.toString().trim() || 'pi';
|
||||
const readlinkResult = Bun.spawnSync({ cmd: ['readlink', '-f', piBin], stdout: 'pipe', stderr: 'ignore' });
|
||||
const realPath = readlinkResult.stdout.toString().trim();
|
||||
const nodeResult = Bun.spawnSync({ cmd: ['which', 'node'], stdout: 'pipe', stderr: 'ignore' });
|
||||
const nodeBin = nodeResult.stdout.toString().trim() || 'node';
|
||||
if (realPath && realPath.endsWith('.js')) {
|
||||
return [nodeBin, realPath];
|
||||
}
|
||||
return [piBin];
|
||||
})();
|
||||
|
||||
// Active Pi processes
|
||||
type PiSession = {
|
||||
sessionId: string;
|
||||
email: string;
|
||||
userId: number;
|
||||
model: string;
|
||||
cwd: string;
|
||||
proc: Subprocess;
|
||||
onEvent: (event: PiEvent) => void;
|
||||
};
|
||||
|
||||
const sessions = new Map<string, PiSession>();
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function collectSkillFlags(email: string): string[] {
|
||||
const flags: string[] = [];
|
||||
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (existsSync(join(dir, entry.name, 'SKILL.md'))) {
|
||||
flags.push('--skill', `${dir}/${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function collectExtensionFlags(email: string): string[] {
|
||||
const flags: string[] = [];
|
||||
const dirs = [getGlobalExtensionsDir(), getUserExtensionsDir(email)];
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (existsSync(join(dir, entry.name, 'index.ts'))) {
|
||||
flags.push('--extension', `${dir}/${entry.name}/index.ts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function buildResourcesEnv(): string {
|
||||
const nativeDir = getNativeResourcesDir();
|
||||
const globalDir = getGlobalResourcesDir();
|
||||
const resourceDirs = new Map<string, string>();
|
||||
for (const dir of [nativeDir, globalDir]) {
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (existsSync(join(dir, entry.name, 'RESOURCE.md')) || existsSync(join(dir, entry.name, 'config.json'))) {
|
||||
resourceDirs.set(entry.name, dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
const result: Record<string, Record<string, string>> = {};
|
||||
for (const [name] of resourceDirs) {
|
||||
let nativeConfig: Record<string, string> = {};
|
||||
let globalConfig: Record<string, string> = {};
|
||||
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
|
||||
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
|
||||
const config: Record<string, string> = {};
|
||||
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
|
||||
for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!;
|
||||
if (Object.values(config).some((v) => v !== '')) {
|
||||
result[name] = config;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
async function resolveApiKeyForModel(model: string): Promise<string | null> {
|
||||
const provider = model.split('/')[0];
|
||||
if (!provider) return null;
|
||||
try {
|
||||
const authFile = Bun.file(join(PI_CONFIG_DIR, 'auth.json'));
|
||||
if (!(await authFile.exists())) return null;
|
||||
const auth = (await authFile.json()) as Record<string, { key?: string }>;
|
||||
return auth[provider]?.key?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event parsing (mirrors pi-bridge.ts) ──
|
||||
|
||||
function parseErrorMessage(raw: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(raw.replace(/^\d+\s*/, ''));
|
||||
const inner = parsed?.error;
|
||||
if (inner?.message) return inner.message;
|
||||
} catch { /* not JSON */ }
|
||||
return raw;
|
||||
}
|
||||
|
||||
function extractMessageError(msg: Record<string, unknown>): string | null {
|
||||
if (msg.stopReason !== 'error') return null;
|
||||
const raw = msg.errorMessage as string | undefined;
|
||||
if (!raw) return null;
|
||||
return parseErrorMessage(raw);
|
||||
}
|
||||
|
||||
function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: string): PiEvent[] {
|
||||
const type = event.type as string;
|
||||
|
||||
if (type === 'response') {
|
||||
if (event.command === 'prompt' && !event.success) {
|
||||
return [{ type: 'error', message: (event.error as string) ?? 'Prompt failed' }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'agent_start':
|
||||
return [];
|
||||
|
||||
case 'message_update': {
|
||||
const ame = event.assistantMessageEvent as Record<string, unknown> | undefined;
|
||||
if (ame?.type === 'text_delta') {
|
||||
return [{ type: 'delta', text: ame.delta as string }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
case 'message_end': {
|
||||
const events: PiEvent[] = [];
|
||||
if (currentStreamBuffer) {
|
||||
events.push({ type: 'text', text: currentStreamBuffer });
|
||||
}
|
||||
const msg = event.message as Record<string, unknown> | undefined;
|
||||
if (msg) {
|
||||
const errorText = extractMessageError(msg);
|
||||
if (errorText) events.push({ type: 'error', message: errorText });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
case 'tool_execution_start':
|
||||
return [{
|
||||
type: 'tool:start',
|
||||
toolCallId: (event.toolCallId as string) ?? '',
|
||||
toolName: (event.toolName as string) ?? 'unknown',
|
||||
toolInput: (event.args as Record<string, unknown>) ?? {},
|
||||
}];
|
||||
|
||||
case 'tool_execution_end': {
|
||||
const toolCallId = (event.toolCallId as string) ?? '';
|
||||
const result = event.result;
|
||||
let resultObj: Record<string, unknown> | null = null;
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
resultObj = result as Record<string, unknown>;
|
||||
} else if (typeof result === 'string') {
|
||||
try { resultObj = JSON.parse(result); } catch { /* not JSON */ }
|
||||
}
|
||||
const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false;
|
||||
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
|
||||
return [{ type: 'tool:result', toolCallId, output, isError }];
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
const cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
const events: PiEvent[] = [];
|
||||
const messages = event.messages as Array<Record<string, unknown>> | undefined;
|
||||
if (messages) {
|
||||
for (const msg of messages) {
|
||||
const usage = msg.usage as Record<string, unknown> | undefined;
|
||||
if (usage) {
|
||||
cost.inputTokens += (usage.input as number) ?? 0;
|
||||
cost.outputTokens += (usage.output as number) ?? 0;
|
||||
const usageCost = usage.cost as Record<string, unknown> | undefined;
|
||||
if (usageCost) cost.totalUSD += (usageCost.total as number) ?? 0;
|
||||
}
|
||||
const errorText = extractMessageError(msg);
|
||||
if (errorText) events.push({ type: 'error', message: errorText });
|
||||
}
|
||||
}
|
||||
events.push({ type: 'result', cost });
|
||||
return events;
|
||||
}
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>): void {
|
||||
const stdin = proc.stdin;
|
||||
if (!stdin || typeof stdin === 'number') return;
|
||||
try {
|
||||
const writer = stdin as { write(data: string): void; flush(): void };
|
||||
writer.write(JSON.stringify(command) + '\n');
|
||||
writer.flush();
|
||||
} catch (err) {
|
||||
console.error('[sidecar:pi] writeRpcCommand error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export type PiSpawnOptions = {
|
||||
sessionId: string;
|
||||
email: string;
|
||||
userId: number;
|
||||
username: string;
|
||||
role: string;
|
||||
cwd: string;
|
||||
model: string;
|
||||
sessionFile?: string;
|
||||
onEvent: (event: PiEvent) => void;
|
||||
};
|
||||
|
||||
export async function spawnPi(options: PiSpawnOptions): Promise<void> {
|
||||
const { sessionId, email, userId, username, role, cwd, model, sessionFile, onEvent } = options;
|
||||
|
||||
const skillFlags = collectSkillFlags(email);
|
||||
const extensionFlags = collectExtensionFlags(email);
|
||||
|
||||
// Generate resource skill
|
||||
const { generateResourceSkill } = await import('../api/pi/pi-bridge');
|
||||
const resourceSkillDir = generateResourceSkill(DATA_PATH);
|
||||
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
|
||||
|
||||
const piArgs = [
|
||||
...PI_CMD,
|
||||
'--mode', 'rpc',
|
||||
'--no-skills', '--no-prompt-templates', '--no-themes',
|
||||
...skillFlags,
|
||||
...extensionFlags,
|
||||
...resourceSkillFlags,
|
||||
];
|
||||
if (model) piArgs.push('--model', model);
|
||||
if (sessionFile) piArgs.push('--session', sessionFile);
|
||||
|
||||
const apiKey = await resolveApiKeyForModel(model);
|
||||
if (apiKey) piArgs.push('--api-key', apiKey);
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
}
|
||||
|
||||
const homeDir = getHomeDirForRole(email, role);
|
||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
||||
const shellUsername = username ? toShellUsername(username, email) : toShellUsername('', email);
|
||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||
|
||||
const env: Record<string, string> = {
|
||||
HOME: isServiceUser ? (process.env.HOME ?? '') : homeDir,
|
||||
OFFICER_USER_HOME: homeDir,
|
||||
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
||||
PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'),
|
||||
PI_TOOLS_DIRS: toolsDirs,
|
||||
OFFICER_RESOURCES: buildResourcesEnv(),
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
TERM: 'xterm-256color',
|
||||
PATH: process.env.PATH ?? '',
|
||||
};
|
||||
|
||||
const proc = isServiceUser
|
||||
? Bun.spawn(piArgs, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...env } })
|
||||
: Bun.spawn(
|
||||
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs],
|
||||
{ cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
||||
);
|
||||
|
||||
const session: PiSession = { sessionId, email, userId, model, cwd, proc, onEvent };
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
console.log(`[sidecar:pi] spawned Pi for session ${sessionId} (model=${model}, pid=${proc.pid})`);
|
||||
|
||||
// Read stdout JSON event stream
|
||||
const stdout = proc.stdout as ReadableStream<Uint8Array>;
|
||||
const reader = stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let streamBuffer = '';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line) as Record<string, unknown>;
|
||||
const piEvents = parsePiEvent(event, streamBuffer);
|
||||
for (const piEvent of piEvents) {
|
||||
if (piEvent.type === 'delta') {
|
||||
streamBuffer += piEvent.text;
|
||||
} else if (piEvent.type === 'text' || piEvent.type === 'tool:start') {
|
||||
streamBuffer = '';
|
||||
}
|
||||
onEvent(piEvent);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch { /* process ended */ }
|
||||
})();
|
||||
|
||||
// Stderr → log
|
||||
const stderr = proc.stderr as ReadableStream<Uint8Array>;
|
||||
const stderrReader = stderr.getReader();
|
||||
const stderrDecoder = new TextDecoder();
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await stderrReader.read();
|
||||
if (done) break;
|
||||
const text = stderrDecoder.decode(value, { stream: true });
|
||||
if (text.trim()) console.log(`[sidecar:pi:stderr] ${text.trim()}`);
|
||||
}
|
||||
} catch { /* process ended */ }
|
||||
})();
|
||||
|
||||
// Handle exit
|
||||
proc.exited.then((code) => {
|
||||
sessions.delete(sessionId);
|
||||
if (code !== 0) {
|
||||
console.error(`[sidecar:pi] Pi process ${sessionId} exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function sendPrompt(sessionId: string, prompt: string, requestId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
writeRpcCommand(session.proc, { type: 'prompt', id: requestId, message: prompt });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function abort(sessionId: string, requestId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
writeRpcCommand(session.proc, { type: 'abort', id: requestId });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setThinkingLevel(sessionId: string, level: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
writeRpcCommand(session.proc, { type: 'set_thinking_level', level });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function killPiSession(sessionId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
try { session.proc.kill(); } catch { /* already dead */ }
|
||||
sessions.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getSession(sessionId: string): PiSession | undefined {
|
||||
return sessions.get(sessionId);
|
||||
}
|
||||
|
||||
export function getAllSessions(): PiSessionInfo[] {
|
||||
return Array.from(sessions.values()).map((s) => ({
|
||||
sessionId: s.sessionId,
|
||||
email: s.email,
|
||||
userId: s.userId,
|
||||
model: s.model,
|
||||
cwd: s.cwd,
|
||||
pid: s.proc.pid,
|
||||
alive: isPidAlive(s.proc.pid),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { MessageCost, PiEvent } from '../api/pi/types';
|
||||
import type { Job, EnqueueParams, JobProgress } from '../queue/types';
|
||||
|
||||
// ── Envelope ──
|
||||
|
||||
export type SidecarMessage = SidecarCommand | SidecarEvent;
|
||||
|
||||
// ── Commands (API server → sidecar) ──
|
||||
|
||||
export type SidecarCommand =
|
||||
| { type: 'ping'; id: string }
|
||||
| { type: 'state:sync'; id: string }
|
||||
// Proxy
|
||||
| { type: 'proxy:secret'; id: string }
|
||||
// Claude Code
|
||||
| { type: 'claude:spawn'; id: string; params: ClaudeSpawnParams }
|
||||
| { type: 'claude:spawn-streaming'; id: string; params: ClaudeSpawnStreamingParams }
|
||||
| { type: 'claude:kill'; id: string; sessionKey: string }
|
||||
| { type: 'claude:clear-session'; id: string; sessionKey: string }
|
||||
// Pi
|
||||
| { type: 'pi:spawn'; id: string; params: PiSpawnParams }
|
||||
| { type: 'pi:prompt'; id: string; sessionId: string; prompt: string; requestId: string }
|
||||
| { type: 'pi:abort'; id: string; sessionId: string; requestId: string }
|
||||
| { type: 'pi:kill'; id: string; sessionId: string }
|
||||
| { type: 'pi:set-thinking'; id: string; sessionId: string; level: string }
|
||||
// Queue
|
||||
| { type: 'queue:enqueue'; id: string; params: EnqueueParams }
|
||||
| { type: 'queue:cancel'; id: string; jobId: string }
|
||||
| { type: 'queue:list'; id: string }
|
||||
| { type: 'queue:get'; id: string; jobId: string };
|
||||
|
||||
// ── Responses/Events (sidecar → API server) ──
|
||||
|
||||
export type SidecarEvent =
|
||||
| { type: 'pong'; id: string }
|
||||
| { type: 'state:sync'; id: string; state: SidecarState }
|
||||
| { type: 'proxy:secret'; id: string; secret: string }
|
||||
// Claude Code
|
||||
| { type: 'claude:spawned'; id: string; sessionKey: string }
|
||||
| { type: 'claude:event'; sessionKey: string; event: PiEvent }
|
||||
| { type: 'claude:result'; id: string; result: ClaudeCodeResult }
|
||||
| { type: 'claude:error'; id: string; error: string }
|
||||
| { type: 'claude:killed'; id: string }
|
||||
| { type: 'claude:session-cleared'; id: string }
|
||||
// Pi
|
||||
| { type: 'pi:spawned'; id: string; sessionId: string }
|
||||
| { type: 'pi:event'; sessionId: string; event: PiEvent }
|
||||
| { type: 'pi:error'; id: string; error: string }
|
||||
| { type: 'pi:killed'; id: string }
|
||||
// Queue
|
||||
| { type: 'queue:enqueued'; id: string; job: Job }
|
||||
| { type: 'queue:cancelled'; id: string; job: Job | null }
|
||||
| { type: 'queue:list'; id: string; jobs: Job[] }
|
||||
| { type: 'queue:get'; id: string; job: Job | null }
|
||||
| { type: 'queue:error'; id: string; error: string }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
// ── Shared state snapshot ──
|
||||
|
||||
export type SidecarState = {
|
||||
proxySecret: string;
|
||||
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
|
||||
piSessions: PiSessionInfo[];
|
||||
uptime: number;
|
||||
};
|
||||
|
||||
export type PiSessionInfo = {
|
||||
sessionId: string;
|
||||
email: string;
|
||||
userId: number;
|
||||
model: string;
|
||||
cwd: string;
|
||||
pid: number;
|
||||
alive: boolean;
|
||||
};
|
||||
|
||||
// ── Param types ──
|
||||
|
||||
export type ClaudeSpawnParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
sessionKey: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type ClaudeSpawnStreamingParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
sessionKey: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type ClaudeCodeResult = {
|
||||
text: string;
|
||||
sessionId: string;
|
||||
model: string;
|
||||
cost: MessageCost;
|
||||
};
|
||||
|
||||
export type PiSpawnParams = {
|
||||
sessionId: string;
|
||||
email: string;
|
||||
userId: number;
|
||||
username: string;
|
||||
role: string;
|
||||
cwd: string;
|
||||
model: string;
|
||||
sessionFile?: string;
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { getState, updateState } from './state';
|
||||
|
||||
const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051');
|
||||
const ANTHROPIC_API_BASE = 'https://api.anthropic.com';
|
||||
const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
|
||||
|
||||
type CredentialsFile = {
|
||||
claudeAiOauth?: {
|
||||
accessToken?: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function readOAuthToken(): Promise<string | null> {
|
||||
try {
|
||||
const file = Bun.file(CREDENTIALS_PATH);
|
||||
if (!(await file.exists())) return null;
|
||||
const data = (await file.json()) as CredentialsFile;
|
||||
return data.claudeAiOauth?.accessToken?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProxySecret(): string {
|
||||
return getState().proxySecret;
|
||||
}
|
||||
|
||||
export function ensureProxySecret(): string {
|
||||
const state = getState();
|
||||
if (state.proxySecret) return state.proxySecret;
|
||||
|
||||
// Generate once, persist forever
|
||||
const secret = `sk-ant-api03-${crypto.randomUUID()}`;
|
||||
updateState({ proxySecret: secret });
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function startAnthropicProxy() {
|
||||
const secret = ensureProxySecret();
|
||||
|
||||
Bun.serve({
|
||||
port: PROXY_PORT,
|
||||
hostname: '127.0.0.1',
|
||||
idleTimeout: 0,
|
||||
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Validate proxy secret
|
||||
const incomingKey = req.headers.get('x-api-key');
|
||||
if (incomingKey !== secret) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Read fresh OAuth token
|
||||
const token = await readOAuthToken();
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: 'No OAuth token available' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Build upstream URL
|
||||
const upstream = `${ANTHROPIC_API_BASE}${url.pathname}${url.search}`;
|
||||
|
||||
// Clone headers, replace proxy secret with real OAuth token
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set('x-api-key', token);
|
||||
headers.delete('host');
|
||||
|
||||
// Read request body fully before forwarding
|
||||
const body = req.body ? await req.arrayBuffer() : null;
|
||||
|
||||
// Forward request
|
||||
const upstreamRes = await fetch(upstream, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
// Build clean response headers
|
||||
const resHeaders = new Headers();
|
||||
for (const [key, value] of upstreamRes.headers) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue;
|
||||
resHeaders.set(key, value);
|
||||
}
|
||||
|
||||
return new Response(upstreamRes.body, {
|
||||
status: upstreamRes.status,
|
||||
statusText: upstreamRes.statusText,
|
||||
headers: resHeaders,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[sidecar:proxy] listening on 127.0.0.1:${PROXY_PORT}`);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { type Job, type JobProgress, type EnqueueParams, type StepContext, PermanentError } from '../queue/types';
|
||||
import { readJob, writeJob, listAllJobs, ensureQueueDir } from '../queue/storage';
|
||||
import { getHandler } from '../queue/handler-registry';
|
||||
|
||||
// Import handlers to register them
|
||||
import '../queue/handlers';
|
||||
|
||||
const activeLanes = new Map<string, boolean>();
|
||||
const PROGRESS_THROTTLE_MS = 1000;
|
||||
|
||||
export async function initQueue() {
|
||||
await ensureQueueDir();
|
||||
await resumeInterruptedJobs();
|
||||
console.log('[sidecar:queue] initialized');
|
||||
}
|
||||
|
||||
export async function enqueue(params: EnqueueParams): Promise<Job> {
|
||||
const handler = getHandler(params.type);
|
||||
if (!handler) throw new Error(`No handler registered for job type: ${params.type}`);
|
||||
|
||||
const job: Job = {
|
||||
id: crypto.randomUUID(),
|
||||
lane: params.lane,
|
||||
type: params.type,
|
||||
userId: params.userId,
|
||||
status: 'queued',
|
||||
steps: handler.steps.map((s) => ({ name: s.name, status: 'pending' as const })),
|
||||
currentStep: 0,
|
||||
createdAt: Date.now(),
|
||||
meta: params.meta,
|
||||
notify: params.notify,
|
||||
};
|
||||
|
||||
await writeJob(job);
|
||||
console.log(`[sidecar:queue] enqueued job ${job.id} (${job.type}) in lane ${job.lane}`);
|
||||
kickLane(job.lane);
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function cancelJob(id: string): Promise<Job | null> {
|
||||
const job = await readJob(id);
|
||||
if (!job) return null;
|
||||
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') return job;
|
||||
|
||||
job.status = 'cancelled';
|
||||
job.completedAt = Date.now();
|
||||
for (const step of job.steps) {
|
||||
if (step.status === 'pending' || step.status === 'running') {
|
||||
step.status = 'failed';
|
||||
step.error = 'Cancelled';
|
||||
}
|
||||
}
|
||||
await writeJob(job);
|
||||
console.log(`[sidecar:queue] cancelled job ${job.id}`);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function resumeInterruptedJobs() {
|
||||
const jobs = await listAllJobs();
|
||||
const lanesToKick = new Set<string>();
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === 'running') {
|
||||
job.status = 'queued';
|
||||
job.startedAt = undefined;
|
||||
for (const step of job.steps) {
|
||||
if (step.status === 'running') {
|
||||
step.status = 'pending';
|
||||
step.startedAt = undefined;
|
||||
}
|
||||
}
|
||||
await writeJob(job);
|
||||
console.log(`[sidecar:queue] reset interrupted job ${job.id} back to queued`);
|
||||
lanesToKick.add(job.lane);
|
||||
} else if (job.status === 'queued') {
|
||||
lanesToKick.add(job.lane);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lane of lanesToKick) {
|
||||
kickLane(lane);
|
||||
}
|
||||
}
|
||||
|
||||
function kickLane(lane: string) {
|
||||
if (activeLanes.get(lane)) return;
|
||||
activeLanes.set(lane, true);
|
||||
processNextInLane(lane);
|
||||
}
|
||||
|
||||
function scheduleRetry(lane: string, delayMs: number) {
|
||||
setTimeout(() => kickLane(lane), delayMs);
|
||||
}
|
||||
|
||||
async function processNextInLane(lane: string) {
|
||||
try {
|
||||
const jobs = await listAllJobs();
|
||||
const now = Date.now();
|
||||
const next = jobs
|
||||
.filter((j) => j.lane === lane && j.status === 'queued' && (!j.retryAt || j.retryAt <= now))
|
||||
.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||
|
||||
if (!next) {
|
||||
activeLanes.set(lane, false);
|
||||
return;
|
||||
}
|
||||
|
||||
await runJob(next);
|
||||
} catch (err) {
|
||||
console.error(`[sidecar:queue] lane ${lane} processing error:`, err);
|
||||
} finally {
|
||||
const jobs = await listAllJobs();
|
||||
const hasMore = jobs.some(
|
||||
(j) => j.lane === lane && j.status === 'queued' && (!j.retryAt || j.retryAt <= Date.now()),
|
||||
);
|
||||
if (hasMore) {
|
||||
processNextInLane(lane);
|
||||
} else {
|
||||
activeLanes.set(lane, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runJob(job: Job) {
|
||||
const handler = getHandler(job.type);
|
||||
if (!handler) {
|
||||
job.status = 'failed';
|
||||
job.error = `No handler for type: ${job.type}`;
|
||||
job.completedAt = Date.now();
|
||||
await writeJob(job);
|
||||
return;
|
||||
}
|
||||
|
||||
job.status = 'running';
|
||||
job.startedAt = Date.now();
|
||||
job.retryAt = undefined;
|
||||
await writeJob(job);
|
||||
const isRetry = (job.retries ?? 0) > 0;
|
||||
console.log(
|
||||
`[sidecar:queue] ${isRetry ? 'resuming' : 'running'} job ${job.id} (${job.type})${isRetry ? ` retry ${job.retries}` : ''}`,
|
||||
);
|
||||
|
||||
const sharedMeta: Record<string, unknown> = { ...(job.meta ?? {}) };
|
||||
|
||||
for (let i = 0; i < handler.steps.length; i++) {
|
||||
const fresh = await readJob(job.id);
|
||||
if (!fresh || fresh.status === 'cancelled') {
|
||||
console.log(`[sidecar:queue] job ${job.id} was cancelled, stopping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handlerStep = handler.steps[i]!;
|
||||
const step = fresh.steps[i]!;
|
||||
|
||||
if (step.status === 'completed') continue;
|
||||
|
||||
fresh.currentStep = i;
|
||||
step.status = 'running';
|
||||
step.startedAt = Date.now();
|
||||
await writeJob(fresh);
|
||||
|
||||
let lastProgressWrite = 0;
|
||||
let pendingProgress: JobProgress | null = null;
|
||||
|
||||
const updateProgress = async (progress: JobProgress) => {
|
||||
step.progress = progress;
|
||||
const now = Date.now();
|
||||
if (now - lastProgressWrite >= PROGRESS_THROTTLE_MS) {
|
||||
lastProgressWrite = now;
|
||||
pendingProgress = null;
|
||||
await writeJob(fresh);
|
||||
} else {
|
||||
pendingProgress = progress;
|
||||
}
|
||||
};
|
||||
|
||||
const ctx: StepContext = { job: fresh, step, updateProgress, meta: sharedMeta };
|
||||
|
||||
try {
|
||||
await handlerStep.run(ctx);
|
||||
|
||||
if (pendingProgress) {
|
||||
step.progress = pendingProgress;
|
||||
}
|
||||
step.status = 'completed';
|
||||
step.completedAt = Date.now();
|
||||
await writeJob(fresh);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
step.status = 'failed';
|
||||
step.error = errorMessage;
|
||||
step.completedAt = Date.now();
|
||||
|
||||
const isPermanent = err instanceof PermanentError;
|
||||
const retries = (fresh.retries ?? 0) + 1;
|
||||
if (!isPermanent && handler.retry && retries <= handler.retry.maxRetries) {
|
||||
step.status = 'pending';
|
||||
step.error = undefined;
|
||||
step.startedAt = undefined;
|
||||
step.completedAt = undefined;
|
||||
step.progress = undefined;
|
||||
fresh.status = 'queued';
|
||||
fresh.error = undefined;
|
||||
fresh.completedAt = undefined;
|
||||
fresh.startedAt = undefined;
|
||||
fresh.retries = retries;
|
||||
fresh.retryAt = Date.now() + handler.retry.delayMs;
|
||||
await writeJob(fresh);
|
||||
console.log(
|
||||
`[sidecar:queue] job ${fresh.id} will retry (${retries}/${handler.retry.maxRetries}) in ${handler.retry.delayMs / 1000}s`,
|
||||
);
|
||||
scheduleRetry(fresh.lane, handler.retry.delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
fresh.status = 'failed';
|
||||
fresh.error = `Step "${step.name}" failed: ${errorMessage}`;
|
||||
fresh.completedAt = Date.now();
|
||||
fresh.meta = { ...fresh.meta, ...sharedMeta };
|
||||
await writeJob(fresh);
|
||||
console.error(`[sidecar:queue] job ${fresh.id} failed at step "${step.name}":`, errorMessage);
|
||||
await notifyFailure(fresh);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const final = await readJob(job.id);
|
||||
if (final && final.status === 'running') {
|
||||
final.status = 'completed';
|
||||
final.completedAt = Date.now();
|
||||
final.meta = { ...final.meta, ...sharedMeta };
|
||||
await writeJob(final);
|
||||
console.log(`[sidecar:queue] job ${final.id} completed`);
|
||||
await notifyCompletion(final);
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyCompletion(job: Job) {
|
||||
try {
|
||||
const { sendMail } = await import('emailer');
|
||||
await sendMail({
|
||||
template: 'JobCompleted',
|
||||
subject: `Job completed: ${job.type}`,
|
||||
to: job.userId,
|
||||
data: { job },
|
||||
});
|
||||
} catch {
|
||||
// SMTP might not be configured — non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyFailure(job: Job) {
|
||||
try {
|
||||
const { sendMail } = await import('emailer');
|
||||
await sendMail({
|
||||
template: 'JobFailed',
|
||||
subject: `Job failed: ${job.type}`,
|
||||
to: job.userId,
|
||||
data: { job },
|
||||
});
|
||||
} catch {
|
||||
// SMTP might not be configured — non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
export { readJob, listAllJobs };
|
||||
@@ -0,0 +1,141 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
const STATE_DIR = join(DATA_PATH, 'sidecar');
|
||||
const STATE_FILE = join(STATE_DIR, 'state.json');
|
||||
const LOCK_FILE = join(STATE_DIR, 'sidecar.lock');
|
||||
|
||||
export type PersistedState = {
|
||||
proxySecret: string;
|
||||
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
|
||||
piSessions: Array<{
|
||||
sessionId: string;
|
||||
email: string;
|
||||
userId: number;
|
||||
model: string;
|
||||
cwd: string;
|
||||
pid: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: PersistedState = {
|
||||
proxySecret: '',
|
||||
claudeSessions: {},
|
||||
piSessions: [],
|
||||
};
|
||||
|
||||
let currentState: PersistedState = { ...DEFAULT_STATE };
|
||||
let saveTimer: Timer | null = null;
|
||||
|
||||
function ensureDir() {
|
||||
if (!existsSync(STATE_DIR)) {
|
||||
mkdirSync(STATE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function loadState(): PersistedState {
|
||||
ensureDir();
|
||||
try {
|
||||
const raw = Bun.file(STATE_FILE);
|
||||
// Synchronous check — Bun.file doesn't have sync exists, use fs
|
||||
if (!existsSync(STATE_FILE)) {
|
||||
currentState = { ...DEFAULT_STATE };
|
||||
return currentState;
|
||||
}
|
||||
// We need to read synchronously at startup
|
||||
const text = require('node:fs').readFileSync(STATE_FILE, 'utf-8');
|
||||
currentState = { ...DEFAULT_STATE, ...JSON.parse(text) };
|
||||
return currentState;
|
||||
} catch {
|
||||
currentState = { ...DEFAULT_STATE };
|
||||
return currentState;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveState(): Promise<void> {
|
||||
ensureDir();
|
||||
await Bun.write(STATE_FILE, JSON.stringify(currentState, null, 2));
|
||||
}
|
||||
|
||||
export function getState(): PersistedState {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
export function updateState(patch: Partial<PersistedState>): void {
|
||||
Object.assign(currentState, patch);
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
export function setClaudeSession(sessionKey: string, sessionId: string): void {
|
||||
currentState.claudeSessions[sessionKey] = sessionId;
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
export function clearClaudeSession(sessionKey: string): void {
|
||||
delete currentState.claudeSessions[sessionKey];
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
export function getClaudeSession(sessionKey: string): string | undefined {
|
||||
return currentState.claudeSessions[sessionKey];
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (saveTimer) return;
|
||||
saveTimer = setTimeout(async () => {
|
||||
saveTimer = null;
|
||||
await saveState();
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
export async function flushAndSave(): Promise<void> {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
await saveState();
|
||||
}
|
||||
|
||||
// ── Lockfile ──
|
||||
|
||||
export function acquireLock(): boolean {
|
||||
ensureDir();
|
||||
try {
|
||||
if (existsSync(LOCK_FILE)) {
|
||||
const pidStr = require('node:fs').readFileSync(LOCK_FILE, 'utf-8').trim();
|
||||
const pid = Number(pidStr);
|
||||
if (pid && isProcessAlive(pid)) {
|
||||
return false; // another sidecar is running
|
||||
}
|
||||
// Stale lock — remove it
|
||||
}
|
||||
require('node:fs').writeFileSync(LOCK_FILE, String(process.pid));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseLock(): void {
|
||||
try {
|
||||
if (existsSync(LOCK_FILE)) {
|
||||
require('node:fs').unlinkSync(LOCK_FILE);
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPidAlive(pid: number): boolean {
|
||||
return isProcessAlive(pid);
|
||||
}
|
||||
Reference in New Issue
Block a user