/** * Bash tool — runs a shell command in the workspace (+ background jobs). * * Claude-SDK-parity surface (audit C-9): 120s default / 10min max timeout, * SIGTERM-with-grace then SIGKILL on the whole process group (detached spawn — * a SIGKILLed direct child can leave orphans holding the stdio pipes, and * 'close' then never fires), settle-on-exit drain guard so the tool promise * can never hang a turn, and `run_in_background` with the companion * BashOutput / KillShell tools for long builds and dev servers. */ import { spawn, type ChildProcess } from 'child_process'; import type { PiTool } from './types.js'; const DEFAULT_TIMEOUT_MS = 120_000; const HARD_TIMEOUT_MS = 10 * 60_000; const OUTPUT_CAP_BYTES = 200 * 1024; // 200 KB; matches Claude SDK's behavior const TERM_GRACE_MS = 3_000; /** Safety ceiling for background jobs (claude shells die with the subprocess; * pi jobs die with the session abort signal — this is the belt-and-braces). */ const BACKGROUND_MAX_MS = 30 * 60_000; function killTreeWithGrace(child: ChildProcess): void { const signalGroup = (sig: NodeJS.Signals) => { try { if (child.pid) process.kill(-child.pid, sig); else child.kill(sig); } catch { try { child.kill(sig); } catch {} } }; signalGroup('SIGTERM'); const escalate = setTimeout(() => signalGroup('SIGKILL'), TERM_GRACE_MS); escalate.unref?.(); child.once('exit', () => clearTimeout(escalate)); } /* ── Background job table ── */ interface BashJob { id: string; command: string; child: ChildProcess; out: string; truncated: boolean; /** How much of `out` the model has already seen via BashOutput. */ readOffset: number; exited: boolean; /** stdio fully drained ('close' fired) — late pipe data can arrive after 'exit'. */ closed: boolean; exitCode: number | null; exitSignal: NodeJS.Signals | null; killed: boolean; startedAt: number; } /** Finished jobs linger as readable tombstones for a few minutes (repeat polls * keep working), then a reap timer frees them — bounds the table without the * delete-on-first-read trap that lost late pipe output (review PI-D-3). */ const JOB_TOMBSTONE_TTL_MS = 5 * 60_000; const jobs = new Map(); let jobCounter = 0; function appendCapped(job: BashJob, chunk: Buffer): void { if (job.truncated) return; const remaining = OUTPUT_CAP_BYTES - Buffer.byteLength(job.out, 'utf-8'); if (remaining <= 0) { job.truncated = true; return; } const text = chunk.toString('utf-8'); if (Buffer.byteLength(text, 'utf-8') > remaining) { job.out += text.slice(0, remaining); job.truncated = true; } else { job.out += text; } } function jobStatusLine(job: BashJob): string { if (!job.exited) return `[running for ${Math.round((Date.now() - job.startedAt) / 1000)}s]`; if (job.killed) return '[killed]'; return `[exited with code ${job.exitCode}${job.exitSignal ? ` (signal ${job.exitSignal})` : ''}]`; } /* ── Tools ── */ export const bashTool: PiTool = { name: 'Bash', description: 'Run a shell command in the workspace and return its combined stdout+stderr. ' + 'For long-running commands (builds, installs, dev servers) set run_in_background: true ' + 'and poll with BashOutput; stop them with KillShell.', inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'The shell command to execute.' }, description: { type: 'string', description: 'A short description (5–10 words) of what the command does.' }, timeout: { type: 'integer', description: 'Timeout in milliseconds (default 120 000, max 600 000). Ignored for background jobs.' }, run_in_background: { type: 'boolean', description: 'Start the command as a background job and return immediately with a job id.' }, }, required: ['command'], }, async run(input, ctx) { const command = typeof input?.command === 'string' ? input.command : ''; if (!command.trim()) return { output: 'command is required.', isError: true }; if (input?.run_in_background) { const id = `bash_${++jobCounter}`; let child: ChildProcess; try { child = spawn('bash', ['-lc', command], { cwd: ctx.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true, }); } catch (err: any) { return { output: `Failed to spawn command: ${err?.message || err}`, isError: true }; } const job: BashJob = { id, command, child, out: '', truncated: false, readOffset: 0, exited: false, closed: false, exitCode: null, exitSignal: null, killed: false, startedAt: Date.now(), }; jobs.set(id, job); child.stdout?.on('data', (c: Buffer) => appendCapped(job, c)); child.stderr?.on('data', (c: Buffer) => appendCapped(job, c)); const onAbort = () => { if (!job.exited) { job.killed = true; killTreeWithGrace(child); } }; const reap = () => { const t = setTimeout(() => jobs.delete(id), JOB_TOMBSTONE_TTL_MS); t.unref?.(); }; child.on('error', () => { job.exited = true; job.closed = true; job.exitCode = -1; ctx.signal?.removeEventListener('abort', onAbort); reap(); }); child.on('exit', (code, signal) => { job.exited = true; job.exitCode = code; job.exitSignal = signal; // Listener cleanup on natural exit — without it every finished job // pins its ChildProcess + output buffer on the session's AbortSignal // for the conversation's whole life (review D-TOOLS-5). ctx.signal?.removeEventListener('abort', onAbort); reap(); }); child.on('close', () => { job.closed = true; }); // Jobs die with the session (claude parity: background shells die with // the SDK subprocess) and have a hard safety ceiling. const ceiling = setTimeout(() => { if (!job.exited) { job.killed = true; killTreeWithGrace(child); } }, BACKGROUND_MAX_MS); ceiling.unref?.(); ctx.signal?.addEventListener('abort', onAbort, { once: true }); return { output: `Started background job ${id}. Poll it with BashOutput {"bash_id": "${id}"}; stop it with KillShell {"shell_id": "${id}"}.`, }; } const requestedTimeout = Number(input?.timeout) || DEFAULT_TIMEOUT_MS; const timeout = Math.min(HARD_TIMEOUT_MS, Math.max(1000, requestedTimeout)); return await new Promise((resolve) => { let out = ''; let truncated = false; let timedOut = false; let settled = false; // detached:true gives the child its own process group so kills reach // grandchildren too — orphans holding the stdio pipes would otherwise // keep 'close' from ever firing and hang the tool promise. const child = spawn('bash', ['-lc', command], { cwd: ctx.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true, }); const append = (chunk: Buffer) => { if (truncated) return; const remaining = OUTPUT_CAP_BYTES - Buffer.byteLength(out, 'utf-8'); if (remaining <= 0) { truncated = true; return; } const text = chunk.toString('utf-8'); if (Buffer.byteLength(text, 'utf-8') > remaining) { out += text.slice(0, remaining); truncated = true; } else { out += text; } }; child.stdout?.on('data', append); child.stderr?.on('data', append); const timer = setTimeout(() => { timedOut = true; killTreeWithGrace(child); }, timeout); const onAbort = () => { killTreeWithGrace(child); }; ctx.signal?.addEventListener('abort', onAbort); const finish = (code: number | null, signal: NodeJS.Signals | null) => { if (settled) return; settled = true; clearTimeout(timer); ctx.signal?.removeEventListener('abort', onAbort); const tail = truncated ? `\n\n[Output truncated at ${OUTPUT_CAP_BYTES} bytes]` : ''; if (timedOut) { resolve({ output: `Command timed out after ${timeout}ms.\n\n${out}${tail}`, isError: true }); return; } if (ctx.signal?.aborted) { resolve({ output: 'Command aborted (session ended).', isError: true }); return; } if (code === 0) { resolve({ output: (out || '(no output)') + tail }); } else { resolve({ output: `Command exited with code ${code}${signal ? ` (signal ${signal})` : ''}.\n\n${out}${tail}`, isError: true, }); } }; child.on('error', (err) => { if (settled) return; settled = true; clearTimeout(timer); ctx.signal?.removeEventListener('abort', onAbort); resolve({ output: `Failed to spawn command: ${err.message}`, isError: true }); }); // 'close' is the normal settle point (all output drained). But orphaned // grandchildren can inherit the stdio pipes and keep them open after the // direct child died — settle from 'exit' after a short drain grace so the // tool promise can never hang the turn on a pipe that won't close. child.on('close', (code, signal) => finish(code, signal)); child.on('exit', (code, signal) => { setTimeout(() => finish(code, signal), 1500); }); }); }, }; export const bashOutputTool: PiTool = { name: 'BashOutput', description: 'Read NEW output from a background Bash job since the last read, plus its status.', inputSchema: { type: 'object', properties: { bash_id: { type: 'string', description: 'The job id returned by Bash with run_in_background.' }, }, required: ['bash_id'], }, async run(input) { const id = typeof input?.bash_id === 'string' ? input.bash_id : (typeof input?.shell_id === 'string' ? input.shell_id : ''); const job = jobs.get(id); if (!job) { const known = Array.from(jobs.keys()).join(', ') || 'none'; return { output: `No background job "${id}". Known jobs: ${known}.`, isError: true }; } const fresh = job.out.slice(job.readOffset); job.readOffset = job.out.length; const tail = job.truncated ? '\n[output capped at 200 KB]' : ''; // Deliberately NO delete here: 'exit' can fire before the stdio pipes // drain, so an eager delete lost the output tail and made a follow-up // poll error out (review PI-D-3). The reap timer frees finished jobs. return { output: `${jobStatusLine(job)}\n${fresh || '(no new output)'}${tail}`, isError: job.exited && !job.killed && job.exitCode !== 0, }; }, }; export const killShellTool: PiTool = { name: 'KillShell', description: 'Stop a background Bash job started with run_in_background.', inputSchema: { type: 'object', properties: { shell_id: { type: 'string', description: 'The job id to stop.' }, }, required: ['shell_id'], }, async run(input) { const id = typeof input?.shell_id === 'string' ? input.shell_id : (typeof input?.bash_id === 'string' ? input.bash_id : ''); const job = jobs.get(id); if (!job) { const known = Array.from(jobs.keys()).join(', ') || 'none'; return { output: `No background job "${id}". Known jobs: ${known}.`, isError: true }; } if (job.exited) { jobs.delete(id); return { output: `Job ${id} had already finished ${jobStatusLine(job)}.` }; } job.killed = true; killTreeWithGrace(job.child); return { output: `Killing job ${id} (SIGTERM, then SIGKILL after ${TERM_GRACE_MS / 1000}s).` }; }, };