pi-mono/packages/coding-agent/src/core/bash-executor.ts

146 lines
4.3 KiB
TypeScript

/**
* 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 type { WriteStream } from "node:fs";
import { splitIncompleteAnsiSuffix, stripAnsi } from "../utils/ansi.ts";
import { createOutputFileStream } from "../utils/output-files.ts";
import { sanitizeBinaryOutput } from "../utils/shell.ts";
import type { BashOperations } from "./tools/bash.ts";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.ts";
// ============================================================================
// 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 (undefined if killed/cancelled) */
exitCode: number | undefined;
/** 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 truncation threshold) */
fullOutputPath?: string;
}
// ============================================================================
// Implementation
// ============================================================================
/**
* Execute a bash command using custom BashOperations.
* Used for remote execution (SSH, containers, etc.).
*/
export async function executeBashWithOperations(
command: string,
cwd: string,
operations: BashOperations,
options?: BashExecutorOptions,
): Promise<BashResult> {
const outputChunks: string[] = [];
let outputBytes = 0;
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
let tempFilePath: string | undefined;
let tempFileStream: WriteStream | undefined;
let totalBytes = 0;
const ensureTempFile = () => {
if (tempFilePath) {
return;
}
({ path: tempFilePath, stream: tempFileStream } = createOutputFileStream("pi-bash", ".log"));
for (const chunk of outputChunks) {
tempFileStream.write(chunk);
}
};
const decoder = new TextDecoder();
// Unfinished escape sequence at the end of the previous chunk, completed by the next chunk.
let pendingAnsi = "";
const appendText = (rawText: string) => {
// Sanitize: strip ANSI, replace binary garbage, normalize newlines
const text = sanitizeBinaryOutput(stripAnsi(rawText)).replace(/\r/g, "");
if (!text) {
return;
}
// Start writing to temp file if exceeds threshold
if (totalBytes > DEFAULT_MAX_BYTES) {
ensureTempFile();
}
if (tempFileStream) {
tempFileStream.write(text);
}
// Keep rolling buffer
outputChunks.push(text);
outputBytes += text.length;
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
const removed = outputChunks.shift()!;
outputBytes -= removed.length;
}
// Stream to callback
if (options?.onChunk) {
options.onChunk(text);
}
};
const onData = (data: Buffer) => {
totalBytes += data.length;
const { complete, pending } = splitIncompleteAnsiSuffix(pendingAnsi + decoder.decode(data, { stream: true }));
pendingAnsi = pending;
appendText(complete);
};
const flushOutput = () => {
const rest = pendingAnsi + decoder.decode();
pendingAnsi = "";
appendText(rest);
};
let exitCode: number | null = null;
try {
({ exitCode } = await operations.exec(command, cwd, { onData, signal: options?.signal }));
} catch (err) {
// An aborted command still returns the output it produced so far
if (!options?.signal?.aborted) {
tempFileStream?.end();
throw err;
}
}
flushOutput();
const fullOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput);
if (truncationResult.truncated) {
ensureTempFile();
}
tempFileStream?.end();
const cancelled = options?.signal?.aborted ?? false;
return {
output: truncationResult.truncated ? truncationResult.content : fullOutput,
exitCode: cancelled ? undefined : (exitCode ?? undefined),
cancelled,
truncated: truncationResult.truncated,
fullOutputPath: tempFilePath,
};
}