From 323703caba0179d3f05b22aad37d6e6f76f81fcb Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 6 Mar 2026 22:57:36 +0100 Subject: [PATCH] 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 --- v2/bterminal-core/src/sidecar.rs | 2 + v2/package-lock.json | 328 +++++++++++++++++++++++++++++++ v2/package.json | 4 +- v2/sidecar/agent-runner-deno.ts | 133 ++++++------- v2/sidecar/agent-runner.ts | 141 ++++++------- 5 files changed, 457 insertions(+), 151 deletions(-) diff --git a/v2/bterminal-core/src/sidecar.rs b/v2/bterminal-core/src/sidecar.rs index 415aad9..6716030 100644 --- a/v2/bterminal-core/src/sidecar.rs +++ b/v2/bterminal-core/src/sidecar.rs @@ -220,6 +220,8 @@ impl SidecarManager { "--allow-run".to_string(), "--allow-env".to_string(), "--allow-read".to_string(), + "--allow-write".to_string(), + "--allow-net".to_string(), deno_path.to_string_lossy().to_string(), ], }); diff --git a/v2/package-lock.json b/v2/package-lock.json index 326c8bc..88fcf06 100644 --- a/v2/package-lock.json +++ b/v2/package-lock.json @@ -8,6 +8,7 @@ "name": "bterminal-v2", "version": "0.1.0", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.70", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.0", "@xterm/addon-canvas": "^0.7.0", @@ -27,6 +28,29 @@ "vitest": "^4.0.18" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.70", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.70.tgz", + "integrity": "sha512-ABaB37jWt7dZXfIDHebHv99jX9GIyqc0aSjcz9nxq79eauOpa+64Cah5hx/yzhsWz7m5GEtjbMIZCClTfnRRhg==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.34.2", + "@img/sharp-darwin-x64": "^0.34.2", + "@img/sharp-linux-arm": "^0.34.2", + "@img/sharp-linux-arm64": "^0.34.2", + "@img/sharp-linux-x64": "^0.34.2", + "@img/sharp-linuxmusl-arm64": "^0.34.2", + "@img/sharp-linuxmusl-x64": "^0.34.2", + "@img/sharp-win32-arm64": "^0.34.2", + "@img/sharp-win32-x64": "^0.34.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -469,6 +493,310 @@ "node": ">=18" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/v2/package.json b/v2/package.json index 09d2b6c..b873f3d 100644 --- a/v2/package.json +++ b/v2/package.json @@ -11,7 +11,8 @@ "tauri": "cargo tauri", "tauri:dev": "cargo tauri dev", "tauri:build": "cargo tauri build", - "test": "vitest run" + "test": "vitest run", + "build:sidecar": "esbuild sidecar/agent-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/agent-runner.mjs --external:@anthropic-ai/claude-agent-sdk" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", @@ -24,6 +25,7 @@ "vitest": "^4.0.18" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.70", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.0", "@xterm/addon-canvas": "^0.7.0", diff --git a/v2/sidecar/agent-runner-deno.ts b/v2/sidecar/agent-runner-deno.ts index 6758d22..3a4b736 100644 --- a/v2/sidecar/agent-runner-deno.ts +++ b/v2/sidecar/agent-runner-deno.ts @@ -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(); +// Active sessions with abort controllers +const sessions = new Map(); function send(msg: Record) { Deno.stdout.writeSync(encoder.encode(JSON.stringify(msg) + "\n")); @@ -49,7 +50,7 @@ function handleMessage(msg: Record) { } } -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 = {}; + // Strip CLAUDE* env vars to prevent nesting detection + const cleanEnv: Record = {}; 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; + 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 diff --git a/v2/sidecar/agent-runner.ts b/v2/sidecar/agent-runner.ts index ce22c09..632b3c4 100644 --- a/v2/sidecar/agent-runner.ts +++ b/v2/sidecar/agent-runner.ts @@ -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(); +const sessions = new Map(); function send(msg: Record) { stdout.write(JSON.stringify(msg) + '\n'); @@ -59,7 +59,7 @@ function handleMessage(msg: Record) { } } -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 = {}; + // Strip CLAUDE* env vars to prevent nesting detection by the spawned CLI + const cleanEnv: Record = {}; 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; 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');