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
@@ -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();
}