1use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::time::Duration;
13
14use tokio::process::Command;
15use tokio::task::JoinSet;
16use tokio::time::timeout;
17
18use crate::traits::AgentCapabilities;
19
20pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_millis(3000);
24
25#[derive(Debug, Clone)]
30pub struct InvocationTemplate {
31 pub args: &'static [&'static str],
34 pub prompt_via_stdin: bool,
37}
38
39#[derive(Debug, Clone)]
43pub struct AgentFingerprint {
44 pub id: &'static str,
46 pub binary_names: &'static [&'static str],
48 pub version_args: &'static [&'static str],
51 pub capabilities: AgentCapabilities,
54 pub invocation: InvocationTemplate,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum DiscoveryStatus {
62 Available,
64 Unavailable(String),
67}
68
69#[derive(Debug, Clone)]
71pub struct DiscoveredBinary {
72 pub agent_id: String,
73 pub binary_name: String,
76 pub path: PathBuf,
77 pub version: Option<String>,
79 pub status: DiscoveryStatus,
80 pub capabilities: AgentCapabilities,
81 pub invocation: InvocationTemplate,
82 pub version_args: Vec<String>,
85}
86
87impl DiscoveredBinary {
88 pub fn is_available(&self) -> bool {
89 matches!(self.status, DiscoveryStatus::Available)
90 }
91}
92
93#[derive(Debug, Clone)]
97pub struct PathScanner {
98 dirs: Vec<PathBuf>,
99}
100
101impl PathScanner {
102 pub fn from_env() -> Self {
105 let dirs = std::env::var_os("PATH")
106 .map(|p| std::env::split_paths(&p).collect())
107 .unwrap_or_default();
108 Self { dirs }
109 }
110
111 pub fn from_dirs(dirs: Vec<PathBuf>) -> Self {
112 Self { dirs }
113 }
114
115 pub fn dirs(&self) -> &[PathBuf] {
116 &self.dirs
117 }
118
119 pub fn find_first(&self, name: &str) -> Option<PathBuf> {
122 for dir in &self.dirs {
123 let candidate = dir.join(name);
124 if is_executable(&candidate) {
125 return Some(candidate);
126 }
127 }
128 None
129 }
130}
131
132#[cfg(unix)]
133fn is_executable(path: &Path) -> bool {
134 use std::os::unix::fs::PermissionsExt;
135 match std::fs::metadata(path) {
136 Ok(m) => m.is_file() && m.permissions().mode() & 0o111 != 0,
137 Err(_) => false,
138 }
139}
140
141#[cfg(not(unix))]
142fn is_executable(path: &Path) -> bool {
143 std::fs::metadata(path)
144 .map(|m| m.is_file())
145 .unwrap_or(false)
146}
147
148#[derive(Debug, Clone)]
151pub struct DelegateDiscovery {
152 scanner: PathScanner,
153 fingerprints: Vec<AgentFingerprint>,
154 probe_timeout: Duration,
155}
156
157impl DelegateDiscovery {
158 pub fn new() -> Self {
161 Self {
162 scanner: PathScanner::from_env(),
163 fingerprints: default_fingerprints(),
164 probe_timeout: DEFAULT_PROBE_TIMEOUT,
165 }
166 }
167
168 pub fn with_scanner(mut self, scanner: PathScanner) -> Self {
169 self.scanner = scanner;
170 self
171 }
172
173 pub fn with_fingerprints(mut self, fps: Vec<AgentFingerprint>) -> Self {
174 self.fingerprints = fps;
175 self
176 }
177
178 pub fn with_probe_timeout(mut self, d: Duration) -> Self {
179 self.probe_timeout = d;
180 self
181 }
182
183 pub fn fingerprints(&self) -> &[AgentFingerprint] {
184 &self.fingerprints
185 }
186
187 pub async fn discover(&self) -> Vec<DiscoveredBinary> {
193 let mut found: Vec<(AgentFingerprint, String, PathBuf)> = Vec::new();
194 let mut seen_paths: HashSet<PathBuf> = HashSet::new();
195
196 for fp in &self.fingerprints {
197 for name in fp.binary_names {
198 if let Some(path) = self.scanner.find_first(name) {
199 if seen_paths.insert(path.clone()) {
203 found.push((fp.clone(), (*name).to_string(), path));
204 }
205 break;
206 }
207 }
208 }
209
210 let mut set: JoinSet<DiscoveredBinary> = JoinSet::new();
211 let probe_timeout = self.probe_timeout;
212 for (fp, binary_name, path) in found {
213 let version_args: Vec<String> = fp.version_args.iter().map(|s| s.to_string()).collect();
214 set.spawn(async move {
215 let (status, version) = probe(&path, &version_args, probe_timeout).await;
216 DiscoveredBinary {
217 agent_id: fp.id.to_string(),
218 binary_name,
219 path,
220 version,
221 status,
222 capabilities: fp.capabilities,
223 invocation: fp.invocation,
224 version_args,
225 }
226 });
227 }
228
229 let mut out = Vec::new();
230 while let Some(res) = set.join_next().await {
231 match res {
232 Ok(b) => out.push(b),
233 Err(e) => tracing::warn!(error = %e, "discovery probe task panicked"),
234 }
235 }
236 out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
237 out
238 }
239}
240
241impl Default for DelegateDiscovery {
242 fn default() -> Self {
243 Self::new()
244 }
245}
246
247async fn probe(
248 path: &Path,
249 version_args: &[String],
250 budget: Duration,
251) -> (DiscoveryStatus, Option<String>) {
252 let mut attempt = 0u32;
258 loop {
259 let fut = Command::new(path)
260 .args(version_args)
261 .stdin(Stdio::null())
262 .stdout(Stdio::piped())
263 .stderr(Stdio::piped())
264 .kill_on_drop(true)
265 .output();
266
267 match timeout(budget, fut).await {
268 Ok(Ok(out)) if out.status.success() => {
269 let text = String::from_utf8_lossy(&out.stdout);
270 let v = text
271 .lines()
272 .map(str::trim)
273 .find(|l| !l.is_empty())
274 .map(|l| l.to_string());
275 return (DiscoveryStatus::Available, v);
276 }
277 Ok(Ok(out)) => {
278 let code = out.status.code().unwrap_or(-1);
279 let stderr = String::from_utf8_lossy(&out.stderr);
280 let msg = stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
281 let reason = if msg.is_empty() {
282 format!("version probe exited {code}")
283 } else {
284 format!("version probe exited {code}: {}", msg.trim())
285 };
286 return (DiscoveryStatus::Unavailable(reason), None);
287 }
288 Ok(Err(e)) if e.raw_os_error() == Some(26) && attempt < 4 => {
289 tokio::time::sleep(Duration::from_millis(20 * (attempt as u64 + 1))).await;
290 attempt += 1;
291 continue;
292 }
293 Ok(Err(e)) => {
294 return (
295 DiscoveryStatus::Unavailable(format!("probe spawn failed: {e}")),
296 None,
297 );
298 }
299 Err(_) => {
300 return (
301 DiscoveryStatus::Unavailable(format!("probe timed out after {budget:?}")),
302 None,
303 );
304 }
305 }
306 }
307}
308
309pub fn default_fingerprints() -> Vec<AgentFingerprint> {
312 vec![
313 AgentFingerprint {
314 id: "claude-code",
315 binary_names: &["claude", "claude-code"],
316 version_args: &["--version"],
317 capabilities: AgentCapabilities {
318 tags: vec!["code-edit".to_string(), "plan".to_string()],
319 languages: vec!["rust".to_string(), "typescript".to_string()],
320 max_concurrency: 1,
321 needs_network: true,
322 },
323 invocation: InvocationTemplate {
325 args: &["-p", "-"],
326 prompt_via_stdin: true,
327 },
328 },
329 AgentFingerprint {
330 id: "codex",
331 binary_names: &["codex", "codex-cli"],
332 version_args: &["--version"],
333 capabilities: AgentCapabilities {
334 tags: vec!["code-edit".to_string()],
335 languages: vec![],
336 max_concurrency: 1,
337 needs_network: true,
338 },
339 invocation: InvocationTemplate {
341 args: &["exec", "-"],
342 prompt_via_stdin: true,
343 },
344 },
345 AgentFingerprint {
346 id: "aider",
347 binary_names: &["aider"],
348 version_args: &["--version"],
349 capabilities: AgentCapabilities {
350 tags: vec!["code-edit".to_string()],
351 languages: vec![],
352 max_concurrency: 1,
353 needs_network: true,
354 },
355 invocation: InvocationTemplate {
357 args: &["--yes", "--no-stream", "--message", "{prompt}"],
358 prompt_via_stdin: false,
359 },
360 },
361 AgentFingerprint {
362 id: "qwen-code",
363 binary_names: &["qwen", "qwen-code"],
364 version_args: &["--version"],
365 capabilities: AgentCapabilities {
366 tags: vec!["code-edit".to_string()],
367 languages: vec![],
368 max_concurrency: 1,
369 needs_network: true,
370 },
371 invocation: InvocationTemplate {
373 args: &["-p", "-"],
374 prompt_via_stdin: true,
375 },
376 },
377 AgentFingerprint {
378 id: "gemini-cli",
379 binary_names: &["gemini"],
380 version_args: &["--version"],
381 capabilities: AgentCapabilities {
382 tags: vec!["code-edit".to_string()],
383 languages: vec![],
384 max_concurrency: 1,
385 needs_network: true,
386 },
387 invocation: InvocationTemplate {
388 args: &["-p", "-"],
389 prompt_via_stdin: true,
390 },
391 },
392 AgentFingerprint {
393 id: "opencode",
394 binary_names: &["opencode"],
395 version_args: &["--version"],
396 capabilities: AgentCapabilities {
397 tags: vec!["code-edit".to_string()],
398 languages: vec![],
399 max_concurrency: 1,
400 needs_network: true,
401 },
402 invocation: InvocationTemplate {
403 args: &["run", "-"],
404 prompt_via_stdin: true,
405 },
406 },
407 ]
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn path_scanner_finds_executable() {
416 let dir = tempfile::tempdir().unwrap();
419 let bin = dir.path().join("fakeagent");
420 std::fs::write(&bin, "#!/bin/sh\necho ok\n").unwrap();
421 #[cfg(unix)]
422 {
423 use std::os::unix::fs::PermissionsExt;
424 let mut p = std::fs::metadata(&bin).unwrap().permissions();
425 p.set_mode(0o755);
426 std::fs::set_permissions(&bin, p).unwrap();
427 }
428 let scanner = PathScanner::from_dirs(vec![dir.path().to_path_buf()]);
429 assert_eq!(scanner.find_first("fakeagent"), Some(bin));
430 assert!(scanner.find_first("nope").is_none());
431 }
432
433 #[cfg(unix)]
434 #[test]
435 fn path_scanner_skips_non_executable_files() {
436 let dir = tempfile::tempdir().unwrap();
437 let bin = dir.path().join("not-exec");
438 std::fs::write(&bin, "hi").unwrap();
439 let scanner = PathScanner::from_dirs(vec![dir.path().to_path_buf()]);
440 assert!(scanner.find_first("not-exec").is_none());
441 }
442
443 #[test]
444 fn default_fingerprints_are_non_empty_and_unique() {
445 let fps = default_fingerprints();
446 assert!(!fps.is_empty());
447 let ids: HashSet<_> = fps.iter().map(|f| f.id).collect();
448 assert_eq!(ids.len(), fps.len(), "fingerprint ids must be unique");
449 for fp in &fps {
450 assert!(!fp.binary_names.is_empty(), "{} has no binary names", fp.id);
451 assert!(!fp.version_args.is_empty(), "{} has no version args", fp.id);
452 }
453 }
454
455 #[cfg(unix)]
456 #[tokio::test]
457 async fn discover_reports_available_for_working_probe() {
458 let dir = tempfile::tempdir().unwrap();
459 let bin = dir.path().join("faux-claude");
460 std::fs::write(&bin, "#!/bin/sh\necho 'faux-claude 1.2.3'\n").unwrap();
461 use std::os::unix::fs::PermissionsExt;
462 let mut p = std::fs::metadata(&bin).unwrap().permissions();
463 p.set_mode(0o755);
464 std::fs::set_permissions(&bin, p).unwrap();
465
466 let fp = AgentFingerprint {
467 id: "faux",
468 binary_names: &["faux-claude"],
469 version_args: &["--version"],
470 capabilities: AgentCapabilities::default(),
471 invocation: InvocationTemplate {
472 args: &[],
473 prompt_via_stdin: true,
474 },
475 };
476 let discovery = DelegateDiscovery::new()
477 .with_scanner(PathScanner::from_dirs(vec![dir.path().to_path_buf()]))
478 .with_fingerprints(vec![fp])
479 .with_probe_timeout(Duration::from_secs(2));
480 let results = discovery.discover().await;
481 assert_eq!(results.len(), 1);
482 assert_eq!(results[0].agent_id, "faux");
483 assert_eq!(results[0].status, DiscoveryStatus::Available);
484 assert_eq!(results[0].version.as_deref(), Some("faux-claude 1.2.3"));
485 }
486
487 #[cfg(unix)]
488 #[tokio::test]
489 async fn discover_marks_failing_probe_unavailable() {
490 let dir = tempfile::tempdir().unwrap();
491 let bin = dir.path().join("broken-agent");
492 std::fs::write(&bin, "#!/bin/sh\necho 'bad happened' 1>&2\nexit 2\n").unwrap();
493 use std::os::unix::fs::PermissionsExt;
494 let mut p = std::fs::metadata(&bin).unwrap().permissions();
495 p.set_mode(0o755);
496 std::fs::set_permissions(&bin, p).unwrap();
497
498 let fp = AgentFingerprint {
499 id: "broken",
500 binary_names: &["broken-agent"],
501 version_args: &["--version"],
502 capabilities: AgentCapabilities::default(),
503 invocation: InvocationTemplate {
504 args: &[],
505 prompt_via_stdin: true,
506 },
507 };
508 let discovery = DelegateDiscovery::new()
509 .with_scanner(PathScanner::from_dirs(vec![dir.path().to_path_buf()]))
510 .with_fingerprints(vec![fp])
511 .with_probe_timeout(Duration::from_secs(2));
512 let results = discovery.discover().await;
513 assert_eq!(results.len(), 1);
514 match &results[0].status {
515 DiscoveryStatus::Unavailable(reason) => {
516 assert!(reason.contains("exited 2"), "reason was {reason}");
517 }
518 other => panic!("expected Unavailable, got {other:?}"),
519 }
520 assert!(results[0].version.is_none());
521 }
522
523 #[cfg(unix)]
524 #[tokio::test]
525 async fn discover_times_out_hung_probe() {
526 let dir = tempfile::tempdir().unwrap();
527 let bin = dir.path().join("hang-agent");
528 std::fs::write(&bin, "#!/bin/sh\nsleep 5\n").unwrap();
529 use std::os::unix::fs::PermissionsExt;
530 let mut p = std::fs::metadata(&bin).unwrap().permissions();
531 p.set_mode(0o755);
532 std::fs::set_permissions(&bin, p).unwrap();
533
534 let fp = AgentFingerprint {
535 id: "hang",
536 binary_names: &["hang-agent"],
537 version_args: &["--version"],
538 capabilities: AgentCapabilities::default(),
539 invocation: InvocationTemplate {
540 args: &[],
541 prompt_via_stdin: true,
542 },
543 };
544 let discovery = DelegateDiscovery::new()
545 .with_scanner(PathScanner::from_dirs(vec![dir.path().to_path_buf()]))
546 .with_fingerprints(vec![fp])
547 .with_probe_timeout(Duration::from_millis(100));
548 let results = discovery.discover().await;
549 assert_eq!(results.len(), 1);
550 match &results[0].status {
551 DiscoveryStatus::Unavailable(reason) => {
552 assert!(reason.contains("timed out"), "reason was {reason}");
553 }
554 other => panic!("expected Unavailable, got {other:?}"),
555 }
556 }
557
558 #[cfg(unix)]
559 #[tokio::test]
560 async fn discover_dedupes_when_two_fingerprints_hit_same_file() {
561 let dir = tempfile::tempdir().unwrap();
564 let bin = dir.path().join("shared");
565 std::fs::write(&bin, "#!/bin/sh\necho 'shared 1.0'\n").unwrap();
566 use std::os::unix::fs::PermissionsExt;
567 let mut p = std::fs::metadata(&bin).unwrap().permissions();
568 p.set_mode(0o755);
569 std::fs::set_permissions(&bin, p).unwrap();
570
571 let fp_a = AgentFingerprint {
572 id: "alpha",
573 binary_names: &["shared"],
574 version_args: &["--version"],
575 capabilities: AgentCapabilities::default(),
576 invocation: InvocationTemplate {
577 args: &[],
578 prompt_via_stdin: true,
579 },
580 };
581 let fp_b = AgentFingerprint {
582 id: "beta",
583 binary_names: &["shared"],
584 version_args: &["--version"],
585 capabilities: AgentCapabilities::default(),
586 invocation: InvocationTemplate {
587 args: &[],
588 prompt_via_stdin: true,
589 },
590 };
591 let discovery = DelegateDiscovery::new()
592 .with_scanner(PathScanner::from_dirs(vec![dir.path().to_path_buf()]))
593 .with_fingerprints(vec![fp_a, fp_b])
594 .with_probe_timeout(Duration::from_secs(2));
595 let results = discovery.discover().await;
596 assert_eq!(results.len(), 1);
597 assert_eq!(results[0].agent_id, "alpha");
598 }
599}