From 7e6e77771307a0a26a827be9e3b211dc1f80db34 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 6 Mar 2026 15:10:01 +0100 Subject: [PATCH] feat(v2): add Deno sidecar proof-of-concept Experimental agent-runner-deno.ts as drop-in replacement for Node.js sidecar. Uses Deno.Command for claude CLI subprocess, TextLineStream for NDJSON parsing. Same stdio NDJSON protocol. Compiles to single binary via deno compile. Not yet integrated with Rust SidecarManager. --- v2/sidecar/agent-runner-deno.ts | 159 ++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 v2/sidecar/agent-runner-deno.ts diff --git a/v2/sidecar/agent-runner-deno.ts b/v2/sidecar/agent-runner-deno.ts new file mode 100644 index 0000000..0808e36 --- /dev/null +++ b/v2/sidecar/agent-runner-deno.ts @@ -0,0 +1,159 @@ +// Agent Runner — Deno sidecar entry point (experimental) +// 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 + +import { TextLineStream } from "https://deno.land/std@0.224.0/streams/text_line_stream.ts"; + +const encoder = new TextEncoder(); + +// Active agent sessions keyed by session ID +const sessions = new Map(); + +function send(msg: Record) { + Deno.stdout.writeSync(encoder.encode(JSON.stringify(msg) + "\n")); +} + +function log(message: string) { + Deno.stderr.writeSync(encoder.encode(`[sidecar] ${message}\n`)); +} + +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) { + switch (msg.type) { + case "ping": + send({ type: "pong" }); + break; + case "query": + 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 env = { ...Deno.env.toObject() }; + delete env.CLAUDECODE; + + const command = new Deno.Command("claude", { + args, + cwd: cwd || Deno.cwd(), + env, + stdin: "piped", + stdout: "piped", + stderr: "piped", + }); + + const child = command.spawn(); + sessions.set(sessionId, child); + 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}`); + } + } + })(); + + // 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, + }); + }); +} + +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 + setTimeout(() => { + if (sessions.has(sessionId)) { + log(`Force killing agent session ${sessionId}`); + child.kill("SIGKILL"); + } + }, 5000); +} + +// Main: read NDJSON from stdin +log("Sidecar started (Deno)"); +send({ type: "ready" }); + +const lines = Deno.stdin.readable + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()); + +for await (const line of lines) { + try { + const msg = JSON.parse(line); + handleMessage(msg); + } catch { + log(`Invalid JSON: ${line}`); + } +}