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:
Generated
+1560
-16
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -26,4 +26,5 @@ pub mod highlight;
|
||||
pub mod html;
|
||||
pub mod image;
|
||||
pub mod keys;
|
||||
pub mod shell;
|
||||
pub mod text;
|
||||
|
||||
@@ -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, ¶ms)
|
||||
.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() {}
|
||||
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user