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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user