feat(shell): migrated shell execution to native brush-core bindings with streaming support

- Added native shell execution API via brush-core with streaming output support and configurable timeouts.
- Migrated bash executor from persistent ptree-based shell sessions to native bindings with improved performance and simplified architecture.
- Removed persistent shell session module and replaced with native brush-core implementation for better resource management.
- Added executeShell() and abortShellExecution() functions to native bindings for shell command execution with cancellation support.
- Implemented shell execution with support for environment variables, working directory changes, and timeout handling.
This commit is contained in:
can1357
2026-02-01 11:33:42 +01:00
parent c3e7726b8f
commit 056f0bd22d
14 changed files with 2041 additions and 781 deletions
Generated
+1560 -16
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -13,8 +13,11 @@ crate-type = ["cdylib"]
workspace = true
[dependencies]
napi = { version = "3", features = ["napi10", "tokio_rt"] }
napi = { version = "3", features = ["napi10", "tokio_rt", "tokio_time"] }
napi-derive = "3"
brush-core = "0.3.5"
libc = "0.2"
os_pipe = "1"
grep-regex = "0.1"
grep-searcher = "0.1"
grep-matcher = "0.1"
@@ -36,6 +39,7 @@ syntect = { version = "5.3", default-features = false, features = [
"regex-fancy",
] }
html-to-markdown-rs = { version = "2.24", default-features = false }
phf = { version = "0.11", features = ["macros"] }
[build-dependencies]
napi-build = "2"
+1
View File
@@ -26,4 +26,5 @@ pub mod highlight;
pub mod html;
pub mod image;
pub mod keys;
pub mod shell;
pub mod text;
+288
View File
@@ -0,0 +1,288 @@
//! Brush-based shell execution exported via N-API.
//!
//! # Overview
//! Executes shell commands in a non-interactive brush-core shell, streaming
//! output back to JavaScript via a threadsafe callback.
//!
//! # Example
//! ```ignore
//! const result = await natives.executeShell({ command: "ls" }, (chunk) => {
//! console.log(chunk);
//! });
//! ```
use std::{
collections::HashMap,
io::Read,
sync::{LazyLock, Mutex},
time::Duration,
};
use brush_core::{
CreateOptions, OpenFile, OpenFiles, ProcessGroupPolicy, Shell, ShellValue, ShellVariable,
};
use napi::{
bindgen_prelude::*,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
tokio::{self, task, time},
};
use napi_derive::napi;
type ExecutionMap = HashMap<String, ExecutionControl>;
struct ExecutionControl {
cancel: tokio::sync::oneshot::Sender<()>,
}
struct ExecutionGuard {
execution_id: String,
}
impl Drop for ExecutionGuard {
fn drop(&mut self) {
let Ok(mut executions) = EXECUTIONS.lock() else {
return;
};
executions.remove(&self.execution_id);
}
}
static EXECUTIONS: LazyLock<Mutex<ExecutionMap>> = LazyLock::new(|| Mutex::new(HashMap::new()));
/// Options for executing a shell command via brush-core.
#[napi(object)]
pub struct ShellExecuteOptions {
pub command: String,
pub cwd: Option<String>,
pub env: Option<HashMap<String, String>>,
pub timeout_ms: Option<u32>,
pub execution_id: String,
}
/// Result of executing a shell command via brush-core.
#[napi(object)]
pub struct ShellExecuteResult {
pub exit_code: Option<i32>,
pub cancelled: bool,
pub timed_out: bool,
}
/// Execute a brush shell command.
#[napi]
pub async fn execute_shell(
options: ShellExecuteOptions,
#[napi(ts_arg_type = "((chunk: string) => void) | undefined | null")] on_chunk: Option<
ThreadsafeFunction<String>,
>,
) -> Result<ShellExecuteResult> {
let execution_id = options.execution_id.clone();
let timeout_ms = options.timeout_ms;
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
{
let mut executions = EXECUTIONS
.lock()
.map_err(|_| Error::from_reason("Execution lock poisoned"))?;
if executions.contains_key(&execution_id) {
return Err(Error::from_reason("Execution already running"));
}
executions.insert(execution_id.clone(), ExecutionControl { cancel: cancel_tx });
}
let _guard = ExecutionGuard { execution_id };
let run_future = run_shell(options, on_chunk);
tokio::pin!(run_future);
let mut cancelled = false;
let mut timed_out = false;
let run_result = if let Some(ms) = timeout_ms {
let timeout = time::sleep(Duration::from_millis(u64::from(ms)));
tokio::pin!(timeout);
tokio::select! {
result = &mut run_future => result,
_ = cancel_rx => {
cancelled = true;
attempt_kill_children().await;
return Ok(ShellExecuteResult { exit_code: None, cancelled, timed_out });
}
() = &mut timeout => {
timed_out = true;
attempt_kill_children().await;
return Ok(ShellExecuteResult { exit_code: None, cancelled, timed_out });
}
}
} else {
tokio::select! {
result = &mut run_future => result,
_ = cancel_rx => {
cancelled = true;
attempt_kill_children().await;
return Ok(ShellExecuteResult { exit_code: None, cancelled, timed_out });
}
}
}?;
Ok(ShellExecuteResult { exit_code: Some(i32::from(run_result.exit_code)), cancelled, timed_out })
}
/// Abort a running shell execution.
#[napi]
pub fn abort_shell_execution(execution_id: String) -> Result<()> {
let mut executions = EXECUTIONS
.lock()
.map_err(|_| Error::from_reason("Execution lock poisoned"))?;
if let Some(control) = executions.remove(&execution_id) {
let _ = control.cancel.send(());
}
Ok(())
}
async fn run_shell(
options: ShellExecuteOptions,
on_chunk: Option<ThreadsafeFunction<String>>,
) -> Result<brush_core::ExecutionResult> {
let create_options = CreateOptions {
interactive: false,
login: false,
no_profile: true,
no_rc: true,
do_not_inherit_env: true,
..Default::default()
};
let mut shell = Shell::new(&create_options)
.await
.map_err(|err| Error::from_reason(format!("Failed to initialize shell: {err}")))?;
if let Some(cwd) = options.cwd.as_deref() {
shell
.set_working_dir(cwd)
.map_err(|err| Error::from_reason(format!("Failed to set cwd: {err}")))?;
}
if let Some(env) = options.env {
for (key, value) in env {
let mut var = ShellVariable::new(ShellValue::String(value));
var.export();
shell
.env
.set_global(key, var)
.map_err(|err| Error::from_reason(format!("Failed to set env: {err}")))?;
}
}
// Create a pipe using os_pipe
let (pipe_reader, pipe_writer) =
os_pipe::pipe().map_err(|err| Error::from_reason(format!("Failed to create pipe: {err}")))?;
// Convert to std::fs::File via OwnedFd
#[cfg(unix)]
let (reader_file, writer_file): (std::fs::File, std::fs::File) = {
use std::os::unix::io::IntoRawFd;
let reader_fd = pipe_reader.into_raw_fd();
let writer_fd = pipe_writer.into_raw_fd();
// SAFETY: We just obtained these fds from os_pipe and own them exclusively.
unsafe {
(
std::os::unix::io::FromRawFd::from_raw_fd(reader_fd),
std::os::unix::io::FromRawFd::from_raw_fd(writer_fd),
)
}
};
#[cfg(windows)]
let (reader_file, writer_file): (std::fs::File, std::fs::File) = {
use std::os::windows::io::IntoRawHandle;
let reader_handle = pipe_reader.into_raw_handle();
let writer_handle = pipe_writer.into_raw_handle();
// SAFETY: We just obtained these handles from os_pipe and own them exclusively.
unsafe {
(
std::os::windows::io::FromRawHandle::from_raw_handle(reader_handle),
std::os::windows::io::FromRawHandle::from_raw_handle(writer_handle),
)
}
};
let stdout_file = OpenFile::from(
writer_file
.try_clone()
.map_err(|err| Error::from_reason(format!("Failed to clone pipe: {err}")))?,
);
let stderr_file = OpenFile::from(writer_file);
let mut open_files = shell.open_files.clone();
open_files.set(OpenFiles::STDOUT_FD, stdout_file);
open_files.set(OpenFiles::STDERR_FD, stderr_file);
let mut params = shell.default_exec_params();
params.open_files = open_files;
params.process_group_policy = ProcessGroupPolicy::NewProcessGroup;
let reader_handle = task::spawn_blocking(move || read_output(reader_file, on_chunk));
let result = shell
.run_string(options.command, &params)
.await
.map_err(|err| Error::from_reason(format!("Shell execution failed: {err}")));
// Drop shell and params to close write ends of pipes, allowing reader to finish
drop(shell);
drop(params);
let _ = reader_handle.await;
result
}
fn read_output(mut reader: std::fs::File, on_chunk: Option<ThreadsafeFunction<String>>) {
let mut buf = [0u8; 8192];
loop {
let read = match reader.read(&mut buf) {
Ok(0) => break,
Ok(count) => count,
Err(_) => break,
};
if let Some(callback) = on_chunk.as_ref() {
let chunk = String::from_utf8_lossy(&buf[..read]).to_string();
callback.call(Ok(chunk), ThreadsafeFunctionCallMode::NonBlocking);
}
}
}
#[cfg(unix)]
async fn attempt_kill_children() {
let pid = std::process::id();
let path = format!("/proc/{pid}/task/{pid}/children");
let Ok(children) = std::fs::read_to_string(path) else {
return;
};
let pids: Vec<i32> = children
.split_whitespace()
.filter_map(|pid| pid.parse::<i32>().ok())
.collect();
if pids.is_empty() {
return;
}
for pid in &pids {
// SAFETY: Sending SIGINT to child processes is safe; invalid pids are ignored.
unsafe {
libc::kill(*pid, libc::SIGINT);
}
}
time::sleep(Duration::from_millis(50)).await;
for pid in &pids {
// SAFETY: Sending SIGKILL to child processes is safe; invalid pids are ignored.
unsafe {
libc::kill(*pid, libc::SIGKILL);
}
}
}
#[cfg(not(unix))]
async fn attempt_kill_children() {}
+13
View File
@@ -1,6 +1,19 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed persistent shell session support; bash execution now uses native bindings via brush-core for improved reliability
### Changed
- Migrated bash command execution from ptree-based persistent sessions to native shell bindings with streaming support
- Simplified bash executor to use brush-core native API instead of managing long-lived shell processes
### Removed
- Removed `shell-session.ts` module providing persistent shell session management
- Removed shell session test suite for persistent execution patterns
## [9.6.2] - 2026-02-01
### Changed
+85 -100
View File
@@ -1,13 +1,12 @@
/**
* Bash command execution with streaming support and cancellation.
*
* Provides unified bash execution for AgentSession.executeBash() and direct calls.
* Uses brush-core via native bindings for shell execution.
*/
import { Exception, ptree } from "@oh-my-pi/pi-utils";
import * as crypto from "node:crypto";
import { abortShellExecution, executeShell } from "@oh-my-pi/pi-natives";
import { Settings } from "../config/settings";
import { OutputSink } from "../session/streaming-output";
import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
import { executeShellCommand } from "./shell-session";
export interface BashExecutorOptions {
cwd?: string;
@@ -35,115 +34,101 @@ export interface BashResult {
export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
const settings = await Settings.init();
const { shell, args, env, prefix } = settings.getShellConfig();
const snapshotPath = await getOrCreateSnapshot(shell, env);
const { env: shellEnv, prefix } = settings.getShellConfig();
if (shouldUsePersistentShell(settings.get("bash.persistentShell"))) {
return await executeShellCommand({ shell, env, prefix, snapshotPath }, command, {
cwd: options?.cwd,
timeout: options?.timeout,
signal: options?.signal,
onChunk: options?.onChunk,
env: options?.env,
artifactPath: options?.artifactPath,
artifactId: options?.artifactId,
});
// Generate unique execution ID for abort support
const executionId = crypto.randomUUID();
// Merge shell env with additional env vars (additional takes precedence)
// Filter out undefined values and problematic vars for the native API
// BASH_ENV and ENV cause brush-core to fail with "not yet implemented" errors
const mergedEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(shellEnv)) {
if (value !== undefined && key !== "BASH_ENV" && key !== "ENV") {
mergedEnv[key] = value;
}
}
if (options?.env) {
for (const [key, value] of Object.entries(options.env)) {
if (key !== "BASH_ENV" && key !== "ENV") {
mergedEnv[key] = value;
}
}
}
return await executeBashOnce(command, options, { shell, args, env, prefix, snapshotPath });
}
/**
* Determine whether to use persistent shell sessions.
* Priority: OMP_SHELL_PERSIST env var > settings > default (false)
*/
function shouldUsePersistentShell(settingValue: boolean): boolean {
// Env var takes precedence (for debugging/override)
const flag = parseEnvFlag(process.env.OMP_SHELL_PERSIST);
if (flag !== undefined) return flag;
// Windows never uses persistent shell (too unreliable)
if (process.platform === "win32") return false;
// Use setting value (defaults to false)
return settingValue;
}
function parseEnvFlag(value: string | undefined): boolean | undefined {
if (!value) return undefined;
const normalized = value.toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return undefined;
}
async function executeBashOnce(
command: string,
options: BashExecutorOptions | undefined,
config: {
shell: string;
args: string[];
env: Record<string, string | undefined>;
prefix?: string;
snapshotPath: string | null;
},
): Promise<BashResult> {
const { shell, args, env, prefix, snapshotPath } = config;
// Merge additional env vars if provided
const finalEnv = options?.env ? { ...env, ...options.env } : env;
const snapshotPrefix = getSnapshotSourceCommand(snapshotPath);
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
// Apply command prefix if configured
const finalCommand = prefix ? `${prefix} ${command}` : command;
// Create output sink for truncation and artifact handling
const sink = new OutputSink({
onChunk: options?.onChunk,
artifactPath: options?.artifactPath,
artifactId: options?.artifactId,
});
using child = ptree.spawn([shell, ...args, finalCommand], {
cwd: options?.cwd,
env: finalEnv,
signal: options?.signal,
timeout: options?.timeout,
detached: true,
});
// Pump streams - errors during abort/timeout are expected
// Use preventClose to avoid closing the shared sink when either stream finishes
await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch(
() => {},
);
// Wait for process exit
try {
return {
exitCode: await child.exited,
cancelled: false,
...(await sink.dump()),
};
} catch (err: unknown) {
// Exception covers NonZeroExitError, AbortError, TimeoutError
if (err instanceof Exception) {
if (err.aborted) {
const isTimeout = err instanceof ptree.TimeoutError || err.message.toLowerCase().includes("timed out");
const annotation = isTimeout
? `Command timed out after ${Math.round((options?.timeout ?? 0) / 1000)} seconds`
: undefined;
return {
exitCode: undefined,
cancelled: true,
...(await sink.dump(annotation)),
};
}
// NonZeroExitError
// Set up abort handling
let abortListener: (() => void) | undefined;
if (options?.signal) {
const signal = options.signal;
if (signal.aborted) {
// Already aborted
return {
exitCode: err.exitCode,
cancelled: false,
...(await sink.dump()),
exitCode: undefined,
cancelled: true,
...(await sink.dump("Command cancelled")),
};
}
abortListener = () => {
abortShellExecution(executionId);
};
signal.addEventListener("abort", abortListener, { once: true });
}
try {
const result = await executeShell(
{
command: finalCommand,
cwd: options?.cwd,
env: Object.keys(mergedEnv).length > 0 ? mergedEnv : undefined,
timeoutMs: options?.timeout,
executionId,
},
async (chunk: string) => {
await sink.push(chunk);
},
);
// Handle timeout
if (result.timedOut) {
const annotation = options?.timeout
? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
: "Command timed out";
return {
exitCode: undefined,
cancelled: true,
...(await sink.dump(annotation)),
};
}
throw err;
// Handle cancellation
if (result.cancelled) {
return {
exitCode: undefined,
cancelled: true,
...(await sink.dump("Command cancelled")),
};
}
// Normal completion
return {
exitCode: result.exitCode,
cancelled: false,
...(await sink.dump()),
};
} finally {
// Clean up abort listener
if (abortListener && options?.signal) {
options.signal.removeEventListener("abort", abortListener);
}
}
}
@@ -1,609 +0,0 @@
/**
* Persistent shell session executor for streaming bash tool output.
*/
import * as crypto from "node:crypto";
import { logger, postmortem, ptree } from "@oh-my-pi/pi-utils";
import { OutputSink, type OutputSummary } from "../session/streaming-output";
export interface ShellSessionConfig {
shell: string;
env: Record<string, string | undefined>;
prefix?: string;
snapshotPath: string | null;
}
export interface ShellCommandOptions {
cwd?: string;
timeout?: number;
signal?: AbortSignal;
onChunk?: (chunk: string) => void;
env?: Record<string, string>;
artifactPath?: string;
artifactId?: string;
}
export interface ShellCommandResult extends OutputSummary {
exitCode: number | undefined;
cancelled: boolean;
}
const MARKER_PREFIX = "__OMP_CMD_DONE__";
const MARKER_TAIL_MAX = 128;
const ABORT_GRACE_MS = 1500;
const IS_WINDOWS = process.platform === "win32";
interface RunningCommand {
marker: string;
markerSentinel: string;
sink: OutputSink;
resolve: (result: ShellCommandResult) => void;
done: Promise<ShellCommandResult>;
cancelled: boolean;
abortReason?: "timeout" | "signal";
abortNotice?: string;
abortListener?: () => void;
completed: boolean;
}
function escapePosix(value: string): string {
return `'${value.split("'").join("'\"'\"'")}'`;
}
function isFishShell(shell: string): boolean {
return shell.includes("fish");
}
function buildEnvExports(env: Record<string, string> | undefined, fish: boolean): string {
if (!env) return "";
const entries = Object.entries(env).filter(([, value]) => value !== undefined);
if (entries.length === 0) return "";
if (fish) {
return entries.map(([key, value]) => `set -lx ${key} ${escapePosix(value)}`).join("\n");
}
return entries.map(([key, value]) => `export ${key}=${escapePosix(value)}`).join("\n");
}
function buildPosixCommandScript(
command: string,
cwd: string | undefined,
prefix: string | undefined,
marker: string,
commandEnv: Record<string, string> | undefined,
): string {
const envExports = buildEnvExports(commandEnv, false);
const commandLine = prefix ? `${prefix} ${command}` : command;
const lines: string[] = [
"__omp_restore_errexit=0",
"case $- in *e*) __omp_restore_errexit=1 ;; esac",
"set +e",
"__omp_prev_trap_int=$(trap -p INT 2>/dev/null || true)",
"trap - INT",
"__omp_prev_exit=",
"__omp_prev_logout=",
"__omp_prev_exec=",
"if command -v typeset >/dev/null 2>&1; then __omp_prev_exit=$(typeset -f exit 2>/dev/null || true); fi",
"if command -v typeset >/dev/null 2>&1; then __omp_prev_logout=$(typeset -f logout 2>/dev/null || true); fi",
"if command -v typeset >/dev/null 2>&1; then __omp_prev_exec=$(typeset -f exec 2>/dev/null || true); fi",
'exit() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }',
'logout() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }',
'exec() { command "$@"; return $?; }',
];
if (envExports) lines.push(envExports);
if (cwd) lines.push(`cd -- ${escapePosix(cwd)}`);
// Redirect stdin from /dev/null to prevent interactive commands from blocking
// on the shell's stdin pipe (which is used for sending commands, not user input).
// Explicit pipes within the command (e.g., `echo "y" | cmd`) still work.
lines.push(commandLine.length > 0 ? `{ ${commandLine}; } < /dev/null` : ":");
lines.push("__omp_status=$?");
lines.push("unset -f exit logout exec 2>/dev/null");
lines.push('if [ -n "$__omp_prev_exit" ]; then eval "$__omp_prev_exit"; fi');
lines.push('if [ -n "$__omp_prev_logout" ]; then eval "$__omp_prev_logout"; fi');
lines.push('if [ -n "$__omp_prev_exec" ]; then eval "$__omp_prev_exec"; fi');
lines.push('if [ -n "$__omp_prev_trap_int" ]; then eval "$__omp_prev_trap_int"; else trap - INT; fi');
lines.push("unset __omp_prev_trap_int");
lines.push("unset __omp_prev_exit __omp_prev_logout __omp_prev_exec");
lines.push('if [ "$__omp_restore_errexit" -eq 1 ]; then set -e; fi');
lines.push("unset __omp_restore_errexit");
lines.push(`printf '\\n${marker}%d\\n' "$__omp_status"`);
return `${lines.join("\n")}\n`;
}
function buildFishCommandScript(
command: string,
cwd: string | undefined,
prefix: string | undefined,
marker: string,
commandEnv: Record<string, string> | undefined,
): string {
const envExports = buildEnvExports(commandEnv, true);
const commandLine = prefix ? `${prefix} ${command}` : command;
const lines: string[] = [
"begin",
"functions -e __omp_prev_exit 2>/dev/null",
"functions -e __omp_prev_logout 2>/dev/null",
"functions -e __omp_prev_exec 2>/dev/null",
"functions -q exit; and functions -c exit __omp_prev_exit",
"functions -q logout; and functions -c logout __omp_prev_logout",
"functions -q exec; and functions -c exec __omp_prev_exec",
"function exit",
" if test (count $argv) -gt 0",
" set -g __omp_exit_code $argv[1]",
" else",
" set -g __omp_exit_code 0",
" end",
" return $__omp_exit_code",
"end",
"function logout",
" if test (count $argv) -gt 0",
" set -g __omp_exit_code $argv[1]",
" else",
" set -g __omp_exit_code 0",
" end",
" return $__omp_exit_code",
"end",
"function exec",
" command $argv",
" return $status",
"end",
];
if (envExports) lines.push(envExports);
if (cwd) lines.push(`cd -- ${escapePosix(cwd)}`);
// Redirect stdin from /dev/null to prevent interactive commands from blocking
lines.push(commandLine.length > 0 ? `begin; ${commandLine}; end < /dev/null` : ":");
lines.push("if set -q __omp_exit_code");
lines.push(" set -l __omp_status $__omp_exit_code");
lines.push(" set -e __omp_exit_code");
lines.push("else");
lines.push(" set -l __omp_status $status");
lines.push("end");
lines.push("functions -e exit logout exec");
lines.push("functions -q __omp_prev_exit; and functions -c __omp_prev_exit exit; and functions -e __omp_prev_exit");
lines.push(
"functions -q __omp_prev_logout; and functions -c __omp_prev_logout logout; and functions -e __omp_prev_logout",
);
lines.push("functions -q __omp_prev_exec; and functions -c __omp_prev_exec exec; and functions -e __omp_prev_exec");
lines.push(`printf "\\n${marker}%d\\n" $__omp_status`);
lines.push("end");
return `${lines.join("\n")}\n`;
}
function getSessionArgs(shell: string, snapshotPath: string | null): string[] {
if (snapshotPath) return [];
const noLogin = process.env.OMP_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN;
if (noLogin) return [];
if (shell.includes("bash") || shell.includes("zsh") || shell.includes("fish")) return ["-l"];
return [];
}
function serializeEnv(env: Record<string, string | undefined>): string {
const entries = Object.entries(env).filter(([, value]) => value !== undefined);
entries.sort(([a], [b]) => a.localeCompare(b));
return entries.map(([key, value]) => `${key}=${value}`).join("\n");
}
function sanitizePersistentEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
const sanitized = { ...env };
delete sanitized.BASH_ENV;
delete sanitized.ENV;
return sanitized;
}
class ShellSession {
#child: ReturnType<typeof ptree.spawn<"pipe">> | null = null;
#stdinWriter: WritableStreamDefaultWriter<Uint8Array> | Bun.FileSink | null = null;
#buffer = "";
#queue: Promise<void> = Promise.resolve();
#chunkQueue: Promise<void> = Promise.resolve();
#streamsDone: Promise<unknown> = Promise.resolve();
#current: RunningCommand | null = null;
#startPromise: Promise<void> | null = null;
#closed = false;
#encoder = new TextEncoder();
#lastExitCode: number | null | undefined = undefined;
constructor(private readonly config: ShellSessionConfig) {}
async execute(command: string, options: ShellCommandOptions): Promise<ShellCommandResult> {
const run = async () => {
try {
await this.#start();
return await this.#runCommand(command, options);
} catch (error) {
if (this.#shouldRestart(error)) {
await this.#terminateSession();
await this.#start();
return await this.#runCommand(command, options);
}
throw error;
}
};
const queued = this.#queue.then(run, run);
this.#queue = queued.then(
() => {},
() => {},
);
return queued;
}
async dispose(): Promise<void> {
this.#closed = true;
const child = this.#child;
this.#child = null;
this.#stdinWriter = null;
if (child) {
child.kill();
await child.exited.catch(() => {});
}
}
async #start(): Promise<void> {
if (this.#closed) {
throw new Error("Shell session is closed");
}
if (this.#startPromise) return this.#startPromise;
this.#startPromise = this.#spawnShell().catch(error => {
this.#startPromise = null;
throw error;
});
return this.#startPromise;
}
async #spawnShell(): Promise<void> {
const args = getSessionArgs(this.config.shell, this.config.snapshotPath);
this.#child = ptree.spawn([this.config.shell, ...args], {
stdin: "pipe",
env: this.config.env,
detached: !IS_WINDOWS,
});
if (this.#child.proc.exitCode !== null) {
this.#lastExitCode = this.#child.proc.exitCode;
throw new Error(`Shell exited immediately with code ${this.#child.proc.exitCode}`);
}
const stdin = this.#child.stdin;
if (stdin && typeof stdin === "object" && "getWriter" in stdin) {
this.#stdinWriter = (stdin as unknown as WritableStream<Uint8Array>).getWriter();
} else {
this.#stdinWriter = stdin as Bun.FileSink;
}
this.#attachStreams(this.#child);
this.#child.exited.then(code => this.#handleShellExit(code)).catch(() => this.#handleShellExit(null));
const initCommand = this.#buildInitCommand();
if (initCommand) {
await this.#runCommand(initCommand, {});
}
}
#buildInitCommand(): string | null {
if (!this.config.snapshotPath) return null;
const snapshotPath = escapePosix(this.config.snapshotPath);
if (isFishShell(this.config.shell)) {
return `source ${snapshotPath}`;
}
return `source ${snapshotPath} 2>/dev/null`;
}
#attachStreams(child: ReturnType<typeof ptree.spawn<"pipe">>): void {
const readStream = async (stream: ReadableStream<Uint8Array>) => {
const reader = stream.getReader();
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
const text = decoder.decode(value, { stream: true });
if (text) {
await this.#enqueueChunk(text);
}
}
const remaining = decoder.decode();
if (remaining) {
await this.#enqueueChunk(remaining);
}
} catch {
// ignore
} finally {
try {
await reader.cancel();
} catch {}
reader.releaseLock();
}
};
this.#streamsDone = Promise.allSettled([readStream(child.stdout), readStream(child.stderr)]);
}
async #enqueueChunk(text: string): Promise<void> {
this.#chunkQueue = this.#chunkQueue.then(() => this.#processChunk(text));
return this.#chunkQueue;
}
async #processChunk(text: string): Promise<void> {
const running = this.#current;
if (!running) return;
this.#buffer += text;
const sentinel = running.markerSentinel;
while (this.#buffer.length > 0) {
const markerIndex = this.#buffer.indexOf(sentinel);
if (markerIndex === -1) {
const lastNewline = this.#buffer.lastIndexOf("\n");
if (lastNewline > -1) {
const tail = this.#buffer.slice(lastNewline);
const flushLength = tail.length <= MARKER_TAIL_MAX ? lastNewline : this.#buffer.length - MARKER_TAIL_MAX;
if (flushLength > 0) {
await running.sink.push(this.#buffer.slice(0, flushLength));
this.#buffer = this.#buffer.slice(flushLength);
}
return;
}
const flushLength = Math.max(0, this.#buffer.length - Math.min(sentinel.length, MARKER_TAIL_MAX));
if (flushLength > 0) {
await running.sink.push(this.#buffer.slice(0, flushLength));
this.#buffer = this.#buffer.slice(flushLength);
}
return;
}
if (markerIndex > 0) {
await running.sink.push(this.#buffer.slice(0, markerIndex));
}
const markerValueStart = markerIndex + sentinel.length;
const lineEnd = this.#buffer.indexOf("\n", markerValueStart);
if (lineEnd === -1) {
this.#buffer = this.#buffer.slice(markerIndex);
return;
}
const exitText = this.#buffer.slice(markerValueStart, lineEnd).trim();
const exitCode = Number.parseInt(exitText, 10);
this.#buffer = this.#buffer.slice(lineEnd + 1);
await this.#finishCommand(running, Number.isFinite(exitCode) ? exitCode : undefined);
this.#buffer = "";
return;
}
}
async #runCommand(command: string, options: ShellCommandOptions): Promise<ShellCommandResult> {
if (!this.#child || !this.#stdinWriter) {
const exitInfo = this.#lastExitCode === undefined ? "unknown" : String(this.#lastExitCode);
throw new Error(`Shell session not started (shell=${this.config.shell}, exit=${exitInfo})`);
}
this.#buffer = "";
const markerId = crypto.randomUUID().replace(/-/g, "");
const marker = `${MARKER_PREFIX}${markerId}__`;
const markerSentinel = `\n${marker}`;
const sink = new OutputSink({
onChunk: options.onChunk,
artifactPath: options.artifactPath,
artifactId: options.artifactId,
});
const { promise, resolve } = Promise.withResolvers<ShellCommandResult>();
const running: RunningCommand = {
marker,
markerSentinel,
sink,
resolve,
done: promise,
cancelled: false,
completed: false,
};
this.#current = running;
const timeoutSignal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined;
let timeoutFired = false;
if (timeoutSignal) {
timeoutSignal.addEventListener(
"abort",
() => {
timeoutFired = true;
},
{ once: true },
);
}
const combinedSignal = options.signal
? AbortSignal.any(timeoutSignal ? [options.signal, timeoutSignal] : [options.signal])
: timeoutSignal;
if (combinedSignal) {
const onAbort = () => {
void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout);
};
running.abortListener = () => combinedSignal.removeEventListener("abort", onAbort);
if (combinedSignal.aborted) {
void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout);
} else {
combinedSignal.addEventListener("abort", onAbort, { once: true });
}
}
try {
const script = isFishShell(this.config.shell)
? buildFishCommandScript(command, options.cwd, this.config.prefix, marker, options.env)
: buildPosixCommandScript(command, options.cwd, this.config.prefix, marker, options.env);
await this.#writeToStdin(script);
} catch (error) {
await this.#handleWriteFailure(error instanceof Error ? error : new Error(String(error)));
}
return await promise;
}
async #finishCommand(running: RunningCommand, exitCode: number | undefined): Promise<void> {
if (running.completed) return;
running.completed = true;
running.abortListener?.();
this.#current = null;
const summary = await running.sink.dump(running.cancelled ? running.abortNotice : undefined);
running.resolve({
exitCode: running.cancelled ? undefined : exitCode,
cancelled: running.cancelled,
...summary,
});
}
async #abortCommand(
running: RunningCommand,
reason: "timeout" | "signal",
timeoutMs: number | undefined,
): Promise<void> {
if (running.completed) return;
running.cancelled = true;
running.abortReason = reason;
const notice =
reason === "timeout" && timeoutMs
? `Command timed out after ${Math.round(timeoutMs / 1000)} seconds`
: "Command cancelled";
running.abortNotice = notice;
await this.#sendInterrupt();
const completed = await Promise.race([
running.done.then(
() => true,
() => true,
),
Bun.sleep(ABORT_GRACE_MS).then(() => false),
]);
if (completed) return;
await this.#terminateSession();
// Drain streams and chunk queue - marker might have arrived but not yet processed
await this.#streamsDone;
await this.#chunkQueue;
if (running.completed) return;
running.completed = true;
running.abortListener?.();
this.#current = null;
const summary = await running.sink.dump(notice);
running.resolve({
exitCode: undefined,
cancelled: true,
...summary,
});
}
async #sendInterrupt(): Promise<void> {
const child = this.#child;
if (!child?.pid) return;
try {
if (IS_WINDOWS) {
child.proc.kill("SIGINT");
return;
}
if (child.isProcessGroup) {
process.kill(-child.pid, "SIGINT");
} else {
process.kill(child.pid, "SIGINT");
}
} catch {}
}
async #terminateSession(): Promise<void> {
const child = this.#child;
this.#child = null;
this.#stdinWriter = null;
this.#startPromise = null;
if (child) {
child.kill();
await child.exited.catch(() => {});
}
}
async #handleShellExit(exitCode: number | null): Promise<void> {
const running = this.#current;
this.#lastExitCode = exitCode;
this.#child = null;
this.#stdinWriter = null;
this.#startPromise = null;
if (!running || running.completed) return;
// Wait for any pending chunks to be processed - marker might be in the queue
await this.#streamsDone;
await this.#chunkQueue;
if (running.completed) return;
running.cancelled = true;
running.abortReason = "signal";
running.completed = true;
running.abortListener?.();
this.#current = null;
this.#buffer = "";
const summary = await running.sink.dump(running.abortNotice ?? "Shell session terminated");
running.resolve({
exitCode: undefined,
cancelled: true,
...summary,
});
}
async #handleWriteFailure(error: Error): Promise<void> {
logger.warn("Shell session write failed", { error: error.message });
await this.#terminateSession();
throw error;
}
#shouldRestart(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return (
error.message.includes("Shell session not started") ||
error.message.includes("Shell session stdin unavailable")
);
}
async #writeToStdin(script: string): Promise<void> {
if (!this.#stdinWriter) {
throw new Error("Shell session stdin unavailable");
}
const payload = this.#encoder.encode(script);
const writer = this.#stdinWriter;
await Promise.resolve(writer.write(payload));
}
}
const sessions = new Map<string, ShellSession>();
function buildSessionKey(config: ShellSessionConfig): string {
return [config.shell, config.prefix ?? "", config.snapshotPath ?? "", serializeEnv(config.env)].join("\n");
}
export async function executeShellCommand(
config: ShellSessionConfig,
command: string,
options: ShellCommandOptions,
): Promise<ShellCommandResult> {
const sanitizedConfig = { ...config, env: sanitizePersistentEnv(config.env) };
const key = buildSessionKey(sanitizedConfig);
let session = sessions.get(key);
if (!session) {
session = new ShellSession(sanitizedConfig);
sessions.set(key, session);
}
return await session.execute(command, options);
}
export const __testing = {
buildPosixCommandScript,
buildFishCommandScript,
escapePosix,
getSessionArgs,
};
postmortem.register("shell-session", async () => {
const active = Array.from(sessions.values());
sessions.clear();
await Promise.all(active.map(session => session.dispose()));
});
@@ -1,36 +0,0 @@
import { describe, expect, it } from "bun:test";
import { __testing as shellSessionTesting } from "@oh-my-pi/pi-coding-agent/exec/shell-session";
describe("shell session wrappers", () => {
it("builds posix wrapper with cwd, prefix, and marker", () => {
const script = shellSessionTesting.buildPosixCommandScript(
"echo hello",
"/tmp/workdir",
"strace -f",
"__OMP_CMD_DONE__abc__",
{ FOO: "bar" },
);
expect(script).toContain("set +e");
expect(script).toContain("cd -- '/tmp/workdir'");
expect(script).toContain("export FOO='bar'");
expect(script).toContain("strace -f echo hello");
expect(script).toContain('exit() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }');
expect(script).toContain("trap - INT");
expect(script).toContain("printf '\\n__OMP_CMD_DONE__abc__%d\\n' \"$__omp_status\"");
});
it("builds fish wrapper with marker and restore", () => {
const script = shellSessionTesting.buildFishCommandScript(
"echo hi",
"/tmp/fishdir",
undefined,
"__OMP_CMD_DONE__xyz__",
{ BAR: "baz" },
);
expect(script).toContain("cd -- '/tmp/fishdir'");
expect(script).toContain("set -lx BAR 'baz'");
expect(script).toContain('printf "\\n__OMP_CMD_DONE__xyz__%d\\n"');
});
});
+2 -18
View File
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EditTool } from "@oh-my-pi/pi-coding-agent/patch";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash";
@@ -476,22 +476,6 @@ function b() {
/Working directory does not exist/,
);
});
it("should handle process spawn errors", async () => {
_resetSettingsForTest();
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
shell: "/nonexistent-shell-path-xyz123",
args: ["-c"],
env: {},
prefix: undefined,
});
const bashWithBadShell = new BashTool(createTestToolSession(testDir));
await expect(bashWithBadShell.execute("test-call-12", { command: "echo test" })).rejects.toThrow(/ENOENT/);
vi.restoreAllMocks();
});
});
describe("grep tool", () => {
+7
View File
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
### Added
- Added `executeShell()` function to execute shell commands with streaming output support and configurable timeout
- Added `abortShellExecution()` function to cancel running shell executions by ID
- Added `ShellExecuteOptions` and `ShellExecuteResult` types for shell command execution configuration and results
### Changed
- Improved native binary installation to use atomic rename operations and better fallback handling for Windows DLLs
+12 -1
View File
@@ -6,6 +6,8 @@ import * as path from "node:path";
import type { FindMatch, FindOptions, FindResult } from "./find/types";
import { native } from "./native";
export type { RequestOptions } from "./request-options";
// =============================================================================
// Grep (ripgrep-based regex search)
// =============================================================================
@@ -108,4 +110,13 @@ export {
htmlToMarkdown,
} from "./html/index";
export type { RequestOptions } from "./request-options";
// =============================================================================
// Shell execution (brush-core)
// =============================================================================
export {
abortShellExecution,
executeShell,
type ShellExecuteOptions,
type ShellExecuteResult,
} from "./shell/index";
+8
View File
@@ -11,6 +11,7 @@ import type {
} from "./grep/types";
import type { HighlightColors } from "./highlight/index";
import type { HtmlToMarkdownOptions } from "./html/types";
import type { ShellExecuteOptions, ShellExecuteResult } from "./shell/types";
import type { ExtractSegmentsResult, SliceWithWidthResult } from "./text/index";
export interface NativePhotonImage {
@@ -66,6 +67,11 @@ export interface NativeBindings {
strictAfter: boolean,
): ExtractSegmentsResult;
matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean;
executeShell(
options: ShellExecuteOptions,
onChunk?: (error: Error | null, chunk: string) => void,
): Promise<ShellExecuteResult>;
abortShellExecution(executionId: string): void;
}
const require = createRequire(import.meta.url);
@@ -141,6 +147,8 @@ function validateNative(bindings: NativeBindings, source: string): void {
checkFn("sliceWithWidth");
checkFn("extractSegments");
checkFn("matchesKittySequence");
checkFn("executeShell");
checkFn("abortShellExecution");
if (missing.length) {
throw new Error(
+33
View File
@@ -0,0 +1,33 @@
/**
* Native shell execution via brush-core.
*/
import { native } from "../native";
import type { ShellExecuteOptions, ShellExecuteResult } from "./types";
export type { ShellExecuteOptions, ShellExecuteResult } from "./types";
/**
* Execute a shell command using brush-core.
*
* @param options - Execution options including command, cwd, env, timeout
* @param onChunk - Optional callback for streaming output chunks
* @returns Promise resolving to execution result with exit code and status
*/
export async function executeShell(
options: ShellExecuteOptions,
onChunk?: (chunk: string) => void,
): Promise<ShellExecuteResult> {
// napi-rs ThreadsafeFunction passes (error, value) - skip callback on error
const wrappedCallback = onChunk ? (err: Error | null, chunk: string) => !err && onChunk(chunk) : undefined;
return native.executeShell(options, wrappedCallback);
}
/**
* Abort a running shell execution.
*
* @param executionId - The execution ID to abort
*/
export function abortShellExecution(executionId: string): void {
native.abortShellExecution(executionId);
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Options for executing a shell command via brush-core.
*/
export interface ShellExecuteOptions {
/** The command to execute */
command: string;
/** Working directory for command execution */
cwd?: string;
/** Environment variables to set */
env?: Record<string, string>;
/** Timeout in milliseconds */
timeoutMs?: number;
/** Unique identifier for this execution (used for abort) */
executionId: string;
}
/**
* Result of executing a shell command via brush-core.
*/
export interface ShellExecuteResult {
/** Exit code of the command (undefined if cancelled or timed out) */
exitCode?: number;
/** Whether the command was cancelled via abort */
cancelled: boolean;
/** Whether the command timed out */
timedOut: boolean;
}