Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174/** * bash — built-in tool to execute shell commands (ARCHITECTURE §4.1). * * Runs a command via `Bun.spawn` in the tool context's `cwd`, capturing * **combined** stdout + stderr (merged into a single stream). The exit code and * wall-clock duration are returned as structured `ToolResult` fields (NOT jammed into * the output text) so the client decides how to render them; the model still sees the * exit code as `[exit code: N]` via the `assembleHistory` seam (saga 26haas). * * **isError decision:** Non-zero exit codes are NOT treated as `isError`. * The model may need to see compiler errors, grep misses, etc. as normal * output. `isError: true` is set ONLY when the process fails to spawn or * times out — i.e. when we cannot produce meaningful output at all. * * Default timeout: 120 000 ms (2 minutes). * On timeout, the process is killed and an `isError` result is returned. * * No `onStart`/`onResult` render hooks: bash emits INFO (content + exitCode + * durationMs), not rendering. The TUI renders the command once (from `args`, in the call * line) and the result (output + exit/duration) from the structured data (saga 26haas). */import { Type, type Static } from '@sinclair/typebox';import { Value } from '@sinclair/typebox/value';import { resolve } from 'node:path';import type { Tool, ToolContext, ToolResult } from '@/contracts/tool';
/** Default timeout in milliseconds. */const DEFAULT_TIMEOUT_MS = 120_000;
// ── Input schema ──────────────────────────────────────────────────────────────
export const BashInputSchema = Type.Object({ command: Type.String({ description: 'Shell command to execute' }), timeoutMs: Type.Optional( Type.Integer({ description: 'Timeout in milliseconds', minimum: 1 }), ),});
export type BashInput = Static<typeof BashInputSchema>;
// ── Tool definition ───────────────────────────────────────────────────────────
export const bashTool: Tool<typeof BashInputSchema> = { name: 'bash', description: 'Execute a shell command. Captures combined stdout+stderr; the exit code and ' + 'duration are returned as structured fields (the model sees `[exit code: N]`). ' + `Non-zero exit codes are NOT errors — only spawn failures and timeouts produce isError. ` + `Default timeout: ${DEFAULT_TIMEOUT_MS} ms.`, inputSchema: BashInputSchema, isReadOnly: false,
async execute(input: BashInput, ctx: ToolContext): Promise<ToolResult> { // Validate input against schema if (!Value.Check(BashInputSchema, input)) { return { content: { kind: 'error', message: `Invalid input for bash tool: ${JSON.stringify(input)}` }, isError: true, }; }
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const cwd = resolve(ctx.cwd); const abortedResult = (): ToolResult => ({ content: { kind: 'error', message: `Command aborted: ${input.command}` }, isError: true, });
// Do not start side effects for a turn which was stopped before this call began. if (ctx.signal?.aborted) return abortedResult();
try { const start = Date.now(); // A separate process group lets stop/timeout terminate the whole command tree rather // than only `sh` while a compiler, test runner, or nested shell continues in the // background. Bun's `detached` maps to setsid(2) on POSIX. const proc = Bun.spawn(['sh', '-c', input.command], { cwd, stdout: 'pipe', stderr: 'pipe', detached: process.platform !== 'win32', });
let timeoutHandle: ReturnType<typeof setTimeout> | undefined; let abortListener: (() => void) | undefined; const killTree = (): void => { if (proc.pid > 0) { if (process.platform === 'win32') { try { Bun.spawnSync(['taskkill', '/pid', String(proc.pid), '/t', '/f'], { stdout: 'ignore', stderr: 'ignore', }); return; } catch { // Fall through to the direct child handle when taskkill is unavailable. } } else { try { process.kill(-proc.pid, 'SIGKILL'); return; } catch { // The group may already have exited; fall back to Bun's direct child handle. } } } try { proc.kill('SIGKILL'); } catch { /* process already exited */ } };
const timeoutPromise = new Promise<'timeout'>((resolveTimeout) => { timeoutHandle = setTimeout(() => resolveTimeout('timeout'), timeoutMs); }); const abortPromise = new Promise<'aborted'>((resolveAbort) => { if (!ctx.signal) return; abortListener = () => resolveAbort('aborted'); ctx.signal.addEventListener('abort', abortListener, { once: true }); });
// Start draining both pipes immediately so verbose commands cannot block on a full // pipe while we wait for their exit. const stdoutPromise = new Response(proc.stdout).text(); const stderrPromise = new Response(proc.stderr).text(); const raceResult = await Promise.race([proc.exited, timeoutPromise, abortPromise]);
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); if (abortListener !== undefined) ctx.signal?.removeEventListener('abort', abortListener);
if (raceResult === 'aborted') { killTree(); // Observe process/pipe settlement without making stop wait for output consumption. void Promise.allSettled([proc.exited, stdoutPromise, stderrPromise]); return abortedResult(); } if (raceResult === 'timeout') { killTree(); void Promise.allSettled([proc.exited, stdoutPromise, stderrPromise]); return { content: { kind: 'error', message: `Command timed out after ${timeoutMs} ms: ${input.command}`, }, isError: true, }; }
const exitCode = raceResult; const durationMs = Date.now() - start; const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
const combined: string[] = []; if (stdout) combined.push(stdout); if (stderr) combined.push(stderr);
let output = combined.join('\n'); if (output && !output.endsWith('\n')) output += '\n'; output = output.replace(/\n$/, '');
return { content: { kind: 'text', text: output }, isError: false, exitCode, durationMs, }; } catch (err) { if (ctx.signal?.aborted) return abortedResult(); const message = err instanceof Error ? err.message : String(err); return { content: { kind: 'error', message: `Failed to spawn command: ${message}` }, isError: true, }; } },};