mod claude;
mod codex;
mod gemini;
pub use claude::ClaudeAdapter;
pub use codex::CodexAdapter;
pub use gemini::GeminiAdapter;
use crate::error::{Error, Result};
use crate::events::StreamEvent;
use crate::types::{CliName, RunOptions, RunResult};
#[cfg(windows)]
#[cfg(windows)]
use process_wrap::tokio::CreationFlags;
use process_wrap::tokio::JobObject;
#[cfg(windows)]
use windows::Win32::System::Threading::CREATE_NO_WINDOW;
#[cfg(unix)]
use process_wrap::tokio::ProcessGroup;
use process_wrap::tokio::TokioChildWrapper;
use process_wrap::tokio::TokioCommandWrap;
use std::collections::HashMap;
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{debug, warn};
pub trait CliAdapter: Send + Sync {
fn name(&self) -> CliName;
fn run(
&self,
opts: &RunOptions,
emit: &(dyn Fn(StreamEvent) + Send + Sync),
cancel: tokio_util::sync::CancellationToken,
) -> impl std::future::Future<Output = crate::error::Result<RunResult>> + Send;
}
pub(crate) fn get_adapter(cli: CliName) -> Box<dyn CliAdapterBoxed> {
match cli {
CliName::Claude => Box::new(ClaudeAdapter),
CliName::Codex => Box::new(CodexAdapter),
CliName::Gemini => Box::new(GeminiAdapter),
}
}
#[allow(dead_code)]
pub(crate) trait CliAdapterBoxed: Send + Sync {
fn name(&self) -> CliName;
fn run_boxed<'a>(
&'a self,
opts: &'a RunOptions,
emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
cancel: tokio_util::sync::CancellationToken,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
>;
}
impl<T: CliAdapter> CliAdapterBoxed for T {
fn name(&self) -> CliName {
CliAdapter::name(self)
}
fn run_boxed<'a>(
&'a self,
opts: &'a RunOptions,
emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
cancel: tokio_util::sync::CancellationToken,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
> {
Box::pin(self.run(opts, emit, cancel))
}
}
pub(crate) enum SpawnOutcome {
Done {
exit_code: Option<i32>,
signal: Option<i32>,
stderr: Option<String>,
dropped_lines: u64,
},
Cancelled,
}
pub(crate) struct SpawnParams<'a> {
pub cli_label: &'a str,
pub binary: &'a str,
pub args: &'a [String],
pub extra_env: &'a HashMap<String, String>,
pub strip_env: &'a [&'static str],
pub cwd: &'a str,
pub max_bytes: usize,
pub cancel: &'a tokio_util::sync::CancellationToken,
}
pub(crate) async fn spawn_and_stream(
params: SpawnParams<'_>,
mut on_line: impl FnMut(&str) + Send,
) -> Result<SpawnOutcome> {
let SpawnParams {
cli_label,
binary,
args,
extra_env,
strip_env,
cwd,
max_bytes,
cancel,
} = params;
debug!(cli = cli_label, binary = %binary, args = ?args, "spawning CLI");
let mut wrap = TokioCommandWrap::with_new(binary, |cmd| {
cmd.args(args);
for key in strip_env {
cmd.env_remove(key);
}
cmd.envs(extra_env)
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
});
#[cfg(windows)]
wrap.wrap(CreationFlags(CREATE_NO_WINDOW));
#[cfg(unix)]
wrap.wrap(ProcessGroup::leader());
#[cfg(windows)]
wrap.wrap(JobObject);
let mut child = wrap
.spawn()
.map_err(|e| Error::Process(format!("failed to spawn {cli_label}: {e}")))?;
let stdout = child.stdout().take().expect("stdout piped");
let stderr = child.stderr().take().expect("stderr piped");
let stderr_handle = tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let mut stderr = stderr;
let mut buf: Vec<u8> = Vec::new();
let mut chunk = [0u8; 8192];
loop {
match stderr.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.len() > STDERR_TAIL_BYTES * 2 {
buf.drain(..buf.len() - STDERR_TAIL_BYTES);
}
}
}
}
String::from_utf8_lossy(&buf).into_owned()
});
let mut reader = BufReader::new(stdout);
let mut line_buf: Vec<u8> = Vec::new();
let mut dropped_lines: u64 = 0;
loop {
line_buf.clear();
tokio::select! {
result = read_line_capped(&mut reader, &mut line_buf, max_bytes) => {
match result {
Ok(CappedLine::Eof) => break,
Ok(CappedLine::Line { dropped: true }) => {
dropped_lines += 1;
warn!(cli = cli_label, max_bytes, "dropped a stdout line larger than the retention cap");
}
Ok(CappedLine::Line { dropped: false }) => {
on_line(String::from_utf8_lossy(&line_buf).trim());
}
Err(e) => {
warn!(cli = cli_label, error = %e, "error reading stdout");
break;
}
}
}
_ = cancel.cancelled() => {
kill_process_group(&mut child).await;
return Ok(SpawnOutcome::Cancelled);
}
}
}
let status = Box::into_pin(child.wait()).await.map_err(Error::Io)?;
let exit_code = status.code();
#[cfg(unix)]
let signal = std::os::unix::process::ExitStatusExt::signal(&status);
#[cfg(not(unix))]
let signal: Option<i32> = None;
let stderr_text = stderr_handle.await.unwrap_or_default();
Ok(SpawnOutcome::Done {
exit_code,
signal,
stderr: if stderr_text.is_empty() {
None
} else {
Some(stderr_text)
},
dropped_lines,
})
}
const STDERR_TAIL_BYTES: usize = 64 * 1024;
enum CappedLine {
Line { dropped: bool },
Eof,
}
async fn read_line_capped<R: tokio::io::AsyncBufRead + Unpin>(
reader: &mut R,
buf: &mut Vec<u8>,
cap: usize,
) -> std::io::Result<CappedLine> {
let mut dropped = false;
loop {
let (consumed, line_complete) = {
let available = reader.fill_buf().await?;
if available.is_empty() {
return Ok(if buf.is_empty() && !dropped {
CappedLine::Eof
} else {
CappedLine::Line { dropped }
});
}
match available.iter().position(|&b| b == b'\n') {
Some(newline) => {
if !dropped {
if buf.len() + newline <= cap {
buf.extend_from_slice(&available[..newline]);
} else {
dropped = true;
buf.clear();
}
}
(newline + 1, true)
}
None => {
let n = available.len();
if !dropped {
if buf.len() + n <= cap {
buf.extend_from_slice(available);
} else {
dropped = true;
buf.clear();
}
}
(n, false)
}
}
};
reader.consume(consumed);
if line_complete {
return Ok(CappedLine::Line { dropped });
}
}
}
pub(crate) fn warn_dropped_lines(dropped_lines: u64, max_bytes: usize, emit: &dyn Fn(StreamEvent)) {
if dropped_lines > 0 {
emit(StreamEvent::Error {
message: format!(
"{dropped_lines} output line(s) exceeded the {max_bytes}-byte retention cap and were dropped"
),
severity: Some(crate::events::Severity::Warning),
});
}
}
pub(crate) fn describe_signal(signal: Option<i32>) -> Option<String> {
let sig = signal?;
Some(match sig {
2 => "The agent was interrupted (SIGINT).".to_string(),
6 => "The agent aborted (SIGABRT).".to_string(),
9 => "The agent was killed (SIGKILL), most often by the system reclaiming memory."
.to_string(),
11 => "The agent crashed (SIGSEGV).".to_string(),
15 => "The agent was terminated (SIGTERM).".to_string(),
other => format!("The agent was terminated by signal {other}."),
})
}
pub(crate) fn extract_error_message(stderr: Option<&str>) -> Option<String> {
let stderr = stderr?;
let msg = stderr
.lines()
.filter(|l| !l.is_empty())
.find(|l| {
let lower = l.to_lowercase();
lower.contains("error")
|| lower.contains("limit")
|| lower.contains("failed")
|| lower.contains("denied")
|| lower.contains("unauthorized")
})
.or_else(|| stderr.lines().rfind(|l| !l.is_empty()));
msg.map(|s| s.trim().to_string())
}
async fn kill_process_group(child: &mut Box<dyn TokioChildWrapper>) {
let _ = Box::into_pin(child.kill()).await;
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[tokio::test]
async fn cancelling_kills_the_grandchild_not_just_the_child() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("survivor");
std::fs::write(&marker, "alive").unwrap();
let script = format!("(sleep 3; rm -f '{}') & sleep 10", marker.display());
let args = vec!["-c".to_string(), script];
let cancel = tokio_util::sync::CancellationToken::new();
let token = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
token.cancel();
});
let outcome = spawn_and_stream(
SpawnParams {
cli_label: "test",
binary: "sh",
args: &args,
extra_env: &HashMap::new(),
strip_env: &[],
cwd: dir.path().to_str().unwrap(),
max_bytes: 1024,
cancel: &cancel,
},
|_: &str| {},
)
.await
.expect("spawn");
assert!(
matches!(outcome, SpawnOutcome::Cancelled),
"run was cancelled"
);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
assert!(
marker.exists(),
"the grandchild outlived cancellation and deleted the marker — the kill did not reach the process group"
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_signalled_process_reports_the_signal_not_a_fabricated_exit_code() {
let args = vec!["-c".to_string(), "kill -9 $$".to_string()];
let cancel = tokio_util::sync::CancellationToken::new();
let outcome = spawn_and_stream(
SpawnParams {
cli_label: "test",
binary: "sh",
args: &args,
extra_env: &HashMap::new(),
strip_env: &[],
cwd: ".",
max_bytes: 1024,
cancel: &cancel,
},
|_| {},
)
.await
.expect("spawn should succeed");
match outcome {
SpawnOutcome::Done {
exit_code, signal, ..
} => {
assert_eq!(exit_code, None, "a signalled process has no exit code");
assert_eq!(signal, Some(9), "SIGKILL should be reported as itself");
}
SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
}
}
#[tokio::test]
async fn a_normal_exit_still_reports_its_code() {
let args = vec!["-c".to_string(), "exit 3".to_string()];
let cancel = tokio_util::sync::CancellationToken::new();
let outcome = spawn_and_stream(
SpawnParams {
cli_label: "test",
binary: "sh",
args: &args,
extra_env: &HashMap::new(),
strip_env: &[],
cwd: ".",
max_bytes: 1024,
cancel: &cancel,
},
|_| {},
)
.await
.expect("spawn should succeed");
match outcome {
SpawnOutcome::Done {
exit_code, signal, ..
} => {
assert_eq!(exit_code, Some(3));
assert_eq!(signal, None, "an ordinary exit was not signalled");
}
SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
}
}
#[test]
fn describe_signal_names_the_common_kills() {
assert!(describe_signal(Some(9)).unwrap().contains("SIGKILL"));
assert!(describe_signal(Some(9)).unwrap().contains("memory"));
assert!(describe_signal(Some(15)).unwrap().contains("SIGTERM"));
assert!(describe_signal(Some(42)).unwrap().contains("42"));
assert_eq!(describe_signal(None), None);
}
#[cfg(unix)]
#[tokio::test]
async fn total_output_beyond_max_bytes_streams_through_and_completes() {
let script = "i=0; while [ $i -lt 200 ]; do printf '%0100d\\n' $i; i=$((i+1)); done";
let args = vec!["-c".to_string(), script.to_string()];
let cancel = tokio_util::sync::CancellationToken::new();
let mut lines = 0u32;
let outcome = spawn_and_stream(
SpawnParams {
cli_label: "test",
binary: "sh",
args: &args,
extra_env: &HashMap::new(),
strip_env: &[],
cwd: ".",
max_bytes: 1024,
cancel: &cancel,
},
|_| lines += 1,
)
.await
.expect("a large-but-line-bounded run must not be an error");
match outcome {
SpawnOutcome::Done { exit_code, .. } => assert_eq!(exit_code, Some(0)),
SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
}
assert_eq!(lines, 200, "every line was streamed through");
}
#[cfg(unix)]
#[tokio::test]
async fn an_oversized_line_is_dropped_and_the_run_continues() {
let script = "echo before; printf '%05000d\\n' 7; echo after";
let args = vec!["-c".to_string(), script.to_string()];
let cancel = tokio_util::sync::CancellationToken::new();
let mut seen: Vec<String> = Vec::new();
let outcome = spawn_and_stream(
SpawnParams {
cli_label: "test",
binary: "sh",
args: &args,
extra_env: &HashMap::new(),
strip_env: &[],
cwd: ".",
max_bytes: 1024,
cancel: &cancel,
},
|l| seen.push(l.to_string()),
)
.await
.expect("an oversized line must not abort the run");
assert_eq!(seen, vec!["before".to_string(), "after".to_string()]);
match outcome {
SpawnOutcome::Done {
exit_code,
dropped_lines,
..
} => {
assert_eq!(exit_code, Some(0));
assert_eq!(dropped_lines, 1, "the loss is counted, never silent");
}
SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
}
}
#[cfg(unix)]
#[tokio::test]
async fn stderr_retains_a_bounded_tail() {
let script = "i=0; while [ $i -lt 10000 ]; do printf '%0100d\\n' $i 1>&2; i=$((i+1)); done; \
echo 'Error: the last words' 1>&2; exit 1";
let args = vec!["-c".to_string(), script.to_string()];
let cancel = tokio_util::sync::CancellationToken::new();
let outcome = spawn_and_stream(
SpawnParams {
cli_label: "test",
binary: "sh",
args: &args,
extra_env: &HashMap::new(),
strip_env: &[],
cwd: ".",
max_bytes: 1024,
cancel: &cancel,
},
|_| {},
)
.await
.expect("spawn should succeed");
match outcome {
SpawnOutcome::Done { stderr, .. } => {
let stderr = stderr.expect("stderr was written");
assert!(
stderr.len() <= 256 * 1024,
"stderr retention must be bounded, got {} bytes",
stderr.len()
);
assert!(
stderr.contains("the last words"),
"the tail is the part that explains the failure"
);
}
SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
}
}
}