refactor(coding-agent): extracted bash execution into dedicated executor

- Extracted bash execution logic from bash tool into dedicated BashExecutor with configurable cwd and timeout options.
- Added timeout handling with automatic process termination and timeout-specific result tracking.
- Simplified streaming output handling by replacing rolling buffer with string concatenation.
- Reformatted indentation from spaces to tabs across shell utility files.
This commit is contained in:
can1357
2026-01-03 11:07:03 +01:00
parent f3a36eb433
commit 31cd0cebf9
6 changed files with 276 additions and 365 deletions
+6 -9
View File
@@ -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=="],
@@ -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();
}
+31 -154
View File
@@ -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<typeof createWriteStream> | 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<Uint8Array>).getReader();
const stderrReader = (child.stderr as ReadableStream<Uint8Array>).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);
}
+6 -1
View File
@@ -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,
};
}
@@ -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<string, string | undefined>): Promise<string | null> {
// Return cached snapshot if valid
if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) {
return cachedSnapshotPath;
}
export async function getOrCreateSnapshot(
shell: string,
env: Record<string, string | undefined>,
): Promise<string | null> {
// 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<string, str
* Returns empty string if no snapshot available.
*/
export function getSnapshotSourceCommand(snapshotPath: string | null): string {
if (!snapshotPath) return "";
// Escape for shell
const escaped = snapshotPath.replace(/'/g, "'\\''");
return `source '${escaped}' 2>/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;
}
+105 -105
View File
@@ -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<string, string | undefined>;
prefix: string | undefined;
shell: string;
args: string[];
env: Record<string, string | undefined>;
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<string, string | undefined> {
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<string, string | undefined> {
* 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;
}
/**