1mod claude;
2mod codex;
3mod gemini;
4
5pub use claude::ClaudeAdapter;
6pub use codex::CodexAdapter;
7pub use gemini::GeminiAdapter;
8
9use crate::error::{Error, Result};
10use crate::events::StreamEvent;
11use crate::types::{CliName, RunOptions, RunResult};
12use std::collections::HashMap;
13use process_wrap::tokio::TokioChildWrapper;
14use process_wrap::tokio::TokioCommandWrap;
15#[cfg(unix)]
16use process_wrap::tokio::ProcessGroup;
17#[cfg(windows)]
18use process_wrap::tokio::JobObject;
19use tokio::io::{AsyncBufReadExt, BufReader};
20use tracing::{debug, warn};
21
22pub trait CliAdapter: Send + Sync {
24 fn name(&self) -> CliName;
25
26 fn run(
27 &self,
28 opts: &RunOptions,
29 emit: &(dyn Fn(StreamEvent) + Send + Sync),
30 cancel: tokio_util::sync::CancellationToken,
31 ) -> impl std::future::Future<Output = crate::error::Result<RunResult>> + Send;
32}
33
34pub(crate) fn get_adapter(cli: CliName) -> Box<dyn CliAdapterBoxed> {
36 match cli {
37 CliName::Claude => Box::new(ClaudeAdapter),
38 CliName::Codex => Box::new(CodexAdapter),
39 CliName::Gemini => Box::new(GeminiAdapter),
40 }
41}
42
43#[allow(dead_code)]
49pub(crate) trait CliAdapterBoxed: Send + Sync {
50 fn name(&self) -> CliName;
51
52 fn run_boxed<'a>(
53 &'a self,
54 opts: &'a RunOptions,
55 emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
56 cancel: tokio_util::sync::CancellationToken,
57 ) -> std::pin::Pin<
58 Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
59 >;
60}
61
62impl<T: CliAdapter> CliAdapterBoxed for T {
63 fn name(&self) -> CliName {
64 CliAdapter::name(self)
65 }
66
67 fn run_boxed<'a>(
68 &'a self,
69 opts: &'a RunOptions,
70 emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
71 cancel: tokio_util::sync::CancellationToken,
72 ) -> std::pin::Pin<
73 Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
74 > {
75 Box::pin(self.run(opts, emit, cancel))
76 }
77}
78
79pub(crate) enum SpawnOutcome {
83 Done {
85 exit_code: Option<i32>,
92 signal: Option<i32>,
95 stderr: Option<String>,
96 },
97 Cancelled,
99}
100
101pub(crate) struct SpawnParams<'a> {
103 pub cli_label: &'a str,
104 pub binary: &'a str,
105 pub args: &'a [String],
106 pub extra_env: &'a HashMap<String, String>,
107 pub strip_env: &'a [&'static str],
110 pub cwd: &'a str,
111 pub max_bytes: usize,
112 pub cancel: &'a tokio_util::sync::CancellationToken,
113}
114
115pub(crate) async fn spawn_and_stream(
122 params: SpawnParams<'_>,
123 mut on_line: impl FnMut(&str) + Send,
124) -> Result<SpawnOutcome> {
125 let SpawnParams {
126 cli_label,
127 binary,
128 args,
129 extra_env,
130 strip_env,
131 cwd,
132 max_bytes,
133 cancel,
134 } = params;
135 debug!(cli = cli_label, binary = %binary, args = ?args, "spawning CLI");
136
137 let mut wrap = TokioCommandWrap::with_new(binary, |cmd| {
151 cmd.args(args);
152 for key in strip_env {
153 cmd.env_remove(key);
154 }
155 cmd.envs(extra_env)
156 .current_dir(cwd)
157 .stdin(std::process::Stdio::null())
158 .stdout(std::process::Stdio::piped())
159 .stderr(std::process::Stdio::piped())
160 .kill_on_drop(true);
161 });
162 #[cfg(unix)]
163 wrap.wrap(ProcessGroup::leader());
164 #[cfg(windows)]
165 wrap.wrap(JobObject);
166
167 let mut child = wrap
168 .spawn()
169 .map_err(|e| Error::Process(format!("failed to spawn {cli_label}: {e}")))?;
170
171 let stdout = child.stdout().take().expect("stdout piped");
172 let stderr = child.stderr().take().expect("stderr piped");
173
174 let stderr_handle = tokio::spawn(async move {
175 let mut reader = BufReader::new(stderr);
176 let mut buf = String::new();
177 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {}
178 buf
179 });
180
181 let mut reader = BufReader::new(stdout);
182 let mut line = String::new();
183 let mut total_bytes: usize = 0;
184
185 loop {
186 line.clear();
187 tokio::select! {
188 result = reader.read_line(&mut line) => {
189 match result {
190 Ok(0) => break,
191 Ok(n) => {
192 total_bytes += n;
193 if total_bytes > max_bytes {
194 warn!(cli = cli_label, total_bytes, max_bytes, "output exceeded max buffer size");
195 kill_process_group(&mut child).await;
196 return Err(Error::Process(format!(
197 "output exceeded max buffer size ({max_bytes} bytes)"
198 )));
199 }
200 on_line(line.trim());
201 }
202 Err(e) => {
203 warn!(cli = cli_label, error = %e, "error reading stdout");
204 break;
205 }
206 }
207 }
208 _ = cancel.cancelled() => {
209 kill_process_group(&mut child).await;
210 return Ok(SpawnOutcome::Cancelled);
211 }
212 }
213 }
214
215 let status = Box::into_pin(child.wait()).await.map_err(Error::Io)?;
216 let exit_code = status.code();
222 #[cfg(unix)]
223 let signal = std::os::unix::process::ExitStatusExt::signal(&status);
224 #[cfg(not(unix))]
225 let signal: Option<i32> = None;
226 let stderr_text = stderr_handle.await.unwrap_or_default();
227
228 Ok(SpawnOutcome::Done {
229 exit_code,
230 signal,
231 stderr: if stderr_text.is_empty() {
232 None
233 } else {
234 Some(stderr_text)
235 },
236 })
237}
238
239pub(crate) fn describe_signal(signal: Option<i32>) -> Option<String> {
246 let sig = signal?;
247 Some(match sig {
248 2 => "The agent was interrupted (SIGINT).".to_string(),
249 6 => "The agent aborted (SIGABRT).".to_string(),
250 9 => "The agent was killed (SIGKILL), most often by the system reclaiming memory."
251 .to_string(),
252 11 => "The agent crashed (SIGSEGV).".to_string(),
253 15 => "The agent was terminated (SIGTERM).".to_string(),
254 other => format!("The agent was terminated by signal {other}."),
255 })
256}
257
258pub(crate) fn extract_error_message(stderr: Option<&str>) -> Option<String> {
262 let stderr = stderr?;
263 let msg = stderr
265 .lines()
266 .filter(|l| !l.is_empty())
267 .find(|l| {
268 let lower = l.to_lowercase();
269 lower.contains("error")
270 || lower.contains("limit")
271 || lower.contains("failed")
272 || lower.contains("denied")
273 || lower.contains("unauthorized")
274 })
275 .or_else(|| stderr.lines().rfind(|l| !l.is_empty()));
276 msg.map(|s| s.trim().to_string())
277}
278
279async fn kill_process_group(child: &mut Box<dyn TokioChildWrapper>) {
286 let _ = Box::into_pin(child.kill()).await;
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[cfg(unix)]
315 #[tokio::test]
316 async fn cancelling_kills_the_grandchild_not_just_the_child() {
317 let dir = tempfile::tempdir().unwrap();
318 let marker = dir.path().join("survivor");
319 std::fs::write(&marker, "alive").unwrap();
320
321 let script = format!("(sleep 3; rm -f '{}') & sleep 10", marker.display());
323 let args = vec!["-c".to_string(), script];
324 let cancel = tokio_util::sync::CancellationToken::new();
325
326 let token = cancel.clone();
327 tokio::spawn(async move {
328 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
329 token.cancel();
330 });
331
332 let outcome = spawn_and_stream(
333 SpawnParams {
334 cli_label: "test",
335 binary: "sh",
336 args: &args,
337 extra_env: &HashMap::new(),
338 strip_env: &[],
339 cwd: dir.path().to_str().unwrap(),
340 max_bytes: 1024,
341 cancel: &cancel,
342 },
343 |_: &str| {},
344 )
345 .await
346 .expect("spawn");
347
348 assert!(matches!(outcome, SpawnOutcome::Cancelled), "run was cancelled");
349
350 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
352 assert!(
353 marker.exists(),
354 "the grandchild outlived cancellation and deleted the marker — the kill did not reach the process group"
355 );
356 }
357
358 #[cfg(unix)]
368 #[tokio::test]
369 async fn a_signalled_process_reports_the_signal_not_a_fabricated_exit_code() {
370 let args = vec!["-c".to_string(), "kill -9 $$".to_string()];
371 let cancel = tokio_util::sync::CancellationToken::new();
372 let outcome = spawn_and_stream(
373 SpawnParams {
374 cli_label: "test",
375 binary: "sh",
376 args: &args,
377 extra_env: &HashMap::new(),
378 strip_env: &[],
379 cwd: ".",
380 max_bytes: 1024,
381 cancel: &cancel,
382 },
383 |_| {},
384 )
385 .await
386 .expect("spawn should succeed");
387
388 match outcome {
389 SpawnOutcome::Done {
390 exit_code, signal, ..
391 } => {
392 assert_eq!(exit_code, None, "a signalled process has no exit code");
393 assert_eq!(signal, Some(9), "SIGKILL should be reported as itself");
394 }
395 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
396 }
397 }
398
399 #[tokio::test]
402 async fn a_normal_exit_still_reports_its_code() {
403 let args = vec!["-c".to_string(), "exit 3".to_string()];
404 let cancel = tokio_util::sync::CancellationToken::new();
405 let outcome = spawn_and_stream(
406 SpawnParams {
407 cli_label: "test",
408 binary: "sh",
409 args: &args,
410 extra_env: &HashMap::new(),
411 strip_env: &[],
412 cwd: ".",
413 max_bytes: 1024,
414 cancel: &cancel,
415 },
416 |_| {},
417 )
418 .await
419 .expect("spawn should succeed");
420
421 match outcome {
422 SpawnOutcome::Done {
423 exit_code, signal, ..
424 } => {
425 assert_eq!(exit_code, Some(3));
426 assert_eq!(signal, None, "an ordinary exit was not signalled");
427 }
428 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
429 }
430 }
431
432 #[test]
434 fn describe_signal_names_the_common_kills() {
435 assert!(describe_signal(Some(9)).unwrap().contains("SIGKILL"));
436 assert!(describe_signal(Some(9)).unwrap().contains("memory"));
437 assert!(describe_signal(Some(15)).unwrap().contains("SIGTERM"));
438 assert!(describe_signal(Some(42)).unwrap().contains("42"));
439 assert_eq!(describe_signal(None), None);
440 }
441}