feat(v2): implement agent-runner sidecar with claude CLI subprocess
Replace Agent SDK stub with working implementation that spawns claude CLI with --output-format stream-json, manages multiple sessions via Map<sessionId, ChildProcess>, and forwards NDJSON events to Rust backend. Supports query, stop, and graceful shutdown.
This commit is contained in:
parent
f0ec44f6a6
commit
f928501075
2 changed files with 618 additions and 3 deletions
|
|
@ -1,12 +1,16 @@
|
|||
// Agent Runner — Node.js sidecar entry point
|
||||
// Spawned by Rust backend, communicates via stdio NDJSON
|
||||
// Phase 3: full Agent SDK integration
|
||||
// Manages claude CLI subprocess with --output-format stream-json
|
||||
|
||||
import { stdin, stdout, stderr } from 'process';
|
||||
import { createInterface } from 'readline';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
|
||||
const rl = createInterface({ input: stdin });
|
||||
|
||||
// Active agent sessions keyed by session ID
|
||||
const sessions = new Map<string, ChildProcess>();
|
||||
|
||||
function send(msg: Record<string, unknown>) {
|
||||
stdout.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
|
@ -24,19 +28,149 @@ rl.on('line', (line: string) => {
|
|||
}
|
||||
});
|
||||
|
||||
interface QueryMessage {
|
||||
type: 'query';
|
||||
sessionId: string;
|
||||
prompt: string;
|
||||
cwd?: string;
|
||||
maxTurns?: number;
|
||||
maxBudgetUsd?: number;
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
interface StopMessage {
|
||||
type: 'stop';
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
function handleMessage(msg: Record<string, unknown>) {
|
||||
switch (msg.type) {
|
||||
case 'ping':
|
||||
send({ type: 'pong' });
|
||||
break;
|
||||
case 'query':
|
||||
// Phase 3: call Agent SDK query()
|
||||
send({ type: 'error', message: 'Agent SDK not yet integrated — Phase 3' });
|
||||
handleQuery(msg as unknown as QueryMessage);
|
||||
break;
|
||||
case 'stop':
|
||||
handleStop(msg as unknown as StopMessage);
|
||||
break;
|
||||
default:
|
||||
send({ type: 'error', message: `Unknown message type: ${msg.type}` });
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuery(msg: QueryMessage) {
|
||||
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId } = msg;
|
||||
|
||||
if (sessions.has(sessionId)) {
|
||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-p',
|
||||
'--output-format', 'stream-json',
|
||||
'--verbose',
|
||||
];
|
||||
|
||||
if (maxTurns) {
|
||||
args.push('--max-turns', String(maxTurns));
|
||||
}
|
||||
|
||||
if (maxBudgetUsd) {
|
||||
args.push('--max-budget-usd', String(maxBudgetUsd));
|
||||
}
|
||||
|
||||
if (resumeSessionId) {
|
||||
args.push('--resume', resumeSessionId);
|
||||
}
|
||||
|
||||
args.push(prompt);
|
||||
|
||||
log(`Starting agent session ${sessionId}: claude ${args.join(' ')}`);
|
||||
|
||||
const child = spawn('claude', args, {
|
||||
cwd: cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
// Unset CLAUDECODE to avoid nesting detection
|
||||
CLAUDECODE: undefined,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
sessions.set(sessionId, child);
|
||||
|
||||
send({ type: 'agent_started', sessionId });
|
||||
|
||||
// Parse NDJSON from claude's stdout
|
||||
const childRl = createInterface({ input: child.stdout! });
|
||||
childRl.on('line', (line: string) => {
|
||||
try {
|
||||
const sdkMsg = JSON.parse(line);
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: sdkMsg,
|
||||
});
|
||||
} catch {
|
||||
// Non-JSON output from claude (shouldn't happen with stream-json)
|
||||
log(`Non-JSON from claude stdout: ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Capture stderr for debugging
|
||||
const stderrRl = createInterface({ input: child.stderr! });
|
||||
stderrRl.on('line', (line: string) => {
|
||||
log(`[claude:${sessionId}] ${line}`);
|
||||
send({
|
||||
type: 'agent_log',
|
||||
sessionId,
|
||||
message: line,
|
||||
});
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
log(`Claude process error for ${sessionId}: ${err.message}`);
|
||||
sessions.delete(sessionId);
|
||||
send({
|
||||
type: 'agent_error',
|
||||
sessionId,
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
child.on('exit', (code: number | null, signal: string | null) => {
|
||||
log(`Claude process exited for ${sessionId}: code=${code} signal=${signal}`);
|
||||
sessions.delete(sessionId);
|
||||
send({
|
||||
type: 'agent_stopped',
|
||||
sessionId,
|
||||
exitCode: code,
|
||||
signal,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleStop(msg: StopMessage) {
|
||||
const { sessionId } = msg;
|
||||
const child = sessions.get(sessionId);
|
||||
if (!child) {
|
||||
send({ type: 'error', sessionId, message: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Stopping agent session ${sessionId}`);
|
||||
child.kill('SIGTERM');
|
||||
|
||||
// Force kill after 5s if still running
|
||||
setTimeout(() => {
|
||||
if (sessions.has(sessionId)) {
|
||||
log(`Force killing agent session ${sessionId}`);
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
log('Sidecar started');
|
||||
send({ type: 'ready' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue