import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { z } from "zod"; import type { ToolDefinition, ToolExecuteContext } from "../types/index.js"; const DEFAULT_TIMEOUT = 2 * 60 * 1000; // 2 minutes export interface BackgroundShellJob { command: string; stdout: string; stderr: string; /** Resolves when the process exits */ completion: Promise<{ stdout: string; stderr: string; exitCode: number; error?: string }>; } /** Shared store for shell commands that were backgrounded due to user interrupt */ export class BackgroundShellStore { private jobs = new Map(); register(job: BackgroundShellJob): string { const id = `run_shell_${randomUUID()}`; this.jobs.set(id, job); // Auto-cleanup after completion + 10 minutes job.completion.finally(() => { setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000); }); return id; } async getResult( id: string, ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> { const job = this.jobs.get(id); if (!job) { return { status: "error", error: `No background shell job found with id '${id}'` }; } const result = await job.completion; return { status: "done", result: JSON.stringify(result) }; } has(id: string): boolean { return this.jobs.has(id); } } export function createRunShellTool( workingDirectory: string, shellStore?: BackgroundShellStore, ): ToolDefinition { return { name: "run_shell", description: "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. If the user interrupts while a command is running, the command continues in the background and you receive a job ID. Use the retrieve tool with that ID to get the result later.", parameters: z.object({ command: z.string().describe("The shell command to execute"), timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"), background: z .boolean() .optional() .describe( "If true, the command starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the result later.", ), }), execute: async ( args: Record, context?: ToolExecuteContext, ): Promise => { const command = args.command as string; const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT; const background = (args.background as boolean | undefined) ?? false; const [shell, shellArgs] = getShell(); const child = spawn(shell, [...shellArgs, command], { cwd: workingDirectory, env: process.env, timeout, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; const completionPromise = new Promise<{ stdout: string; stderr: string; exitCode: number; error?: string; }>((resolve) => { child.stdout?.on("data", (data: Buffer) => { const chunk = data.toString(); stdout += chunk; context?.onOutput?.(chunk, "stdout"); }); child.stderr?.on("data", (data: Buffer) => { const chunk = data.toString(); stderr += chunk; context?.onOutput?.(chunk, "stderr"); }); child.on("close", (exitCode) => { resolve({ stdout, stderr, exitCode: exitCode ?? 1 }); }); child.on("error", (err) => { resolve({ stdout, stderr, exitCode: 1, error: err.message }); }); }); // If background mode requested, register immediately and return job ID if (background && shellStore) { const jobId = shellStore.register({ command, stdout, stderr, completion: completionPromise, }); return [ `Command started in background.`, `job_id: ${jobId}`, `command: ${command}`, ``, `Use the retrieve tool with this job_id to get the result when ready.`, ].join("\n"); } const queueCallbacks = context?.queueCallbacks; if (queueCallbacks && shellStore) { const { promise: queuePromise, cancel: cancelQueueWait } = queueCallbacks.waitForQueuedMessage(); const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const); const raceResult = await Promise.race([completionPromise, queueSignal]); if (raceResult === "QUEUE_INTERRUPT") { // Background the still-running process const jobId = shellStore.register({ command, stdout, stderr, completion: completionPromise, }); const queuedMsgs = queueCallbacks.dequeueMessages(); const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n"); return [ `Command backgrounded — still running.`, `job_id: ${jobId}`, `command: ${command}`, `stdout so far: ${stdout.slice(-500) || "(none)"}`, `stderr so far: ${stderr.slice(-500) || "(none)"}`, ``, `Use the retrieve tool with this job_id to get the final result when ready.`, ``, `[USER INTERRUPT]`, `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`, ``, userMessages, ].join("\n"); } // Command finished before interrupt — clean up queue listener cancelQueueWait(); return JSON.stringify(raceResult); } const result = await completionPromise; return JSON.stringify(result); }, }; } function getShell(): [string, string[]] { return process.platform === "win32" ? ["powershell", ["-Command"]] : ["bash", ["-c"]]; }