mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 08:00:59 +00:00
WP1: Create bash-executor.ts with unified bash execution
This commit is contained in:
parent
cceb44410e
commit
3f305502cd
3 changed files with 185 additions and 3 deletions
|
|
@ -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
|
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
|
3. Manual test: Run `!sleep 10`, press Esc, verify cancellation works
|
||||||
|
|
||||||
- [ ] Create `src/core/bash-executor.ts` with `executeBash()` function
|
- [x] Create `src/core/bash-executor.ts` with `executeBash()` function
|
||||||
- [ ] Add proper TypeScript types and exports
|
- [x] Add proper TypeScript types and exports
|
||||||
- [ ] Verify with `npm run check`
|
- [x] Verify with `npm run check`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
177
packages/coding-agent/src/core/bash-executor.ts
Normal file
177
packages/coding-agent/src/core/bash-executor.ts
Normal file
|
|
@ -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<BashResult> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
5
packages/coding-agent/src/core/index.ts
Normal file
5
packages/coding-agent/src/core/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* Core modules shared between all run modes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";
|
||||||
Loading…
Add table
Add a link
Reference in a new issue