250 lines
7.1 KiB
TypeScript
250 lines
7.1 KiB
TypeScript
import { join } from 'node:path';
|
|
import type { Subprocess } from 'bun';
|
|
import type { PiEvent } from '../../api/pi/types';
|
|
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
|
import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../sandbox';
|
|
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
|
import { parseStream } from './stream-parser';
|
|
|
|
const SEND_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
|
// Use /usr/local/bin/claude so it's visible inside bwrap sandbox (which ro-binds /usr).
|
|
// The actual binary lives at ~/.local/bin/claude, symlinked from /usr/local/bin/claude.
|
|
const CLAUDE_BIN = '/usr/local/bin/claude';
|
|
|
|
// Capture original HOME before user-instance overrides it
|
|
const HOST_HOME = process.env.HOME!;
|
|
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|
|
|
// Build full bwrap sandbox args for Claude (prefix + Anthropic env + runuser suffix)
|
|
function buildSandboxArgs(email: string): string[] {
|
|
const prefix = buildSandboxPrefix(email);
|
|
|
|
// Claude-specific env vars
|
|
if (process.env.ANTHROPIC_BASE_URL) prefix.push('--setenv', 'ANTHROPIC_BASE_URL', process.env.ANTHROPIC_BASE_URL);
|
|
if (process.env.ANTHROPIC_API_KEY) prefix.push('--setenv', 'ANTHROPIC_API_KEY', process.env.ANTHROPIC_API_KEY);
|
|
|
|
return [...prefix, ...buildRunuserSuffix()];
|
|
}
|
|
|
|
// Active streaming processes
|
|
const activeProcs = new Map<string, Subprocess>();
|
|
|
|
// MCP config paths, set by user-instance at startup
|
|
let mcpSandboxPath: string | undefined; // path inside bwrap sandbox (/data/...)
|
|
let mcpHostPath: string | undefined; // path on the host filesystem
|
|
|
|
export function setMcpConfigPath(sandboxPath: string, hostPath: string): void {
|
|
mcpSandboxPath = sandboxPath;
|
|
mcpHostPath = hostPath;
|
|
}
|
|
|
|
// ── 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 { prompt, sessionKey, email } = params;
|
|
|
|
const existingSession = getClaudeSession(sessionKey);
|
|
|
|
const claudeArgs = [
|
|
CLAUDE_BIN,
|
|
'-p',
|
|
prompt,
|
|
'--dangerously-skip-permissions',
|
|
'--output-format',
|
|
'json',
|
|
];
|
|
|
|
const isSuperAdmin = params.role === 'Super Admin';
|
|
const mcpConfig = isSuperAdmin ? mcpHostPath : mcpSandboxPath;
|
|
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
|
|
|
const subModel = params.model?.split('/')[1];
|
|
if (subModel) claudeArgs.push('--model', subModel);
|
|
|
|
if (existingSession) {
|
|
claudeArgs.push('--resume', existingSession);
|
|
}
|
|
|
|
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
|
const spawnCwd = isSuperAdmin ? HOST_HOME : undefined;
|
|
|
|
const proc = Bun.spawn(spawnCmd, {
|
|
stdin: 'pipe',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
cwd: spawnCwd,
|
|
env: process.env as Record<string, string>,
|
|
});
|
|
|
|
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 { prompt, sessionKey, email } = params;
|
|
|
|
const existingSession = getClaudeSession(sessionKey);
|
|
|
|
const claudeArgs = [
|
|
CLAUDE_BIN,
|
|
'-p',
|
|
prompt,
|
|
'--dangerously-skip-permissions',
|
|
'--output-format',
|
|
'stream-json',
|
|
'--verbose',
|
|
'--include-partial-messages',
|
|
];
|
|
|
|
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
|
const isSuperAdmin = params.role === 'Super Admin';
|
|
const mcpConfig = isSuperAdmin ? mcpHostPath : mcpSandboxPath;
|
|
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
|
|
|
const subModel = params.model?.split('/')[1];
|
|
if (subModel) claudeArgs.push('--model', subModel);
|
|
|
|
if (existingSession) {
|
|
claudeArgs.push('--resume', existingSession);
|
|
}
|
|
|
|
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
|
const spawnCwd = isSuperAdmin ? HOST_HOME : undefined;
|
|
|
|
const proc = Bun.spawn(spawnCmd, {
|
|
stdin: 'ignore',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
cwd: spawnCwd,
|
|
env: cleanEnv as Record<string, string>,
|
|
});
|
|
|
|
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 callbacks = {
|
|
onEvent,
|
|
onSessionId: (sessionId: string) => setClaudeSession(sessionKey, sessionId),
|
|
};
|
|
|
|
const state = await parseStream(stdout, callbacks);
|
|
|
|
clearTimeout(timeout);
|
|
|
|
if (!state.gotResult) {
|
|
const exitCode = await proc.exited;
|
|
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
|
|
if (state.textBuffer) {
|
|
onEvent({ type: 'text', text: state.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());
|
|
}
|