diff --git a/bun.lock b/bun.lock index aae6a79ac..08f955690 100644 --- a/bun.lock +++ b/bun.lock @@ -15,13 +15,12 @@ "@typescript/native-preview": "^7.0.0-dev.20260102.1", "bun-types": "^1.3.5", "concurrently": "^9.2.1", - "husky": "^9.1.7", "lint-staged": "^16.2.7", }, }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "1.341.0", + "version": "1.5.0", "dependencies": { "@oh-my-pi/pi-ai": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", @@ -33,7 +32,7 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "1.341.0", + "version": "1.5.0", "bin": { "pi-ai": "./src/cli.ts", }, @@ -57,7 +56,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "1.341.0", + "version": "1.5.0", "bin": { "pi": "src/cli.ts", }, @@ -88,7 +87,7 @@ }, "packages/mom": { "name": "@oh-my-pi/pi-mom", - "version": "1.341.0", + "version": "1.5.0", "bin": { "mom": "src/main.ts", }, @@ -112,7 +111,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "1.341.0", + "version": "1.5.0", "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", @@ -127,7 +126,7 @@ }, "packages/web-ui": { "name": "@oh-my-pi/pi-web-ui", - "version": "1.341.0", + "version": "1.5.0", "dependencies": { "@lmstudio/sdk": "^1.5.0", "@oh-my-pi/pi-agent-core": "workspace:*", @@ -744,8 +743,6 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index 6d37540fe..16e95c01f 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -20,6 +20,10 @@ import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate"; // ============================================================================ export interface BashExecutorOptions { + /** Working directory for command execution */ + cwd?: string; + /** Timeout in milliseconds */ + timeout?: number; /** Callback for streaming output chunks (already sanitized) */ onChunk?: (chunk: string) => void; /** AbortSignal for cancellation */ @@ -70,6 +74,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions return new Promise((resolve, reject) => { const child: Subprocess = Bun.spawn([shell, ...args, finalCommand], { + cwd: options?.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -85,16 +90,27 @@ export async function executeBash(command: string, options?: BashExecutorOptions let tempFilePath: string | undefined; let tempFileStream: WriteStream | undefined; let totalBytes = 0; + let timedOut = false; - // Handle abort signal + // Handle abort signal and timeout const abortHandler = () => { killProcessTree(child.pid); }; + // Set up timeout if specified + let timeoutHandle: Timer | undefined; + if (options?.timeout && options.timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + abortHandler(); + }, options.timeout); + } + if (options?.signal) { if (options.signal.aborted) { // Already aborted, don't even start child.kill(); + if (timeoutHandle) clearTimeout(timeoutHandle); resolve({ output: "", exitCode: undefined, @@ -167,11 +183,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions const exitCode = await child.exited; - // Clean up abort listener + // Clean up + if (timeoutHandle) clearTimeout(timeoutHandle); if (options?.signal) { options.signal.removeEventListener("abort", abortHandler); } - if (tempFileStream) { tempFileStream.end(); } @@ -180,6 +196,19 @@ export async function executeBash(command: string, options?: BashExecutorOptions const fullOutput = outputChunks.join(""); const truncationResult = truncateTail(fullOutput); + // Handle timeout + if (timedOut) { + const timeoutSecs = Math.round((options?.timeout || 0) / 1000); + resolve({ + output: `${fullOutput}\n\nCommand timed out after ${timeoutSecs} seconds`, + exitCode: undefined, + cancelled: true, + truncated: truncationResult.truncated, + fullOutputPath: tempFilePath, + }); + return; + } + // Non-zero exit codes or signal-killed processes are considered cancelled if killed via signal const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false)); @@ -191,11 +220,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions fullOutputPath: tempFilePath, }); } catch (err) { - // Clean up abort listener + // Clean up + if (timeoutHandle) clearTimeout(timeoutHandle); if (options?.signal) { options.signal.removeEventListener("abort", abortHandler); } - if (tempFileStream) { tempFileStream.end(); } diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index e92eacdce..2fec51fa9 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -1,21 +1,8 @@ -import { createWriteStream } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { Type } from "@sinclair/typebox"; -import type { Subprocess } from "bun"; -import { getShellConfig, killProcessTree } from "../../utils/shell"; +import { executeBash } from "../bash-executor"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate"; -/** - * Generate a unique temp file path for bash output - */ -function getTempFilePath(): string { - const randomId = crypto.getRandomValues(new Uint8Array(8)); - const id = Array.from(randomId, (b) => b.toString(16).padStart(2, "0")).join(""); - 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)" })), @@ -74,140 +61,34 @@ Usage notes: signal?: AbortSignal, onUpdate?, ) => { - const { shell, args } = getShellConfig(); - const child: Subprocess = Bun.spawn([shell, ...args, command], { + // Track output for streaming updates + let currentOutput = ""; + + const result = await executeBash(command, { cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", + timeout: timeout ? timeout * 1000 : undefined, // Convert to milliseconds + signal, + onChunk: (chunk) => { + currentOutput += chunk; + if (onUpdate) { + const truncation = truncateTail(currentOutput); + onUpdate({ + content: [{ type: "text", text: truncation.content || "" }], + details: { + truncation: truncation.truncated ? truncation : undefined, + }, + }); + } + }, }); - // 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 chunks for tail truncation - const chunks: Buffer[] = []; - let chunksBytes = 0; - const maxChunksBytes = DEFAULT_MAX_BYTES * 2; - - let timedOut = false; - let aborted = false; - - // Handle abort signal - const onAbort = () => { - aborted = true; - if (child.pid) { - killProcessTree(child.pid); - } - }; - - if (signal) { - if (signal.aborted) { - child.kill(); - throw new Error("Command aborted"); - } - signal.addEventListener("abort", onAbort, { once: true }); + // Handle errors + if (result.cancelled) { + throw new Error(result.output || "Command aborted"); } - // Set timeout if provided - let timeoutHandle: Timer | 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); - for (const chunk of chunks) { - tempFileStream.write(chunk); - } - } - - if (tempFileStream) { - tempFileStream.write(data); - } - - // Keep rolling buffer of recent data - chunks.push(data); - chunksBytes += data.length; - - while (chunksBytes > maxChunksBytes && chunks.length > 1) { - const removed = chunks.shift()!; - chunksBytes -= removed.length; - } - - // Stream partial output to callback - if (onUpdate) { - const fullBuffer = Buffer.concat(chunks); - const fullText = fullBuffer.toString("utf-8"); - const truncation = truncateTail(fullText); - onUpdate({ - content: [{ type: "text", text: truncation.content || "" }], - details: { - truncation: truncation.truncated ? truncation : undefined, - fullOutputPath: tempFilePath, - }, - }); - } - }; - - // Read streams using Bun's ReadableStream API - const stdoutReader = (child.stdout as ReadableStream).getReader(); - const stderrReader = (child.stderr as ReadableStream).getReader(); - - await Promise.all([ - (async () => { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - handleData(Buffer.from(value)); - } - })(), - (async () => { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - handleData(Buffer.from(value)); - } - })(), - ]); - - const exitCode = await child.exited; - - // Cleanup - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - if (tempFileStream) tempFileStream.end(); - - // Combine all buffered chunks - const fullBuffer = Buffer.concat(chunks); - const fullOutput = fullBuffer.toString("utf-8"); - - if (aborted && !timedOut) { - let output = fullOutput; - if (output) output += "\n\n"; - output += "Command aborted"; - throw new Error(output); - } - - if (timedOut) { - let output = fullOutput; - if (output) output += "\n\n"; - output += `Command timed out after ${timeout} seconds`; - throw new Error(output); - } - - // Apply tail truncation - const truncation = truncateTail(fullOutput); + // Apply tail truncation for final output + const truncation = truncateTail(result.output); let outputText = truncation.content || "(no output)"; let details: BashToolDetails | undefined; @@ -215,28 +96,24 @@ Usage notes: if (truncation.truncated) { details = { truncation, - fullOutputPath: tempFilePath, + fullOutputPath: result.fullOutputPath, }; const startLine = truncation.totalLines - truncation.outputLines + 1; const endLine = truncation.totalLines; if (truncation.lastLinePartial) { - const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8")); - outputText += `\n\n[Showing last ${formatSize( - truncation.outputBytes, - )} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`; + const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8")); + outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`; } else if (truncation.truncatedBy === "lines") { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`; + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`; } else { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize( - DEFAULT_MAX_BYTES, - )} limit). Full output: ${tempFilePath}]`; + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`; } } - if (exitCode !== 0 && exitCode !== null) { - outputText += `\n\nCommand exited with code ${exitCode}`; + if (result.exitCode !== 0 && result.exitCode !== undefined) { + outputText += `\n\nCommand exited with code ${result.exitCode}`; throw new Error(outputText); } diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 866ea6219..3777691b1 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -66,7 +66,12 @@ Usage: // Reject .ipynb files - use NotebookEdit tool instead if (absolutePath.endsWith(".ipynb")) { return { - content: [{ type: "text", text: "Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead." }], + content: [ + { + type: "text", + text: "Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.", + }, + ], details: undefined, }; } diff --git a/packages/coding-agent/src/utils/shell-snapshot.ts b/packages/coding-agent/src/utils/shell-snapshot.ts index d9c1919cd..72d9d0a55 100644 --- a/packages/coding-agent/src/utils/shell-snapshot.ts +++ b/packages/coding-agent/src/utils/shell-snapshot.ts @@ -6,7 +6,7 @@ * shell experience. */ -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, unlinkSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,10 +17,10 @@ let cleanupRegistered = false; * Get the user's shell config file path. */ function getShellConfigFile(shell: string): string { - const home = homedir(); - if (shell.includes("zsh")) return join(home, ".zshrc"); - if (shell.includes("bash")) return join(home, ".bashrc"); - return join(home, ".profile"); + const home = homedir(); + if (shell.includes("zsh")) return join(home, ".zshrc"); + if (shell.includes("bash")) return join(home, ".bashrc"); + return join(home, ".profile"); } /** @@ -29,15 +29,15 @@ function getShellConfigFile(shell: string): string { * Matches Claude Code's snapshot generation logic. */ function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: string): string { - const hasRcFile = existsSync(rcFile); - const isZsh = shell.includes("zsh"); + const hasRcFile = existsSync(rcFile); + const isZsh = shell.includes("zsh"); - // Escape the snapshot path for shell - const escapedPath = snapshotPath.replace(/'/g, "'\\''"); + // Escape the snapshot path for shell + const escapedPath = snapshotPath.replace(/'/g, "'\\''"); - // Function extraction differs between bash and zsh - const functionScript = isZsh - ? ` + // Function extraction differs between bash and zsh + const functionScript = isZsh + ? ` echo "# Functions" >> "$SNAPSHOT_FILE" # Force autoload all functions first typeset -f > /dev/null 2>&1 @@ -46,7 +46,7 @@ typeset +f 2>/dev/null | grep -vE '^(_|__)' | while read func; do typeset -f "$func" >> "$SNAPSHOT_FILE" 2>/dev/null done ` - : ` + : ` echo "# Functions" >> "$SNAPSHOT_FILE" # Force autoload all functions first declare -f > /dev/null 2>&1 @@ -57,20 +57,20 @@ declare -F 2>/dev/null | cut -d' ' -f3 | grep -vE '^(_|__)' | while read func; d done `; - // Shell options extraction - const optionsScript = isZsh - ? ` + // Shell options extraction + const optionsScript = isZsh + ? ` echo "# Shell Options" >> "$SNAPSHOT_FILE" setopt 2>/dev/null | sed 's/^/setopt /' | head -n 1000 >> "$SNAPSHOT_FILE" ` - : ` + : ` echo "# Shell Options" >> "$SNAPSHOT_FILE" shopt -p 2>/dev/null | head -n 1000 >> "$SNAPSHOT_FILE" set -o 2>/dev/null | grep "on" | awk '{print "set -o " $1}' | head -n 1000 >> "$SNAPSHOT_FILE" echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE" `; - return ` + return ` SNAPSHOT_FILE='${escapedPath}' # Source user's rc file if it exists @@ -110,55 +110,58 @@ fi * Create a shell snapshot, caching the result. * Returns the path to the snapshot file, or null if creation failed. */ -export async function getOrCreateSnapshot(shell: string, env: Record): Promise { - // Return cached snapshot if valid - if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) { - return cachedSnapshotPath; - } +export async function getOrCreateSnapshot( + shell: string, + env: Record, +): Promise { + // Return cached snapshot if valid + if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) { + return cachedSnapshotPath; + } - // Skip on Windows (no .bashrc in standard location) - if (process.platform === "win32") { - return null; - } + // Skip on Windows (no .bashrc in standard location) + if (process.platform === "win32") { + return null; + } - const rcFile = getShellConfigFile(shell); + const rcFile = getShellConfigFile(shell); - // Create snapshot directory - const snapshotDir = join(tmpdir(), "pi-shell-snapshots"); - try { - mkdirSync(snapshotDir, { recursive: true }); - } catch { - return null; - } + // Create snapshot directory + const snapshotDir = join(tmpdir(), "pi-shell-snapshots"); + try { + mkdirSync(snapshotDir, { recursive: true }); + } catch { + return null; + } - // Generate unique snapshot path - const timestamp = Date.now(); - const random = Math.random().toString(36).substring(2, 8); - const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh"; - const snapshotPath = join(snapshotDir, `snapshot-${shellName}-${timestamp}-${random}.sh`); + // Generate unique snapshot path + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh"; + const snapshotPath = join(snapshotDir, `snapshot-${shellName}-${timestamp}-${random}.sh`); - // Generate and execute snapshot script - const script = generateSnapshotScript(shell, snapshotPath, rcFile); + // Generate and execute snapshot script + const script = generateSnapshotScript(shell, snapshotPath, rcFile); - try { - const result = Bun.spawnSync([shell, "-l", "-c", script], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - env, - timeout: 10000, // 10 second timeout - }); + try { + const result = Bun.spawnSync([shell, "-l", "-c", script], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env, + timeout: 10000, // 10 second timeout + }); - if (result.exitCode === 0 && existsSync(snapshotPath)) { - cachedSnapshotPath = snapshotPath; - registerCleanup(); - return snapshotPath; - } - } catch { - // Snapshot creation failed, proceed without it - } + if (result.exitCode === 0 && existsSync(snapshotPath)) { + cachedSnapshotPath = snapshotPath; + registerCleanup(); + return snapshotPath; + } + } catch { + // Snapshot creation failed, proceed without it + } - return null; + return null; } /** @@ -166,50 +169,50 @@ export async function getOrCreateSnapshot(shell: string, env: Record/dev/null && `; + if (!snapshotPath) return ""; + // Escape for shell + const escaped = snapshotPath.replace(/'/g, "'\\''"); + return `source '${escaped}' 2>/dev/null && `; } /** * Register cleanup handler to delete snapshot on process exit. */ function registerCleanup(): void { - if (cleanupRegistered) return; - cleanupRegistered = true; + if (cleanupRegistered) return; + cleanupRegistered = true; - const cleanup = () => { - if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) { - try { - unlinkSync(cachedSnapshotPath); - } catch { - // Ignore cleanup errors - } - } - }; + const cleanup = () => { + if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) { + try { + unlinkSync(cachedSnapshotPath); + } catch { + // Ignore cleanup errors + } + } + }; - process.on("exit", cleanup); - process.on("SIGINT", () => { - cleanup(); - process.exit(130); - }); - process.on("SIGTERM", () => { - cleanup(); - process.exit(143); - }); + process.on("exit", cleanup); + process.on("SIGINT", () => { + cleanup(); + process.exit(130); + }); + process.on("SIGTERM", () => { + cleanup(); + process.exit(143); + }); } /** * Clear the cached snapshot (for testing or forced refresh). */ export function clearSnapshotCache(): void { - if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) { - try { - unlinkSync(cachedSnapshotPath); - } catch { - // Ignore - } - } - cachedSnapshotPath = null; + if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) { + try { + unlinkSync(cachedSnapshotPath); + } catch { + // Ignore + } + } + cachedSnapshotPath = null; } diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 7fe126436..8bebcc1f4 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -1,11 +1,11 @@ -import { existsSync, accessSync, constants } from "node:fs"; +import { accessSync, constants, existsSync } from "node:fs"; import { SettingsManager } from "../core/settings-manager"; export interface ShellConfig { - shell: string; - args: string[]; - env: Record; - prefix: string | undefined; + shell: string; + args: string[]; + env: Record; + prefix: string | undefined; } let cachedShellConfig: ShellConfig | null = null; @@ -14,28 +14,28 @@ let cachedShellConfig: ShellConfig | null = null; * Check if a shell binary is executable. */ function isExecutable(path: string): boolean { - try { - accessSync(path, constants.X_OK); - return true; - } catch { - return false; - } + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } } /** * Build the spawn environment (cached). */ function buildSpawnEnv(shell: string): Record { - const noCI = process.env.PI_BASH_NO_CI || process.env.CLAUDE_BASH_NO_CI; - return { - ...process.env, - SHELL: shell, - GIT_EDITOR: "true", - GPG_TTY: "not a tty", - PICODE: "1", - CLAUDECODE: "1", - ...(noCI ? {} : { CI: "true" }), - }; + const noCI = process.env.PI_BASH_NO_CI || process.env.CLAUDE_BASH_NO_CI; + return { + ...process.env, + SHELL: shell, + GIT_EDITOR: "true", + GPG_TTY: "not a tty", + PICODE: "1", + CLAUDECODE: "1", + ...(noCI ? {} : { CI: "true" }), + }; } /** @@ -43,15 +43,15 @@ function buildSpawnEnv(shell: string): Record { * Supports PI_BASH_NO_LOGIN and CLAUDE_BASH_NO_LOGIN to skip -l. */ function getShellArgs(): string[] { - const noLogin = process.env.PI_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN; - return noLogin ? ["-c"] : ["-l", "-c"]; + const noLogin = process.env.PI_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN; + return noLogin ? ["-c"] : ["-l", "-c"]; } /** * Get shell prefix for wrapping commands (profilers, strace, etc.). */ function getShellPrefix(): string | undefined { - return process.env.PI_SHELL_PREFIX || process.env.CLAUDE_CODE_SHELL_PREFIX; + return process.env.PI_SHELL_PREFIX || process.env.CLAUDE_CODE_SHELL_PREFIX; } /** @@ -76,12 +76,12 @@ function findBashOnPath(): string | null { * Build full shell config from a shell path. */ function buildConfig(shell: string): ShellConfig { - return { - shell, - args: getShellArgs(), - env: buildSpawnEnv(shell), - prefix: getShellPrefix(), - }; + return { + shell, + args: getShellArgs(), + env: buildSpawnEnv(shell), + prefix: getShellPrefix(), + }; } /** @@ -93,92 +93,92 @@ function buildConfig(shell: string): ShellConfig { * 4. Fallback: sh */ export function getShellConfig(): ShellConfig { - if (cachedShellConfig) { - return cachedShellConfig; - } + if (cachedShellConfig) { + return cachedShellConfig; + } - const settings = SettingsManager.create(); - const customShellPath = settings.getShellPath(); + const settings = SettingsManager.create(); + const customShellPath = settings.getShellPath(); - // 1. Check user-specified shell path - if (customShellPath) { - if (existsSync(customShellPath)) { - cachedShellConfig = buildConfig(customShellPath); - return cachedShellConfig; - } - throw new Error( - `Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.pi/agent/settings.json`, - ); - } + // 1. Check user-specified shell path + if (customShellPath) { + if (existsSync(customShellPath)) { + cachedShellConfig = buildConfig(customShellPath); + 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`); - } + 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 = buildConfig(path); - return cachedShellConfig; - } - } + for (const path of paths) { + if (existsSync(path)) { + cachedShellConfig = buildConfig(path); + return cachedShellConfig; + } + } - // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) - const bashOnPath = findBashOnPath(); - if (bashOnPath) { - cachedShellConfig = buildConfig(bashOnPath); - return cachedShellConfig; - } + // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) + const bashOnPath = findBashOnPath(); + if (bashOnPath) { + cachedShellConfig = buildConfig(bashOnPath); + 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")}`, - ); - } + 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")}`, + ); + } - // Unix: prefer user's shell from $SHELL if it's bash/zsh and executable - const userShell = process.env.SHELL; - const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh")); - if (isValidShell && isExecutable(userShell)) { - cachedShellConfig = buildConfig(userShell); - return cachedShellConfig; - } + // Unix: prefer user's shell from $SHELL if it's bash/zsh and executable + const userShell = process.env.SHELL; + const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh")); + if (isValidShell && isExecutable(userShell)) { + cachedShellConfig = buildConfig(userShell); + return cachedShellConfig; + } - // Fallback paths (Claude's approach: check known locations) - const fallbackPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"]; - const preferZsh = !userShell?.includes("bash"); - const shellOrder = preferZsh ? ["zsh", "bash"] : ["bash", "zsh"]; + // Fallback paths (Claude's approach: check known locations) + const fallbackPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"]; + const preferZsh = !userShell?.includes("bash"); + const shellOrder = preferZsh ? ["zsh", "bash"] : ["bash", "zsh"]; - for (const shellName of shellOrder) { - for (const dir of fallbackPaths) { - const shellPath = `${dir}/${shellName}`; - if (isExecutable(shellPath)) { - cachedShellConfig = buildConfig(shellPath); - return cachedShellConfig; - } - } - } + for (const shellName of shellOrder) { + for (const dir of fallbackPaths) { + const shellPath = `${dir}/${shellName}`; + if (isExecutable(shellPath)) { + cachedShellConfig = buildConfig(shellPath); + return cachedShellConfig; + } + } + } - // Last resort: use Bun.which - const bashPath = Bun.which("bash"); - if (bashPath) { - cachedShellConfig = buildConfig(bashPath); - return cachedShellConfig; - } + // Last resort: use Bun.which + const bashPath = Bun.which("bash"); + if (bashPath) { + cachedShellConfig = buildConfig(bashPath); + return cachedShellConfig; + } - const shPath = Bun.which("sh"); - cachedShellConfig = buildConfig(shPath || "sh"); - return cachedShellConfig; + const shPath = Bun.which("sh"); + cachedShellConfig = buildConfig(shPath || "sh"); + return cachedShellConfig; } /**