import { randomBytes } from "node:crypto"; import { createWriteStream, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { spawn, spawnSync } from "child_process"; import { SettingsManager } from "../settings-manager.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js"; let cachedShellConfig: { shell: string; args: string[] } | null = null; /** * Find bash executable on PATH (Windows) */ function findBashOnPath(): string | null { try { const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch && existsSync(firstMatch)) { return firstMatch; } } } catch { // Ignore errors } return null; } /** * Get shell configuration based on platform. * Resolution order: * 1. User-specified shellPath in settings.json * 2. On Windows: Git Bash in known locations * 3. Fallback: bash on PATH (Windows) or sh (Unix) */ function getShellConfig(): { shell: string; args: string[] } { if (cachedShellConfig) { return cachedShellConfig; } const settings = new SettingsManager(); const customShellPath = settings.getShellPath(); // 1. Check user-specified shell path if (customShellPath) { if (existsSync(customShellPath)) { cachedShellConfig = { shell: customShellPath, args: ["-c"] }; return cachedShellConfig; } throw new Error( `Custom shell path not found: ${customShellPath}\n` + `Please update shellPath in ~/.pi/agent/settings.json`, ); } if (process.platform === "win32") { // 2. Try Git Bash in known locations const paths: string[] = []; 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)) { cachedShellConfig = { shell: path, args: ["-c"] }; return cachedShellConfig; } } // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) const bashOnPath = findBashOnPath(); if (bashOnPath) { cachedShellConfig = { shell: bashOnPath, args: ["-c"] }; return cachedShellConfig; } throw new Error( `No bash shell found. Options:\n` + ` 1. Install Git for Windows: https://git-scm.com/download/win\n` + ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` + ` 3. Set shellPath in ~/.pi/agent/settings.json\n\n` + `Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`, ); } cachedShellConfig = { shell: "sh", args: ["-c"] }; return cachedShellConfig; } /** * 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 } } } } /** * Generate a unique temp file path for bash output */ function getTempFilePath(): string { const id = randomBytes(8).toString("hex"); return join(tmpdir(), `pi-bash-${id}.log`); } 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)" })), }); interface BashToolDetails { truncation?: TruncationResult; fullOutputPath?: string; } export const bashTool: AgentTool = { name: "bash", label: "bash", description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. 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"], }); // We'll stream to a temp file if output gets large let tempFilePath: string | undefined; let tempFileStream: ReturnType | undefined; let totalBytes = 0; // Keep a rolling buffer of the last chunk for tail truncation const chunks: Buffer[] = []; let chunksBytes = 0; // Keep more than we need so we have enough for truncation const maxChunksBytes = DEFAULT_MAX_BYTES * 2; let timedOut = false; // Set timeout if provided let timeoutHandle: NodeJS.Timeout | undefined; if (timeout !== undefined && timeout > 0) { timeoutHandle = setTimeout(() => { timedOut = true; onAbort(); }, timeout * 1000); } const handleData = (data: Buffer) => { totalBytes += data.length; // Start writing to temp file once we exceed the threshold if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) { tempFilePath = getTempFilePath(); tempFileStream = createWriteStream(tempFilePath); // Write all buffered chunks to the file for (const chunk of chunks) { tempFileStream.write(chunk); } } // Write to temp file if we have one if (tempFileStream) { tempFileStream.write(data); } // Keep rolling buffer of recent data chunks.push(data); chunksBytes += data.length; // Trim old chunks if buffer is too large while (chunksBytes > maxChunksBytes && chunks.length > 1) { const removed = chunks.shift()!; chunksBytes -= removed.length; } }; // Collect stdout and stderr together if (child.stdout) { child.stdout.on("data", handleData); } if (child.stderr) { child.stderr.on("data", handleData); } // Handle process exit child.on("close", (code) => { if (timeoutHandle) { clearTimeout(timeoutHandle); } if (signal) { signal.removeEventListener("abort", onAbort); } // Close temp file stream if (tempFileStream) { tempFileStream.end(); } // Combine all buffered chunks const fullBuffer = Buffer.concat(chunks); const fullOutput = fullBuffer.toString("utf-8"); if (signal?.aborted) { let output = fullOutput; if (output) output += "\n\n"; output += "Command aborted"; reject(new Error(output)); return; } if (timedOut) { let output = fullOutput; if (output) output += "\n\n"; output += `Command timed out after ${timeout} seconds`; reject(new Error(output)); return; } // Apply tail truncation const truncation = truncateTail(fullOutput); let outputText = truncation.content || "(no output)"; // Build details with truncation info let details: BashToolDetails | undefined; if (truncation.truncated) { details = { truncation, fullOutputPath: tempFilePath, }; } if (code !== 0 && code !== null) { outputText += `\n\nCommand exited with code ${code}`; reject(new Error(outputText)); } else { resolve({ content: [{ type: "text", text: outputText }], details }); } }); // 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 }); } } }); }, };