import { existsSync } from "node:fs"; import { spawn, spawnSync } from "child_process"; import { SettingsManager } from "../core/settings-manager.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) */ export function getShellConfig(): { shell: string; args: string[] } { if (cachedShellConfig) { return cachedShellConfig; } const settings = SettingsManager.create(); 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}\nPlease 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; } /** * Sanitize binary output for display/storage. * Removes characters that crash string-width or cause display issues: * - Control characters (except tab, newline, carriage return) * - Lone surrogates * - Unicode Format characters (crash string-width due to a bug) */ export function sanitizeBinaryOutput(str: string): string { // Fast path: use regex to remove problematic characters // - \p{Format}: Unicode format chars like \u0601 that crash string-width // - \p{Surrogate}: Lone surrogates from invalid UTF-8 // - Control chars except \t \n \r return str.replace(/[\p{Format}\p{Surrogate}]/gu, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); } /** * Kill a process and all its children (cross-platform) */ export 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 { // Ignore errors if taskkill fails } } else { // Use SIGKILL on Unix/Linux/Mac try { process.kill(-pid, "SIGKILL"); } catch { // Fallback to killing just the child if process group kill fails try { process.kill(pid, "SIGKILL"); } catch { // Process already dead } } } }