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 tokio::io::{AsyncBufReadExt, BufReader};
14use tokio::process::Command;
15use tracing::{debug, warn};
16
17pub trait CliAdapter: Send + Sync {
19 fn name(&self) -> CliName;
20
21 fn run(
22 &self,
23 opts: &RunOptions,
24 emit: &(dyn Fn(StreamEvent) + Send + Sync),
25 cancel: tokio_util::sync::CancellationToken,
26 ) -> impl std::future::Future<Output = crate::error::Result<RunResult>> + Send;
27}
28
29pub(crate) fn get_adapter(cli: CliName) -> Box<dyn CliAdapterBoxed> {
31 match cli {
32 CliName::Claude => Box::new(ClaudeAdapter),
33 CliName::Codex => Box::new(CodexAdapter),
34 CliName::Gemini => Box::new(GeminiAdapter),
35 }
36}
37
38#[allow(dead_code)]
44pub(crate) trait CliAdapterBoxed: Send + Sync {
45 fn name(&self) -> CliName;
46
47 fn run_boxed<'a>(
48 &'a self,
49 opts: &'a RunOptions,
50 emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
51 cancel: tokio_util::sync::CancellationToken,
52 ) -> std::pin::Pin<
53 Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
54 >;
55}
56
57impl<T: CliAdapter> CliAdapterBoxed for T {
58 fn name(&self) -> CliName {
59 CliAdapter::name(self)
60 }
61
62 fn run_boxed<'a>(
63 &'a self,
64 opts: &'a RunOptions,
65 emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
66 cancel: tokio_util::sync::CancellationToken,
67 ) -> std::pin::Pin<
68 Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
69 > {
70 Box::pin(self.run(opts, emit, cancel))
71 }
72}
73
74pub(crate) enum SpawnOutcome {
78 Done {
80 exit_code: Option<i32>,
87 signal: Option<i32>,
90 stderr: Option<String>,
91 },
92 Cancelled,
94}
95
96pub(crate) struct SpawnParams<'a> {
98 pub cli_label: &'a str,
99 pub binary: &'a str,
100 pub args: &'a [String],
101 pub extra_env: &'a HashMap<String, String>,
102 pub strip_env: &'a [&'static str],
105 pub cwd: &'a str,
106 pub max_bytes: usize,
107 pub cancel: &'a tokio_util::sync::CancellationToken,
108}
109
110pub(crate) async fn spawn_and_stream(
117 params: SpawnParams<'_>,
118 mut on_line: impl FnMut(&str) + Send,
119) -> Result<SpawnOutcome> {
120 let SpawnParams {
121 cli_label,
122 binary,
123 args,
124 extra_env,
125 strip_env,
126 cwd,
127 max_bytes,
128 cancel,
129 } = params;
130 debug!(cli = cli_label, binary = %binary, args = ?args, "spawning CLI");
131
132 let mut cmd = Command::new(binary);
133 cmd.args(args);
134 for key in strip_env {
135 cmd.env_remove(key);
136 }
137 cmd.envs(extra_env)
138 .current_dir(cwd)
139 .stdin(std::process::Stdio::null())
140 .stdout(std::process::Stdio::piped())
141 .stderr(std::process::Stdio::piped())
142 .kill_on_drop(true);
143
144 #[cfg(unix)]
145 {
146 unsafe {
147 cmd.pre_exec(|| {
148 if libc::setpgid(0, 0) != 0 {
149 return Err(std::io::Error::last_os_error());
150 }
151 Ok(())
152 });
153 }
154 }
155
156 let mut child = cmd
157 .spawn()
158 .map_err(|e| Error::Process(format!("failed to spawn {cli_label}: {e}")))?;
159
160 let child_pid = child.id();
161
162 let stdout = child.stdout.take().expect("stdout piped");
163 let stderr = child.stderr.take().expect("stderr piped");
164
165 let stderr_handle = tokio::spawn(async move {
166 let mut reader = BufReader::new(stderr);
167 let mut buf = String::new();
168 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {}
169 buf
170 });
171
172 let mut reader = BufReader::new(stdout);
173 let mut line = String::new();
174 let mut total_bytes: usize = 0;
175
176 loop {
177 line.clear();
178 tokio::select! {
179 result = reader.read_line(&mut line) => {
180 match result {
181 Ok(0) => break,
182 Ok(n) => {
183 total_bytes += n;
184 if total_bytes > max_bytes {
185 warn!(cli = cli_label, total_bytes, max_bytes, "output exceeded max buffer size");
186 kill_process_group(&mut child, child_pid).await;
187 return Err(Error::Process(format!(
188 "output exceeded max buffer size ({max_bytes} bytes)"
189 )));
190 }
191 on_line(line.trim());
192 }
193 Err(e) => {
194 warn!(cli = cli_label, error = %e, "error reading stdout");
195 break;
196 }
197 }
198 }
199 _ = cancel.cancelled() => {
200 kill_process_group(&mut child, child_pid).await;
201 return Ok(SpawnOutcome::Cancelled);
202 }
203 }
204 }
205
206 let status = child.wait().await.map_err(Error::Io)?;
207 let exit_code = status.code();
213 #[cfg(unix)]
214 let signal = std::os::unix::process::ExitStatusExt::signal(&status);
215 #[cfg(not(unix))]
216 let signal: Option<i32> = None;
217 let stderr_text = stderr_handle.await.unwrap_or_default();
218
219 Ok(SpawnOutcome::Done {
220 exit_code,
221 signal,
222 stderr: if stderr_text.is_empty() {
223 None
224 } else {
225 Some(stderr_text)
226 },
227 })
228}
229
230pub(crate) fn describe_signal(signal: Option<i32>) -> Option<String> {
237 let sig = signal?;
238 Some(match sig {
239 2 => "The agent was interrupted (SIGINT).".to_string(),
240 6 => "The agent aborted (SIGABRT).".to_string(),
241 9 => "The agent was killed (SIGKILL), most often by the system reclaiming memory."
242 .to_string(),
243 11 => "The agent crashed (SIGSEGV).".to_string(),
244 15 => "The agent was terminated (SIGTERM).".to_string(),
245 other => format!("The agent was terminated by signal {other}."),
246 })
247}
248
249pub(crate) fn extract_error_message(stderr: Option<&str>) -> Option<String> {
253 let stderr = stderr?;
254 let msg = stderr
256 .lines()
257 .filter(|l| !l.is_empty())
258 .find(|l| {
259 let lower = l.to_lowercase();
260 lower.contains("error")
261 || lower.contains("limit")
262 || lower.contains("failed")
263 || lower.contains("denied")
264 || lower.contains("unauthorized")
265 })
266 .or_else(|| stderr.lines().rfind(|l| !l.is_empty()));
267 msg.map(|s| s.trim().to_string())
268}
269
270async fn kill_process_group(child: &mut tokio::process::Child, pid: Option<u32>) {
271 #[cfg(unix)]
272 {
273 if let Some(pid) = pid {
274 unsafe {
275 libc::killpg(pid as libc::pid_t, libc::SIGKILL);
276 }
277 }
278 }
279 let _ = child.kill().await;
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[cfg(unix)]
296 #[tokio::test]
297 async fn a_signalled_process_reports_the_signal_not_a_fabricated_exit_code() {
298 let args = vec!["-c".to_string(), "kill -9 $$".to_string()];
299 let cancel = tokio_util::sync::CancellationToken::new();
300 let outcome = spawn_and_stream(
301 SpawnParams {
302 cli_label: "test",
303 binary: "sh",
304 args: &args,
305 extra_env: &HashMap::new(),
306 strip_env: &[],
307 cwd: ".",
308 max_bytes: 1024,
309 cancel: &cancel,
310 },
311 |_| {},
312 )
313 .await
314 .expect("spawn should succeed");
315
316 match outcome {
317 SpawnOutcome::Done {
318 exit_code, signal, ..
319 } => {
320 assert_eq!(exit_code, None, "a signalled process has no exit code");
321 assert_eq!(signal, Some(9), "SIGKILL should be reported as itself");
322 }
323 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
324 }
325 }
326
327 #[tokio::test]
330 async fn a_normal_exit_still_reports_its_code() {
331 let args = vec!["-c".to_string(), "exit 3".to_string()];
332 let cancel = tokio_util::sync::CancellationToken::new();
333 let outcome = spawn_and_stream(
334 SpawnParams {
335 cli_label: "test",
336 binary: "sh",
337 args: &args,
338 extra_env: &HashMap::new(),
339 strip_env: &[],
340 cwd: ".",
341 max_bytes: 1024,
342 cancel: &cancel,
343 },
344 |_| {},
345 )
346 .await
347 .expect("spawn should succeed");
348
349 match outcome {
350 SpawnOutcome::Done {
351 exit_code, signal, ..
352 } => {
353 assert_eq!(exit_code, Some(3));
354 assert_eq!(signal, None, "an ordinary exit was not signalled");
355 }
356 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
357 }
358 }
359
360 #[test]
362 fn describe_signal_names_the_common_kills() {
363 assert!(describe_signal(Some(9)).unwrap().contains("SIGKILL"));
364 assert!(describe_signal(Some(9)).unwrap().contains("memory"));
365 assert!(describe_signal(Some(15)).unwrap().contains("SIGTERM"));
366 assert!(describe_signal(Some(42)).unwrap().contains("42"));
367 assert_eq!(describe_signal(None), None);
368 }
369}