feat(v2): migrate sidecar from raw CLI spawning to @anthropic-ai/claude-agent-sdk

Claude CLI v2.1.69 hangs silently when spawned via child_process.spawn()
with piped stdio (known bug github.com/anthropics/claude-code/issues/6775).

Replace raw CLI spawning in both sidecar runners with the SDK's query()
function, which handles subprocess management internally. SDK message
format matches CLI stream-json, so the sdk-messages.ts adapter is
unchanged.

- agent-runner.ts: use SDK query() with AbortController for stop
- agent-runner-deno.ts: use npm:@anthropic-ai/claude-agent-sdk import
- sidecar.rs: add --allow-write and --allow-net Deno permissions
- package.json: add @anthropic-ai/claude-agent-sdk ^0.2.70, build:sidecar script
This commit is contained in:
Hibryda 2026-03-06 22:57:36 +01:00
parent fdd1884015
commit 323703caba
5 changed files with 457 additions and 151 deletions

View file

@ -1,14 +1,15 @@
// Agent Runner — Deno sidecar entry point (experimental)
// Agent Runner — Deno sidecar entry point
// Drop-in replacement for agent-runner.ts using Deno APIs
// Build: deno compile --allow-run --allow-env --allow-read agent-runner-deno.ts -o dist/agent-runner
// Run: deno run --allow-run --allow-env --allow-read agent-runner-deno.ts
// Uses @anthropic-ai/claude-agent-sdk via npm: specifier
// Run: deno run --allow-run --allow-env --allow-read --allow-write --allow-net agent-runner-deno.ts
import { TextLineStream } from "https://deno.land/std@0.224.0/streams/text_line_stream.ts";
import { query } from "npm:@anthropic-ai/claude-agent-sdk";
const encoder = new TextEncoder();
// Active agent sessions keyed by session ID
const sessions = new Map<string, Deno.ChildProcess>();
// Active sessions with abort controllers
const sessions = new Map<string, AbortController>();
function send(msg: Record<string, unknown>) {
Deno.stdout.writeSync(encoder.encode(JSON.stringify(msg) + "\n"));
@ -49,7 +50,7 @@ function handleMessage(msg: Record<string, unknown>) {
}
}
function handleQuery(msg: QueryMessage) {
async function handleQuery(msg: QueryMessage) {
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId } = msg;
if (sessions.has(sessionId)) {
@ -57,93 +58,89 @@ function handleQuery(msg: QueryMessage) {
return;
}
const args = ["-p", "--output-format", "stream-json", "--verbose"];
log(`Starting agent session ${sessionId} via SDK`);
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);
const controller = new AbortController();
log(`Starting agent session ${sessionId}: claude ${args.join(" ")}`);
// Strip all CLAUDE* env vars to prevent nesting detection by claude CLI
const env: Record<string, string> = {};
// Strip CLAUDE* env vars to prevent nesting detection
const cleanEnv: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(Deno.env.toObject())) {
if (!key.startsWith("CLAUDE")) {
env[key] = value;
cleanEnv[key] = value;
}
}
const command = new Deno.Command("claude", {
args,
cwd: cwd || Deno.cwd(),
env,
stdin: "piped",
stdout: "piped",
stderr: "piped",
});
try {
const q = query({
prompt,
options: {
abortController: controller,
cwd: cwd || Deno.cwd(),
env: cleanEnv,
maxTurns: maxTurns ?? undefined,
maxBudgetUsd: maxBudgetUsd ?? undefined,
resume: resumeSessionId ?? undefined,
allowedTools: [
"Bash", "Read", "Write", "Edit", "Glob", "Grep",
"WebSearch", "WebFetch", "TodoWrite", "NotebookEdit",
],
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
},
});
const child = command.spawn();
sessions.set(sessionId, child);
send({ type: "agent_started", sessionId });
sessions.set(sessionId, controller);
send({ type: "agent_started", sessionId });
// Parse NDJSON from claude's stdout
(async () => {
const lines = child.stdout
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TextLineStream());
for await (const line of lines) {
try {
const sdkMsg = JSON.parse(line);
send({ type: "agent_event", sessionId, event: sdkMsg });
} catch {
log(`Non-JSON from claude stdout: ${line}`);
}
for await (const message of q) {
const sdkMsg = message as Record<string, unknown>;
send({
type: "agent_event",
sessionId,
event: sdkMsg,
});
}
})();
// Capture stderr
(async () => {
const lines = child.stderr
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TextLineStream());
for await (const line of lines) {
log(`[claude:${sessionId}] ${line}`);
send({ type: "agent_log", sessionId, message: line });
}
})();
// Wait for exit
child.status.then((status) => {
log(`Claude process exited for ${sessionId}: code=${status.code} signal=${status.signal}`);
sessions.delete(sessionId);
send({
type: "agent_stopped",
sessionId,
exitCode: status.code,
signal: status.signal,
exitCode: 0,
signal: null,
});
});
} catch (err: unknown) {
sessions.delete(sessionId);
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("aborted") || errMsg.includes("AbortError")) {
log(`Agent session ${sessionId} aborted`);
send({
type: "agent_stopped",
sessionId,
exitCode: null,
signal: "SIGTERM",
});
} else {
log(`Agent session ${sessionId} error: ${errMsg}`);
send({
type: "agent_error",
sessionId,
message: errMsg,
});
}
}
}
function handleStop(msg: StopMessage) {
const { sessionId } = msg;
const child = sessions.get(sessionId);
if (!child) {
const controller = sessions.get(sessionId);
if (!controller) {
send({ type: "error", sessionId, message: "Session not found" });
return;
}
log(`Stopping agent session ${sessionId}`);
child.kill("SIGTERM");
// Force kill after 5s
setTimeout(() => {
if (sessions.has(sessionId)) {
log(`Force killing agent session ${sessionId}`);
child.kill("SIGKILL");
}
}, 5000);
controller.abort();
}
// Main: read NDJSON from stdin

View file

@ -1,15 +1,15 @@
// Agent Runner — Node.js sidecar entry point
// Spawned by Rust backend, communicates via stdio NDJSON
// Manages claude CLI subprocess with --output-format stream-json
// Uses @anthropic-ai/claude-agent-sdk for proper Claude session management
import { stdin, stdout, stderr } from 'process';
import { createInterface } from 'readline';
import { spawn, type ChildProcess } from 'child_process';
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
const rl = createInterface({ input: stdin });
// Active agent sessions keyed by session ID
const sessions = new Map<string, ChildProcess>();
const sessions = new Map<string, { query: Query; controller: AbortController }>();
function send(msg: Record<string, unknown>) {
stdout.write(JSON.stringify(msg) + '\n');
@ -59,7 +59,7 @@ function handleMessage(msg: Record<string, unknown>) {
}
}
function handleQuery(msg: QueryMessage) {
async function handleQuery(msg: QueryMessage) {
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId } = msg;
if (sessions.has(sessionId)) {
@ -67,114 +67,91 @@ function handleQuery(msg: QueryMessage) {
return;
}
const args = [
'-p',
'--output-format', 'stream-json',
'--verbose',
];
log(`Starting agent session ${sessionId} via SDK`);
if (maxTurns) {
args.push('--max-turns', String(maxTurns));
}
const controller = new AbortController();
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(' ')}`);
// Strip all CLAUDE* env vars to prevent nesting detection by claude CLI.
// When BTerminal is launched from a Claude Code terminal, these leak in.
const cleanEnv: Record<string, string> = {};
// Strip CLAUDE* env vars to prevent nesting detection by the spawned CLI
const cleanEnv: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(process.env)) {
if (!key.startsWith('CLAUDE') && value !== undefined) {
if (!key.startsWith('CLAUDE')) {
cleanEnv[key] = value;
}
}
const child = spawn('claude', args, {
cwd: cwd || process.cwd(),
env: cleanEnv,
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const q = query({
prompt,
options: {
abortController: controller,
cwd: cwd || process.cwd(),
env: cleanEnv,
maxTurns: maxTurns ?? undefined,
maxBudgetUsd: maxBudgetUsd ?? undefined,
resume: resumeSessionId ?? undefined,
allowedTools: [
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit',
],
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
},
});
sessions.set(sessionId, child);
sessions.set(sessionId, { query: q, controller });
send({ type: 'agent_started', sessionId });
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);
for await (const message of q) {
// Forward SDK messages as-is — they use the same format as CLI stream-json
const sdkMsg = message as Record<string, unknown>;
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}`);
// Session completed normally
sessions.delete(sessionId);
send({
type: 'agent_stopped',
sessionId,
exitCode: code,
signal,
exitCode: 0,
signal: null,
});
});
} catch (err: unknown) {
sessions.delete(sessionId);
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes('aborted') || errMsg.includes('AbortError')) {
log(`Agent session ${sessionId} aborted`);
send({
type: 'agent_stopped',
sessionId,
exitCode: null,
signal: 'SIGTERM',
});
} else {
log(`Agent session ${sessionId} error: ${errMsg}`);
send({
type: 'agent_error',
sessionId,
message: errMsg,
});
}
}
}
function handleStop(msg: StopMessage) {
const { sessionId } = msg;
const child = sessions.get(sessionId);
if (!child) {
const session = sessions.get(sessionId);
if (!session) {
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);
session.controller.abort();
}
log('Sidecar started');