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};
use std::collections::HashMap;
use process_wrap::tokio::TokioChildWrapper;
use process_wrap::tokio::TokioCommandWrap;
#[cfg(unix)]
use process_wrap::tokio::ProcessGroup;
#[cfg(windows)]
use process_wrap::tokio::JobObject;
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>,
},
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(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 {
let mut reader = BufReader::new(stderr);
let mut buf = String::new();
while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {}
buf
});
let mut reader = BufReader::new(stdout);
let mut line = String::new();
let mut total_bytes: usize = 0;
loop {
line.clear();
tokio::select! {
result = reader.read_line(&mut line) => {
match result {
Ok(0) => break,
Ok(n) => {
total_bytes += n;
if total_bytes > max_bytes {
warn!(cli = cli_label, total_bytes, max_bytes, "output exceeded max buffer size");
kill_process_group(&mut child).await;
return Err(Error::Process(format!(
"output exceeded max buffer size ({max_bytes} bytes)"
)));
}
on_line(line.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)
},
})
}
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);
}
}