import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { spawn } from "child_process"; import { existsSync } from "fs"; /** * Find Git Bash installation on Windows * Searches common installation paths */ function findGitBash(): string | null { if (process.platform !== "win32") { return null; } const paths = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"]; // Also check ProgramFiles environment variables const programFiles = process.env.ProgramFiles; if (programFiles) { paths.push(`${programFiles}\\Git\\bin\\bash.exe`); } const programFilesX86 = process.env["ProgramFiles(x86)"]; if (programFilesX86) { paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`); } for (const path of paths) { if (existsSync(path)) { return path; } } return null; } /** * Get shell configuration based on platform */ function getShellConfig(): { shell: string; args: string[] } { if (process.platform === "win32") { const gitBash = findGitBash(); if (!gitBash) { const paths = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"]; const pathList = paths.map((p) => ` - ${p}`).join("\n"); throw new Error( `Git Bash not found in standard installation locations:\n${pathList}\n\nPlease install Git for Windows from https://git-scm.com/download/win using the default installation path.`, ); } return { shell: gitBash, args: ["-c"] }; } return { shell: "sh", args: ["-c"] }; } /** * Kill a process and all its children */ function killProcessTree(pid: number): void { if (process.platform === "win32") { // Use taskkill on Windows to kill process tree try { spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, }); } catch (e) { // Ignore errors if taskkill fails } } else { // Use SIGKILL on Unix/Linux/Mac try { process.kill(-pid, "SIGKILL"); } catch (e) { // Fallback to killing just the child if process group kill fails try { process.kill(pid, "SIGKILL"); } catch (e2) { // Process already dead } } } } const bashSchema = Type.Object({ command: Type.String({ description: "Bash command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), }); export const bashTool: AgentTool = { name: "bash", label: "bash", description: "Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.", parameters: bashSchema, execute: async ( _toolCallId: string, { command, timeout }: { command: string; timeout?: number }, signal?: AbortSignal, ) => { return new Promise((resolve, _reject) => { const { shell, args } = getShellConfig(); const child = spawn(shell, [...args, command], { detached: true, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; let timedOut = false; // Set timeout if provided let timeoutHandle: NodeJS.Timeout | undefined; if (timeout !== undefined && timeout > 0) { timeoutHandle = setTimeout(() => { timedOut = true; onAbort(); }, timeout * 1000); } // Collect stdout if (child.stdout) { child.stdout.on("data", (data) => { stdout += data.toString(); // Limit buffer size if (stdout.length > 10 * 1024 * 1024) { stdout = stdout.slice(0, 10 * 1024 * 1024); } }); } // Collect stderr if (child.stderr) { child.stderr.on("data", (data) => { stderr += data.toString(); // Limit buffer size if (stderr.length > 10 * 1024 * 1024) { stderr = stderr.slice(0, 10 * 1024 * 1024); } }); } // Handle process exit child.on("close", (code) => { if (timeoutHandle) { clearTimeout(timeoutHandle); } if (signal) { signal.removeEventListener("abort", onAbort); } if (signal?.aborted) { let output = ""; if (stdout) output += stdout; if (stderr) { if (output) output += "\n"; output += stderr; } if (output) output += "\n\n"; output += "Command aborted"; resolve({ content: [{ type: "text", text: `Command failed\n\n${output}` }], details: undefined }); return; } if (timedOut) { let output = ""; if (stdout) output += stdout; if (stderr) { if (output) output += "\n"; output += stderr; } if (output) output += "\n\n"; output += `Command timed out after ${timeout} seconds`; resolve({ content: [{ type: "text", text: `Command failed\n\n${output}` }], details: undefined }); return; } let output = ""; if (stdout) output += stdout; if (stderr) { if (output) output += "\n"; output += stderr; } if (code !== 0 && code !== null) { if (output) output += "\n\n"; resolve({ content: [{ type: "text", text: `Command failed\n\n${output}Command exited with code ${code}` }], details: undefined, }); } else { resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined }); } }); // Handle abort signal - kill entire process tree const onAbort = () => { if (child.pid) { killProcessTree(child.pid); } }; if (signal) { if (signal.aborted) { onAbort(); } else { signal.addEventListener("abort", onAbort, { once: true }); } } }); }, };