From 3f305502cd58b1c7a8ce027e419d17957fb7e1ef Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Dec 2025 00:01:36 +0100 Subject: [PATCH] WP1: Create bash-executor.ts with unified bash execution --- packages/coding-agent/docs/refactor.md | 6 +- .../coding-agent/src/core/bash-executor.ts | 177 ++++++++++++++++++ packages/coding-agent/src/core/index.ts | 5 + 3 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/src/core/bash-executor.ts create mode 100644 packages/coding-agent/src/core/index.ts diff --git a/packages/coding-agent/docs/refactor.md b/packages/coding-agent/docs/refactor.md index cde5dd8a..17b12c10 100644 --- a/packages/coding-agent/docs/refactor.md +++ b/packages/coding-agent/docs/refactor.md @@ -182,9 +182,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro 2. Manual test: Run `pi` in interactive mode, execute `!ls -la`, verify output appears 3. Manual test: Run `!sleep 10`, press Esc, verify cancellation works -- [ ] Create `src/core/bash-executor.ts` with `executeBash()` function -- [ ] Add proper TypeScript types and exports -- [ ] Verify with `npm run check` +- [x] Create `src/core/bash-executor.ts` with `executeBash()` function +- [x] Add proper TypeScript types and exports +- [x] Verify with `npm run check` --- diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts new file mode 100644 index 00000000..be855054 --- /dev/null +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -0,0 +1,177 @@ +/** + * Bash command execution with streaming support and cancellation. + * + * This module provides a unified bash execution implementation used by: + * - AgentSession.executeBash() for interactive and RPC modes + * - Direct calls from modes that need bash execution + */ + +import { randomBytes } from "node:crypto"; +import { createWriteStream, type WriteStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type ChildProcess, spawn } from "child_process"; +import stripAnsi from "strip-ansi"; +import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../shell.js"; +import { DEFAULT_MAX_BYTES, truncateTail } from "../tools/truncate.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface BashExecutorOptions { + /** Callback for streaming output chunks (already sanitized) */ + onChunk?: (chunk: string) => void; + /** AbortSignal for cancellation */ + signal?: AbortSignal; +} + +export interface BashResult { + /** Combined stdout + stderr output (sanitized, possibly truncated) */ + output: string; + /** Process exit code (null if killed/cancelled) */ + exitCode: number | null; + /** Whether the command was cancelled via signal */ + cancelled: boolean; + /** Whether the output was truncated */ + truncated: boolean; + /** Path to temp file containing full output (if output exceeded threshold) */ + fullOutputPath?: string; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Execute a bash command with optional streaming and cancellation support. + * + * Features: + * - Streams sanitized output via onChunk callback + * - Writes large output to temp file for later retrieval + * - Supports cancellation via AbortSignal + * - Sanitizes output (strips ANSI, removes binary garbage, normalizes newlines) + * - Truncates output if it exceeds the default max bytes + * + * @param command - The bash command to execute + * @param options - Optional streaming callback and abort signal + * @returns Promise resolving to execution result + */ +export function executeBash(command: string, options?: BashExecutorOptions): Promise { + return new Promise((resolve, reject) => { + const { shell, args } = getShellConfig(); + const child: ChildProcess = spawn(shell, [...args, command], { + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + // Track sanitized output for truncation + const outputChunks: string[] = []; + let outputBytes = 0; + const maxOutputBytes = DEFAULT_MAX_BYTES * 2; + + // Temp file for large output + let tempFilePath: string | undefined; + let tempFileStream: WriteStream | undefined; + let totalBytes = 0; + + // Handle abort signal + const abortHandler = () => { + if (child.pid) { + killProcessTree(child.pid); + } + }; + + if (options?.signal) { + if (options.signal.aborted) { + // Already aborted, don't even start + child.kill(); + resolve({ + output: "", + exitCode: null, + cancelled: true, + truncated: false, + }); + return; + } + options.signal.addEventListener("abort", abortHandler, { once: true }); + } + + const handleData = (data: Buffer) => { + totalBytes += data.length; + + // Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines + const text = sanitizeBinaryOutput(stripAnsi(data.toString())).replace(/\r/g, ""); + + // Start writing to temp file if exceeds threshold + if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) { + const id = randomBytes(8).toString("hex"); + tempFilePath = join(tmpdir(), `pi-bash-${id}.log`); + tempFileStream = createWriteStream(tempFilePath); + // Write already-buffered chunks to temp file + for (const chunk of outputChunks) { + tempFileStream.write(chunk); + } + } + + if (tempFileStream) { + tempFileStream.write(text); + } + + // Keep rolling buffer of sanitized text + outputChunks.push(text); + outputBytes += text.length; + while (outputBytes > maxOutputBytes && outputChunks.length > 1) { + const removed = outputChunks.shift()!; + outputBytes -= removed.length; + } + + // Stream to callback if provided + if (options?.onChunk) { + options.onChunk(text); + } + }; + + child.stdout?.on("data", handleData); + child.stderr?.on("data", handleData); + + child.on("close", (code) => { + // Clean up abort listener + if (options?.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + + if (tempFileStream) { + tempFileStream.end(); + } + + // Combine buffered chunks for truncation (already sanitized) + const fullOutput = outputChunks.join(""); + const truncationResult = truncateTail(fullOutput); + + // code === null means killed (cancelled) + const cancelled = code === null; + + resolve({ + output: truncationResult.truncated ? truncationResult.content : fullOutput, + exitCode: code, + cancelled, + truncated: truncationResult.truncated, + fullOutputPath: tempFilePath, + }); + }); + + child.on("error", (err) => { + // Clean up abort listener + if (options?.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + + if (tempFileStream) { + tempFileStream.end(); + } + + reject(err); + }); + }); +} diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts new file mode 100644 index 00000000..7f75e756 --- /dev/null +++ b/packages/coding-agent/src/core/index.ts @@ -0,0 +1,5 @@ +/** + * Core modules shared between all run modes. + */ + +export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";