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>
93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
import { join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
|
|
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');
|
|
|
|
// Generate a random proxy secret at startup — shared with Claude Code spawns
|
|
// 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?: {
|
|
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 startAnthropicProxy() {
|
|
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 !== proxySecret) {
|
|
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 — avoids stream-in-stream issues
|
|
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();
|
|
// Skip hop-by-hop and encoding headers that may have been consumed by fetch
|
|
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(`[anthropic-proxy] listening on 127.0.0.1:${PROXY_PORT}`);
|
|
}
|