Files
oh-my-pi/packages/coding-agent/src/exec/bash-executor.ts
T
can1357 9b78a6fa85 refactor(natives): migrated native bindings to NAPI-RS with auto-generated types
- Migrated native bindings from TypeScript wrappers to NAPI-RS generated modules with auto-generated type definitions and runtime enums.
- Replaced chunk tree API with stateful ChunkState class supporting render, edit, and resolve operations with improved error handling.
- Converted callback signatures to error-first pattern (error, result) for shell, PTY, glob, and grep operations.
- Introduced type-safe enums for MacOSAppearance, GrepOutputMode, KeyEventType, ImageFormat, and AstMatchStrictness replacing string literals.
- Refactored chunk tree implementation with dedicated modules for edit, indent, resolve, and state management with comprehensive validation.
- Moved clipboard utilities from native bindings to coding-agent package with improved OSC 52 and Termux compatibility.
2026-04-07 02:00:59 +02:00

241 lines
7.0 KiB
TypeScript

/**
* Bash command execution with streaming support and cancellation.
*
* Uses brush-core via native bindings for shell execution.
*/
import * as fs from "node:fs/promises";
import { executeShell, Shell } from "@oh-my-pi/pi-natives";
import { Settings } from "../config/settings";
import { OutputSink } from "../session/streaming-output";
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
export interface BashExecutorOptions {
cwd?: string;
timeout?: number;
onChunk?: (chunk: string) => void;
signal?: AbortSignal;
/** Session key suffix to isolate shell sessions per agent */
sessionKey?: string;
/** Additional environment variables to inject */
env?: Record<string, string>;
/** Artifact path/id for full output storage */
artifactPath?: string;
artifactId?: string;
}
export interface BashResult {
output: string;
exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
totalLines: number;
totalBytes: number;
outputLines: number;
outputBytes: number;
artifactId?: string;
}
const HARD_TIMEOUT_GRACE_MS = 5_000;
const shellSessions = new Map<string, Shell>();
const brokenShellSessions = new Set<string>();
async function resolveShellCwd(cwd: string | undefined): Promise<string | undefined> {
if (!cwd) return undefined;
try {
// Brush preserves the working directory string verbatim, so resolve symlinks
// up front to keep `pwd` aligned with tools like `git worktree list`.
return await fs.realpath(cwd);
} catch {
return cwd;
}
}
export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
const settings = await Settings.init();
const { shell, env: shellEnv, prefix } = settings.getShellConfig();
const snapshotPath = shell.includes("bash") ? await getOrCreateSnapshot(shell, shellEnv) : null;
const commandCwd = await resolveShellCwd(options?.cwd);
const commandEnv = options?.env ? { ...NON_INTERACTIVE_ENV, ...options.env } : NON_INTERACTIVE_ENV;
// Apply command prefix if configured
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
const finalCommand = prefixedCommand;
// Create output sink for truncation and artifact handling
const sink = new OutputSink({
onChunk: options?.onChunk,
artifactPath: options?.artifactPath,
artifactId: options?.artifactId,
// Throttle the streaming preview callback to avoid saturating the
// event loop when commands produce massive output (e.g. seq 1 50M).
chunkThrottleMs: options?.onChunk ? 50 : 0,
});
// sink.push() is synchronous — buffer management, counters, and onChunk
// all run inline. File writes (artifact path) are handled asynchronously
// inside the sink. No promise chain needed.
const enqueueChunk = (chunk: string) => {
sink.push(chunk);
};
if (options?.signal?.aborted) {
return {
exitCode: undefined,
cancelled: true,
...(await sink.dump("Command cancelled")),
};
}
const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
const persistentSessionBroken = brokenShellSessions.has(sessionKey);
if (persistentSessionBroken) {
shellSessions.delete(sessionKey);
}
let shellSession = persistentSessionBroken ? undefined : shellSessions.get(sessionKey);
if (!shellSession && !persistentSessionBroken) {
shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined });
shellSessions.set(sessionKey, shellSession);
}
const userSignal = options?.signal;
const runAbortController = new AbortController();
const abortCurrentExecution = () => {
if (!runAbortController.signal.aborted) {
runAbortController.abort();
}
if (shellSession) {
// Native abort is async; fire-and-forget because the caller races the command separately.
void shellSession.abort();
}
};
const abortHandler = () => {
abortCurrentExecution();
};
if (userSignal) {
userSignal.addEventListener("abort", abortHandler, { once: true });
}
let hardTimeoutTimer: NodeJS.Timeout | undefined;
const hardTimeoutDeferred = Promise.withResolvers<"hard-timeout">();
const baseTimeoutMs = Math.max(1_000, options?.timeout ?? 300_000);
const hardTimeoutMs = baseTimeoutMs + HARD_TIMEOUT_GRACE_MS;
hardTimeoutTimer = setTimeout(() => {
abortCurrentExecution();
hardTimeoutDeferred.resolve("hard-timeout");
}, hardTimeoutMs);
let resetSession = false;
try {
const runPromise = shellSession
? shellSession.run(
{
command: finalCommand,
cwd: commandCwd,
env: commandEnv,
timeoutMs: options?.timeout,
signal: runAbortController.signal,
},
(err, chunk) => {
if (!err) {
enqueueChunk(chunk);
}
},
)
: executeShell(
{
command: finalCommand,
cwd: commandCwd,
env: commandEnv,
sessionEnv: shellEnv,
snapshotPath: snapshotPath ?? undefined,
timeoutMs: options?.timeout,
signal: runAbortController.signal,
},
(err, chunk) => {
if (!err) {
enqueueChunk(chunk);
}
},
);
const winner = await Promise.race([
runPromise.then(result => ({ kind: "result" as const, result })),
hardTimeoutDeferred.promise.then(() => ({ kind: "hard-timeout" as const })),
]);
if (winner.kind === "hard-timeout") {
if (shellSession) {
resetSession = true;
// Fall back to one-shot execution for the rest of the process once
// a persistent session has stopped responding to cancellation.
brokenShellSessions.add(sessionKey);
}
return {
exitCode: undefined,
cancelled: true,
...(await sink.dump(`Command exceeded hard timeout after ${Math.round(hardTimeoutMs / 1000)} seconds`)),
};
}
// Handle timeout
if (winner.result.timedOut) {
const annotation = options?.timeout
? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
: "Command timed out";
resetSession = true;
return {
exitCode: undefined,
cancelled: true,
...(await sink.dump(annotation)),
};
}
// Handle cancellation
if (winner.result.cancelled) {
resetSession = true;
return {
exitCode: undefined,
cancelled: true,
...(await sink.dump("Command cancelled")),
};
}
// Normal completion
return {
exitCode: winner.result.exitCode,
cancelled: false,
...(await sink.dump()),
};
} catch (err) {
resetSession = true;
throw err;
} finally {
if (hardTimeoutTimer) {
clearTimeout(hardTimeoutTimer);
}
if (userSignal) {
userSignal.removeEventListener("abort", abortHandler);
}
if (resetSession) {
shellSessions.delete(sessionKey);
}
}
}
function buildSessionKey(
shell: string,
prefix: string | undefined,
snapshotPath: string | null,
env: Record<string, string>,
agentSessionKey?: string,
): string {
const entries = Object.entries(env);
entries.sort(([a], [b]) => a.localeCompare(b));
const envSerialized = entries.map(([key, value]) => `${key}=${value}`).join("\n");
return [agentSessionKey ?? "", shell, prefix ?? "", snapshotPath ?? "", envSerialized].join("\n");
}