1use clap::{error::ErrorKind, ArgAction, Parser, ValueEnum};
2use serde::Serialize;
3use serde_json::Value;
4use std::collections::{HashMap, HashSet};
5use std::ffi::OsString;
6use std::fs;
7use std::io::{self, IsTerminal, Read, Write};
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::sync::{mpsc, Arc, Mutex};
11use std::thread;
12use std::time::{Duration, Instant};
13
14mod linear_delivery;
15mod studio;
16
17const VERSION: &str = env!("CARGO_PKG_VERSION");
18const DEFAULT_TIMEOUT_MS: u64 = 600_000;
19const DEFAULT_MAX_MEMBER_CHARS: usize = 12_000;
20const DEFAULT_ITERATIONS: usize = 1;
21const DEFAULT_TEAM_SIZE: usize = 0;
22const TOKEN_ESTIMATE_CHARS_PER_TOKEN: usize = 4;
23
24const ENGINES: [&str; 3] = ["codex", "claude", "gemini"];
25const DEFAULT_SUMMARIZER_ORDER: [&str; 3] = ["codex", "claude", "gemini"];
26const DEFAULT_AUTH_MODE: &str = "auto";
27const CAPABILITY_INHERIT: &str = "inherit";
28const CAPABILITY_OVERRIDE: &str = "override";
29
30#[derive(Debug, Copy, Clone, Eq, PartialEq)]
31enum Engine {
32 Codex,
33 Claude,
34 Gemini,
35}
36
37impl Engine {
38 fn parse(name: &str) -> Option<Self> {
39 match name {
40 "codex" => Some(Self::Codex),
41 "claude" => Some(Self::Claude),
42 "gemini" => Some(Self::Gemini),
43 _ => None,
44 }
45 }
46
47 fn as_str(self) -> &'static str {
48 match self {
49 Self::Codex => "codex",
50 Self::Claude => "claude",
51 Self::Gemini => "gemini",
52 }
53 }
54
55 fn binary_env_var(self) -> &'static str {
56 match self {
57 Self::Codex => "AMON_HEN_CODEX_BIN",
58 Self::Claude => "AMON_HEN_CLAUDE_BIN",
59 Self::Gemini => "AMON_HEN_GEMINI_BIN",
60 }
61 }
62
63 fn allowed_efforts(self) -> &'static [&'static str] {
64 match self {
65 Self::Codex => &["low", "medium", "high", "xhigh", ""],
66 Self::Claude => &["low", "medium", "high", "xhigh", "max"],
67 Self::Gemini => &["low", "medium", "high", ""],
68 }
69 }
70}
71
72#[derive(Parser, Debug, Clone)]
73#[command(
74 name = "amon-hen",
75 version,
76 about = "Ask multiple AI CLIs the same question, then synthesize their answers.",
77 trailing_var_arg = true,
78 disable_version_flag = true
79)]
80pub struct CliArgs {
81 #[arg(short = 'v', long = "version", action = ArgAction::SetTrue)]
82 version: bool,
83
84 #[arg(long, action = ArgAction::SetTrue)]
85 json: bool,
86
87 #[arg(long = "json-stream", alias = "ndjson", action = ArgAction::SetTrue)]
88 json_stream: bool,
89
90 #[arg(long, action = ArgAction::SetTrue)]
91 headless: bool,
92
93 #[arg(long, action = ArgAction::SetTrue)]
94 studio: bool,
95
96 #[arg(long, action = ArgAction::SetTrue)]
97 plain: bool,
98
99 #[arg(long = "no-banner", action = ArgAction::SetTrue)]
100 no_banner: bool,
101 #[arg(long = "banner", hide = true, action = ArgAction::SetTrue)]
102 banner: bool,
103
104 #[arg(short = 'q', long = "quiet", alias = "summary-only", action = ArgAction::SetTrue)]
105 summary_only: bool,
106
107 #[arg(short = 'd', long, action = ArgAction::SetTrue)]
108 verbose: bool,
109
110 #[arg(long, action = ArgAction::SetTrue)]
111 all: bool,
112
113 #[arg(long, action = ArgAction::SetTrue)]
114 codex: bool,
115 #[arg(long = "no-codex", action = ArgAction::SetTrue)]
116 no_codex: bool,
117 #[arg(long, action = ArgAction::SetTrue)]
118 claude: bool,
119 #[arg(long = "no-claude", action = ArgAction::SetTrue)]
120 no_claude: bool,
121 #[arg(long, action = ArgAction::SetTrue)]
122 gemini: bool,
123 #[arg(long = "no-gemini", action = ArgAction::SetTrue)]
124 no_gemini: bool,
125
126 #[arg(long, value_delimiter = ',')]
127 members: Vec<String>,
128
129 #[arg(long, default_value = "auto")]
130 summarizer: String,
131
132 #[arg(long, value_enum)]
133 effort: Option<Effort>,
134
135 #[arg(long = "codex-model")]
136 codex_model: Option<String>,
137 #[arg(long = "claude-model")]
138 claude_model: Option<String>,
139 #[arg(long = "gemini-model")]
140 gemini_model: Option<String>,
141
142 #[arg(long = "codex-effort")]
143 codex_effort: Option<String>,
144 #[arg(long = "claude-effort")]
145 claude_effort: Option<String>,
146 #[arg(long = "gemini-effort")]
147 gemini_effort: Option<String>,
148
149 #[arg(long = "codex-sandbox", default_value = "read-only")]
150 codex_sandbox: String,
151 #[arg(long = "claude-permission-mode", default_value = "plan")]
152 claude_permission_mode: String,
153
154 #[arg(long = "codex-auth", default_value = "auto")]
155 codex_auth: String,
156 #[arg(long = "claude-auth", default_value = "auto")]
157 claude_auth: String,
158 #[arg(long = "gemini-auth", default_value = "auto")]
159 gemini_auth: String,
160
161 #[arg(long = "codex-capabilities", default_value = "inherit")]
162 codex_capabilities: String,
163 #[arg(long = "claude-capabilities", default_value = "inherit")]
164 claude_capabilities: String,
165 #[arg(long = "gemini-capabilities", default_value = "inherit")]
166 gemini_capabilities: String,
167
168 #[arg(long = "codex-config", action = ArgAction::Append)]
169 codex_config: Vec<String>,
170 #[arg(long = "codex-mcp-profile")]
171 codex_mcp_profile: Option<String>,
172 #[arg(long = "claude-mcp-config", action = ArgAction::Append)]
173 claude_mcp_config: Vec<String>,
174 #[arg(long = "claude-allowed-tools", value_delimiter = ',', action = ArgAction::Append)]
175 claude_allowed_tools: Vec<String>,
176 #[arg(long = "claude-disallowed-tools", value_delimiter = ',', action = ArgAction::Append)]
177 claude_disallowed_tools: Vec<String>,
178 #[arg(long = "claude-tools", value_delimiter = ',', action = ArgAction::Append)]
179 claude_tools: Vec<String>,
180 #[arg(long = "claude-agent")]
181 claude_agent: Option<String>,
182 #[arg(long = "claude-agents-json")]
183 claude_agents_json: Option<String>,
184 #[arg(long = "claude-plugin-dir", action = ArgAction::Append)]
185 claude_plugin_dir: Vec<String>,
186 #[arg(long = "claude-strict-mcp-config", action = ArgAction::SetTrue)]
187 claude_strict_mcp_config: bool,
188 #[arg(long = "claude-disable-slash-commands", action = ArgAction::SetTrue)]
189 claude_disable_slash_commands: bool,
190 #[arg(long = "gemini-settings")]
191 gemini_settings: Option<String>,
192 #[arg(long = "gemini-tools-profile", value_delimiter = ',', action = ArgAction::Append)]
193 gemini_tools_profile: Vec<String>,
194 #[arg(long = "gemini-allowed-mcp-servers", value_delimiter = ',', action = ArgAction::Append)]
195 gemini_allowed_mcp_servers: Vec<String>,
196 #[arg(long = "gemini-policy", value_delimiter = ',', action = ArgAction::Append)]
197 gemini_policy: Vec<String>,
198 #[arg(long = "gemini-admin-policy", value_delimiter = ',', action = ArgAction::Append)]
199 gemini_admin_policy: Vec<String>,
200 #[arg(long = "capabilities-status", action = ArgAction::SetTrue)]
201 capabilities_status: bool,
202
203 #[arg(long = "auth-login", action = ArgAction::SetTrue)]
204 auth_login: bool,
205 #[arg(long = "auth-status", action = ArgAction::SetTrue)]
206 auth_status: bool,
207 #[arg(long = "auth-login-providers", value_delimiter = ',')]
208 auth_login_providers: Vec<String>,
209 #[arg(long = "auth-device-code", action = ArgAction::SetTrue)]
210 auth_device_code: bool,
211 #[arg(long = "no-auth-open-browser", action = ArgAction::SetTrue)]
212 no_auth_open_browser: bool,
213 #[arg(long = "auth-open-browser", hide = true, action = ArgAction::SetTrue)]
214 auth_open_browser: bool,
215 #[arg(long = "auth-timeout", default_value_t = 300)]
216 auth_timeout: u64,
217 #[arg(long = "claude-login-mode", default_value = "claudeai")]
218 claude_login_mode: String,
219 #[arg(long = "claude-login-email")]
220 claude_login_email: Option<String>,
221
222 #[arg(long = "file", alias = "tag-file", action = ArgAction::Append)]
223 files: Vec<PathBuf>,
224 #[arg(long = "cmd", alias = "prompt-command", action = ArgAction::Append)]
225 commands: Vec<String>,
226
227 #[arg(long, action = ArgAction::SetTrue)]
228 handoff: bool,
229 #[arg(long)]
230 lead: Option<String>,
231 #[arg(long)]
232 planner: Option<String>,
233 #[arg(long, default_value_t = DEFAULT_ITERATIONS)]
234 iterations: usize,
235 #[arg(long = "team-work", alias = "teamwork", alias = "sub-agents", default_value_t = DEFAULT_TEAM_SIZE)]
236 team_work: usize,
237 #[arg(long = "codex-sub-agents")]
238 codex_sub_agents: Option<usize>,
239 #[arg(long = "claude-sub-agents")]
240 claude_sub_agents: Option<usize>,
241 #[arg(long = "gemini-sub-agents")]
242 gemini_sub_agents: Option<usize>,
243
244 #[arg(long = "deliver-linear", alias = "linear", action = ArgAction::SetTrue)]
245 deliver_linear: bool,
246 #[arg(long = "linear-setup", action = ArgAction::SetTrue)]
247 linear_setup: bool,
248 #[arg(long = "linear-status", action = ArgAction::SetTrue)]
249 linear_status: bool,
250 #[arg(long = "linear-watch", action = ArgAction::SetTrue)]
251 linear_watch: bool,
252 #[arg(long = "linear-until-complete", action = ArgAction::SetTrue)]
253 linear_until_complete: bool,
254 #[arg(long = "linear-issue", value_delimiter = ',', action = ArgAction::Append)]
255 linear_issue: Vec<String>,
256 #[arg(long = "linear-query")]
257 linear_query: Option<String>,
258 #[arg(long = "linear-project", value_delimiter = ',', action = ArgAction::Append)]
259 linear_project: Vec<String>,
260 #[arg(long = "linear-epic", value_delimiter = ',', action = ArgAction::Append)]
261 linear_epic: Vec<String>,
262 #[arg(long = "linear-team")]
263 linear_team: Option<String>,
264 #[arg(long = "linear-state")]
265 linear_state: Option<String>,
266 #[arg(long = "linear-assignee")]
267 linear_assignee: Option<String>,
268 #[arg(long = "linear-limit", default_value_t = 3)]
269 linear_limit: usize,
270 #[arg(long = "linear-endpoint")]
271 linear_endpoint: Option<String>,
272 #[arg(long = "linear-auth", default_value = "api-key")]
273 linear_auth: String,
274 #[arg(long = "linear-api-key-env", default_value = "LINEAR_API_KEY")]
275 linear_api_key_env: String,
276 #[arg(long = "linear-oauth-token-env", default_value = "LINEAR_OAUTH_TOKEN")]
277 linear_oauth_token_env: String,
278 #[arg(long = "linear-completion-gate", default_value = "delivered")]
279 linear_completion_gate: String,
280 #[arg(long = "linear-review-state")]
281 linear_review_state: Option<String>,
282 #[arg(long = "linear-ci-timeout", default_value_t = 900)]
283 linear_ci_timeout: u64,
284 #[arg(long = "linear-ci-poll-interval", default_value_t = 30)]
285 linear_ci_poll_interval: u64,
286 #[arg(long = "linear-poll-interval", default_value_t = 60)]
287 linear_poll_interval: u64,
288 #[arg(long = "linear-max-polls")]
289 linear_max_polls: Option<usize>,
290 #[arg(long = "linear-max-concurrency", default_value_t = 1)]
291 linear_max_concurrency: usize,
292 #[arg(long = "linear-max-attempts", default_value_t = 3)]
293 linear_max_attempts: usize,
294 #[arg(long = "linear-retry-base", default_value_t = 60)]
295 linear_retry_base: u64,
296 #[arg(long = "linear-workspace-strategy", default_value = "worktree")]
297 linear_workspace_strategy: String,
298 #[arg(long = "linear-state-file")]
299 linear_state_file: Option<PathBuf>,
300 #[arg(long = "linear-workspace-root")]
301 linear_workspace_root: Option<PathBuf>,
302 #[arg(long = "linear-observability-dir")]
303 linear_observability_dir: Option<PathBuf>,
304 #[arg(long = "linear-workflow-file")]
305 linear_workflow_file: Option<PathBuf>,
306 #[arg(long = "linear-attach-media", value_delimiter = ',', action = ArgAction::Append)]
307 linear_attach_media: Vec<String>,
308 #[arg(long = "linear-attachment-title")]
309 linear_attachment_title: Option<String>,
310 #[arg(long = "no-linear-comments", action = ArgAction::SetTrue)]
311 no_linear_comments: bool,
312 #[arg(long = "linear-update-review-state", action = ArgAction::SetTrue)]
313 linear_update_review_state: bool,
314 #[arg(long = "delivery-phases", value_delimiter = ',')]
315 delivery_phases: Vec<String>,
316
317 #[arg(long, default_value_t = DEFAULT_TIMEOUT_MS / 1000)]
318 timeout: u64,
319 #[arg(long = "max-member-chars", default_value_t = DEFAULT_MAX_MEMBER_CHARS)]
320 max_member_chars: usize,
321 #[arg(long, default_value = ".")]
322 cwd: PathBuf,
323 #[arg(long, default_value = "auto")]
324 color: String,
325 #[arg(long = "no-color", action = ArgAction::SetTrue)]
326 no_color: bool,
327
328 #[arg(value_name = "QUERY")]
329 prompt: Vec<String>,
330}
331
332#[derive(Copy, Clone, Debug, ValueEnum)]
333enum Effort {
334 Low,
335 Medium,
336 High,
337}
338
339impl Effort {
340 fn as_str(self) -> &'static str {
341 match self {
342 Effort::Low => "low",
343 Effort::Medium => "medium",
344 Effort::High => "high",
345 }
346 }
347}
348
349#[derive(Debug, Clone)]
350struct ResolvedArgs {
351 raw: CliArgs,
352 members: Vec<String>,
353 prompt: String,
354 cwd: PathBuf,
355}
356
357#[derive(Debug, Clone, Serialize)]
358pub struct AmonHenResult {
359 query: String,
360 cwd: String,
361 members_requested: Vec<String>,
362 summarizer_requested: String,
363 workflow: Workflow,
364 prompt_commands: Vec<CommandTelemetry>,
365 members: Vec<EngineResult>,
366 summary: EngineResult,
367}
368
369#[derive(Debug, Clone, Serialize)]
370struct Workflow {
371 handoff: bool,
372 lead: Option<String>,
373 planner: Option<String>,
374 iterations: usize,
375 team_work: usize,
376 teams: HashMap<String, usize>,
377}
378
379#[derive(Debug, Clone, Serialize)]
380pub struct EngineResult {
381 name: String,
382 bin: Option<String>,
383 status: String,
384 duration_ms: u128,
385 detail: String,
386 exit_code: Option<i32>,
387 stdout: String,
388 stderr: String,
389 output: String,
390 command: String,
391 token_usage: TokenUsage,
392 tool_calls: Vec<ToolUsage>,
393 sub_agents: Vec<EngineResult>,
394 role: String,
395 iteration: usize,
396 total_iterations: usize,
397 team_size: usize,
398}
399
400#[derive(Debug, Clone, Serialize)]
401struct TokenUsage {
402 input: usize,
403 output: usize,
404 total: usize,
405 estimated: bool,
406 source: String,
407}
408
409#[derive(Debug, Clone, Serialize)]
410struct ToolUsage {
411 name: String,
412 kind: String,
413 status: String,
414 detail: String,
415}
416
417#[derive(Debug, Clone, Serialize)]
418struct CommandTelemetry {
419 command: String,
420 status: String,
421 detail: String,
422 exit_code: Option<i32>,
423 duration_ms: u128,
424 stdout_chars: usize,
425 stderr_chars: usize,
426 timed_out: bool,
427}
428
429#[derive(Debug, Copy, Clone, Eq, PartialEq)]
430enum ProgressStage {
431 Context,
432 Start,
433 Spawn,
434 Heartbeat,
435 Done,
436}
437
438#[derive(Debug, Clone)]
439struct ProgressEvent {
440 provider: Option<String>,
441 role: Option<String>,
442 stage: ProgressStage,
443 status: Option<String>,
444 message: String,
445}
446
447type ProgressSink = Arc<dyn Fn(ProgressEvent) + Send + Sync + 'static>;
448
449#[derive(Debug)]
450struct PromptContext {
451 prompt: String,
452 commands: Vec<CommandTelemetry>,
453}
454
455#[derive(Debug)]
456struct CommandResult {
457 command: String,
458 args: Vec<String>,
459 code: Option<i32>,
460 stdout: String,
461 stderr: String,
462 timed_out: bool,
463 error: Option<String>,
464 timeout_ms: u64,
465 duration_ms: u128,
466}
467
468#[derive(Clone)]
469struct CommandProgress {
470 label: String,
471 sink: Option<ProgressSink>,
472}
473
474#[derive(Clone)]
475struct EngineRunOptions {
476 prompt: String,
477 cwd: PathBuf,
478 timeout_ms: u64,
479 effort: Option<String>,
480 model: Option<String>,
481 permission: Option<String>,
482 auth: String,
483 capability: ProviderCapability,
484 role: String,
485 iteration: usize,
486 total_iterations: usize,
487 team_size: usize,
488 is_sub_agent: bool,
489 live: bool,
490 progress: Option<ProgressSink>,
491}
492
493#[derive(Debug, Clone)]
494struct ProviderCapability {
495 mode: String,
496 config: Vec<String>,
497 mcp_profile: Option<String>,
498 mcp_config: Vec<String>,
499 allowed_tools: Vec<String>,
500 disallowed_tools: Vec<String>,
501 tools: Vec<String>,
502 agent: Option<String>,
503 agents_json: Option<String>,
504 plugin_dirs: Vec<String>,
505 strict_mcp_config: bool,
506 disable_slash_commands: bool,
507 settings: Option<String>,
508 tools_profile: Vec<String>,
509 allowed_mcp_servers: Vec<String>,
510 policy: Vec<String>,
511 admin_policy: Vec<String>,
512}
513
514#[derive(Debug, Clone, Serialize)]
515struct ProviderAuthStatus {
516 provider: String,
517 configured: bool,
518 status: String,
519 source: String,
520 detail: String,
521}
522
523#[derive(Debug, Clone, Serialize)]
524struct ProviderCapabilityStatus {
525 provider: String,
526 mcp: CommandTelemetry,
527 skills: Option<CommandTelemetry>,
528 tools: Option<CommandTelemetry>,
529 detail: String,
530}
531
532struct TempSettings {
533 _dir: tempfile::TempDir,
534 path: PathBuf,
535}
536
537pub fn run_from_env() -> i32 {
538 let args: Vec<OsString> = std::env::args_os().skip(1).collect();
539 run_with_args(args)
540}
541
542pub fn run_with_args<I, T>(args: I) -> i32
543where
544 I: IntoIterator<Item = T>,
545 T: Into<OsString>,
546{
547 let raw_args: Vec<OsString> = args.into_iter().map(Into::into).collect();
548 if raw_args.first().and_then(|value| value.to_str()) == Some("help") {
549 println!("{}", render_cli_help());
550 return 0;
551 }
552
553 let mut parse_args = vec![OsString::from("amon-hen")];
554 parse_args.extend(raw_args);
555 let parsed = match CliArgs::try_parse_from(parse_args) {
556 Ok(parsed) => parsed,
557 Err(error) => {
558 if error.kind() == ErrorKind::DisplayHelp {
559 println!("{}", render_cli_help());
560 return 0;
561 }
562 if error.kind() == ErrorKind::DisplayVersion {
563 println!("{VERSION}");
564 return 0;
565 }
566 let exit_code = parse_error_exit_code(error.kind());
567 let _ = error.print();
568 return exit_code;
569 }
570 };
571
572 if parsed.version {
573 println!("{VERSION}");
574 return 0;
575 }
576
577 let resolved = match resolve_args(parsed) {
578 Ok(resolved) => resolved,
579 Err(error) => {
580 eprintln!("{error}");
581 return 64;
582 }
583 };
584
585 if resolved.raw.studio {
586 return studio::run_studio(&resolved);
587 }
588
589 if resolved.raw.auth_status {
590 println!(
591 "{}",
592 render_auth_statuses(&collect_auth_statuses(&resolved))
593 );
594 if resolved.prompt.trim().is_empty()
595 && !resolved.raw.auth_login
596 && !resolved.raw.deliver_linear
597 && !resolved.raw.capabilities_status
598 {
599 return 0;
600 }
601 }
602
603 if resolved.raw.capabilities_status {
604 println!(
605 "{}",
606 render_provider_capability_statuses(&collect_provider_capability_statuses(&resolved))
607 );
608 if resolved.prompt.trim().is_empty()
609 && !resolved.raw.auth_login
610 && !resolved.raw.deliver_linear
611 {
612 return 0;
613 }
614 }
615
616 if resolved.raw.auth_login {
617 if let Err(error) = run_social_login(&resolved) {
618 eprintln!("{error}");
619 return 1;
620 }
621 if resolved.prompt.trim().is_empty() && !resolved.raw.deliver_linear {
622 return 0;
623 }
624 }
625
626 if resolved.raw.linear_setup || resolved.raw.linear_status {
627 match linear_delivery::get_linear_status(&resolved) {
628 Ok(status) => {
629 println!("{}", linear_delivery::render_linear_status(&status));
630 return 0;
631 }
632 Err(error) => {
633 eprintln!("{error}");
634 return 1;
635 }
636 }
637 }
638
639 if linear_delivery_requested(&resolved.raw) {
640 match linear_delivery::run_linear_delivery(&resolved) {
641 Ok(result) => {
642 println!(
643 "{}",
644 linear_delivery::render_linear_delivery_result(&result)
645 );
646 return if result.success { 0 } else { 1 };
647 }
648 Err(error) => {
649 eprintln!("{error}");
650 return 1;
651 }
652 }
653 }
654
655 if resolved.prompt.trim().is_empty() {
656 eprintln!("No query provided.\n\n{}", render_cli_help());
657 return 64;
658 }
659
660 let prompt_context = match build_prompt_context(&resolved) {
661 Ok(context) => context,
662 Err(error) => {
663 eprintln!("{error}");
664 return 1;
665 }
666 };
667
668 if should_show_banner(&resolved.raw) {
669 eprintln!("{}", render_banner());
670 }
671
672 let result = run_amon_hen(&resolved, prompt_context.prompt, prompt_context.commands);
673 if resolved.raw.json || resolved.raw.json_stream {
674 let serialized = if resolved.raw.json_stream {
675 serde_json::to_string(&result)
676 } else {
677 serde_json::to_string_pretty(&result)
678 };
679 println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
680 } else {
681 println!("{}", render_human_result(&result, resolved.raw.verbose));
682 }
683
684 if is_success(&result) {
685 0
686 } else {
687 1
688 }
689}
690
691fn parse_error_exit_code(kind: ErrorKind) -> i32 {
692 match kind {
693 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
694 _ => 64,
695 }
696}
697
698fn render_cli_help() -> &'static str {
699 r#"Amon Hen
700Rust-native orchestration for Codex, Claude, Gemini, and Linear delivery.
701
702Usage:
703 amon-hen [OPTIONS] "your task"
704 amon-hen --studio --members codex,claude,gemini "your task"
705 amon-hen --deliver-linear --linear-project "Project" --linear-until-complete
706
707Core run:
708 --members codex,claude,gemini Providers to consult. Also supports --codex, --claude, --gemini, --all.
709 --summarizer auto|codex|claude|gemini
710 Provider used for synthesis. auto tries the configured order.
711 --studio Open the interactive native Studio.
712 --plain Disable rich terminal output for script-friendly runs.
713 --json, --json-stream Emit JSON or newline-delimited JSON.
714 -q, --quiet Print the synthesis only.
715 -d, --verbose Include provider details, telemetry, and tool usage.
716
717Models, effort, and permissions:
718 --codex-model NAME Model passed to Codex.
719 --claude-model NAME Model passed to Claude.
720 --gemini-model NAME Model passed to Gemini.
721 --effort low|medium|high Shared default effort.
722 --codex-effort low|medium|high|xhigh
723 --claude-effort low|medium|high|xhigh|max
724 --gemini-effort low|medium|high
725 --codex-sandbox read-only|workspace-write|danger-full-access
726 --claude-permission-mode MODE Claude permission mode such as plan, acceptEdits, bypassPermissions.
727
728Auth and provider capabilities:
729 --auth-login Start provider social-login flows.
730 --auth-status Show configured auth sources for each provider.
731 --codex-auth auto|api-key|social-login|oauth|keychain
732 --claude-auth auto|api-key|social-login|oauth|keychain
733 --gemini-auth auto|api-key|social-login|oauth|keychain
734 --capabilities-status Probe provider Skills / MCP / Tools support.
735 --codex-capabilities inherit|override
736 --claude-capabilities inherit|override
737 --gemini-capabilities inherit|override
738 --codex-config PATH Extra Codex config file, repeatable.
739 --codex-mcp-profile NAME Codex MCP profile.
740 --claude-mcp-config PATH Claude MCP config file, repeatable.
741 --claude-allowed-tools LIST Allowed Claude tools.
742 --claude-disallowed-tools LIST Disallowed Claude tools.
743 --claude-tools LIST Claude tool list override.
744 --claude-agent NAME Claude agent profile.
745 --claude-agents-json JSON Claude agents JSON override.
746 --claude-plugin-dir PATH Claude plugin directory, repeatable.
747 --gemini-settings PATH Gemini settings file.
748 --gemini-tools-profile LIST Gemini tool profile list.
749 --gemini-allowed-mcp-servers LIST Gemini MCP allow-list.
750 --gemini-policy LIST Gemini policy values.
751 --gemini-admin-policy LIST Gemini admin policy values.
752
753Team workflow:
754 --handoff Feed planner/lead context between providers.
755 --planner codex|claude|gemini Assign the planning role.
756 --lead codex|claude|gemini Assign the lead reviewer/synthesizer role.
757 --iterations N Run multiple provider rounds per prompt.
758 --team-work N Spawn N same-provider sub-agents per provider.
759 --codex-sub-agents N Override Codex sub-agent count.
760 --claude-sub-agents N Override Claude sub-agent count.
761 --gemini-sub-agents N Override Gemini sub-agent count.
762 --file PATH Tag a local file into the prompt context, repeatable.
763 --cmd COMMAND Run a local command and include its telemetry, repeatable.
764
765Linear delivery:
766 --deliver-linear Deliver Linear work instead of a one-shot prompt.
767 --linear-watch Poll Linear for matching work.
768 --linear-until-complete Continue until review/delivery gate is met.
769 --linear-issue ID,ID Target specific Linear issues.
770 --linear-project NAME,NAME Target Linear projects.
771 --linear-epic NAME,NAME Target Linear epics.
772 --linear-team KEY Restrict to a Linear team.
773 --linear-state NAME Restrict by Linear workflow state.
774 --linear-completion-gate delivered|review|ci
775 --linear-max-concurrency N Isolated workspace concurrency.
776 --linear-max-attempts N Retry attempts per issue.
777 --linear-workspace-strategy worktree|local
778 --linear-workspace-root PATH Root for per-issue workspaces.
779 --linear-observability-dir PATH Logs, reconciliation, and run telemetry.
780 --linear-attach-media PATH,PATH Attach generated media back to Linear.
781
782Runtime:
783 --cwd PATH Working directory for provider CLIs.
784 --timeout SECONDS Per-provider timeout.
785 --max-member-chars N Max provider output included in synthesis.
786 --color auto|always|never Color mode.
787 --no-color Disable color.
788 -v, --version Print version.
789 -h, --help Show this help.
790
791Examples:
792 amon-hen --studio --members codex,claude,gemini "Inspect this repo"
793 amon-hen --members codex,claude,gemini --planner codex --lead claude --handoff --iterations 2 "Suggest the cleanest next patch"
794 amon-hen --deliver-linear --linear-project "Developer Platform" --linear-until-complete --team-work 2
795 amon-hen --auth-login --auth-login-providers codex,claude,gemini"#
796}
797
798fn resolve_args(raw: CliArgs) -> Result<ResolvedArgs, String> {
799 let members = resolve_members(&raw)?;
800 validate_engine_name(&raw.summarizer, true, "--summarizer")?;
801 if let Some(lead) = &raw.lead {
802 validate_engine_name(lead, false, "--lead")?;
803 if !members.contains(lead) {
804 return Err(format!(
805 "--lead must be one of the enabled members: {}",
806 members.join(", ")
807 ));
808 }
809 }
810 if let Some(planner) = &raw.planner {
811 validate_engine_name(planner, false, "--planner")?;
812 if !members.contains(planner) {
813 return Err(format!(
814 "--planner must be one of the enabled members: {}",
815 members.join(", ")
816 ));
817 }
818 }
819 validate_provider_effort("codex", raw.codex_effort.as_deref())?;
820 validate_provider_effort("claude", raw.claude_effort.as_deref())?;
821 validate_provider_effort("gemini", raw.gemini_effort.as_deref())?;
822 validate_choice(
823 "--claude-login-mode",
824 &raw.claude_login_mode,
825 &["claudeai", "console", "sso"],
826 )?;
827 validate_choice(
828 "--codex-capabilities",
829 &raw.codex_capabilities,
830 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
831 )?;
832 validate_choice(
833 "--claude-capabilities",
834 &raw.claude_capabilities,
835 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
836 )?;
837 validate_choice(
838 "--gemini-capabilities",
839 &raw.gemini_capabilities,
840 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
841 )?;
842 validate_choice("--linear-auth", &raw.linear_auth, &["api-key", "oauth"])?;
843 validate_choice(
844 "--linear-completion-gate",
845 &raw.linear_completion_gate,
846 &["delivered", "human-review", "ci-success", "review-or-ci"],
847 )?;
848 validate_choice(
849 "--linear-workspace-strategy",
850 &raw.linear_workspace_strategy,
851 &["worktree", "copy", "none"],
852 )?;
853 if raw.linear_limit == 0 {
854 return Err("--linear-limit requires a positive integer.".to_string());
855 }
856 if raw.linear_max_concurrency == 0 {
857 return Err("--linear-max-concurrency requires a positive integer.".to_string());
858 }
859 if raw.linear_max_polls == Some(0) {
860 return Err("--linear-max-polls requires a positive integer.".to_string());
861 }
862 if raw
863 .linear_endpoint
864 .as_ref()
865 .is_some_and(|value| value.trim().is_empty())
866 {
867 return Err("--linear-endpoint requires a non-empty value.".to_string());
868 }
869 for phase in raw
870 .delivery_phases
871 .iter()
872 .map(|phase| phase.trim())
873 .filter(|phase| !phase.is_empty())
874 {
875 validate_choice(
876 "--delivery-phases",
877 phase,
878 &["plan", "implement", "verify", "ship"],
879 )?;
880 }
881
882 let mut prompt = raw.prompt.join(" ");
883 if prompt.trim().is_empty() && !io::stdin().is_terminal() {
884 let mut stdin = String::new();
885 io::stdin()
886 .read_to_string(&mut stdin)
887 .map_err(|error| format!("Failed to read stdin: {error}"))?;
888 prompt = stdin;
889 }
890 let cwd = raw.cwd.canonicalize().unwrap_or_else(|_| raw.cwd.clone());
891
892 Ok(ResolvedArgs {
893 raw,
894 members,
895 prompt,
896 cwd,
897 })
898}
899
900fn resolve_members(raw: &CliArgs) -> Result<Vec<String>, String> {
901 let mut members = if raw.members.is_empty() {
902 ENGINES
903 .iter()
904 .map(|name| (*name).to_string())
905 .collect::<Vec<_>>()
906 } else {
907 raw.members
908 .iter()
909 .map(|name| name.trim().to_string())
910 .filter(|name| !name.is_empty())
911 .collect::<Vec<_>>()
912 };
913
914 if raw.all {
915 members = ENGINES.iter().map(|name| (*name).to_string()).collect();
916 }
917 if raw.no_codex {
918 members.retain(|name| name != "codex");
919 }
920 if raw.no_claude {
921 members.retain(|name| name != "claude");
922 }
923 if raw.no_gemini {
924 members.retain(|name| name != "gemini");
925 }
926 for enabled in [
927 (raw.codex, "codex"),
928 (raw.claude, "claude"),
929 (raw.gemini, "gemini"),
930 ] {
931 if enabled.0 && !members.iter().any(|name| name == enabled.1) {
932 members.push(enabled.1.to_string());
933 }
934 }
935
936 members.dedup();
937 for member in &members {
938 validate_engine_name(member, false, "--members")?;
939 }
940 if members.is_empty() {
941 return Err("At least one member must be enabled.".to_string());
942 }
943 Ok(members)
944}
945
946fn validate_engine_name(name: &str, allow_auto: bool, flag: &str) -> Result<(), String> {
947 if allow_auto && name == "auto" {
948 return Ok(());
949 }
950 if Engine::parse(name).is_some() {
951 Ok(())
952 } else {
953 Err(format!(
954 "{flag} must be one of: {}",
955 if allow_auto {
956 "auto, codex, claude, gemini"
957 } else {
958 "codex, claude, gemini"
959 }
960 ))
961 }
962}
963
964fn validate_provider_effort(provider: &str, value: Option<&str>) -> Result<(), String> {
965 let Some(value) = value else {
966 return Ok(());
967 };
968 let engine = Engine::parse(provider).expect("provider effort validation uses known engines");
969 if engine.allowed_efforts().contains(&value) {
970 Ok(())
971 } else {
972 Err(format!("Unsupported --{provider}-effort value: {value}"))
973 }
974}
975
976fn validate_choice(flag: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
977 if allowed.contains(&value) {
978 Ok(())
979 } else {
980 Err(format!("{flag} must be one of: {}", allowed.join(", ")))
981 }
982}
983
984fn collect_auth_statuses(resolved: &ResolvedArgs) -> Vec<ProviderAuthStatus> {
985 resolved
986 .members
987 .iter()
988 .map(|provider| provider_auth_status(provider, resolved))
989 .collect()
990}
991
992fn provider_auth_status(provider: &str, resolved: &ResolvedArgs) -> ProviderAuthStatus {
993 let auth = provider_auth(resolved, provider);
994 match provider {
995 "codex" => {
996 let bin = resolve_binary("codex");
997 let args = vec!["login".to_string(), "status".to_string()];
998 status_from_command(provider, &auth, &bin, &args, &resolved.cwd)
999 }
1000 "claude" => {
1001 let bin = resolve_binary("claude");
1002 let args = vec![
1003 "auth".to_string(),
1004 "status".to_string(),
1005 "--text".to_string(),
1006 ];
1007 status_from_command(provider, &auth, &bin, &args, &resolved.cwd)
1008 }
1009 "gemini" => {
1010 let has_api_key = std::env::var("GEMINI_API_KEY")
1011 .ok()
1012 .is_some_and(|value| !value.trim().is_empty());
1013 let home = std::env::var("HOME").unwrap_or_default();
1014 let has_oauth_file = !home.is_empty()
1015 && [
1016 ".gemini/oauth_creds.json",
1017 ".gemini/oauth_tokens.json",
1018 ".gemini/settings.json",
1019 ]
1020 .iter()
1021 .any(|path| Path::new(&home).join(path).exists());
1022 let configured = has_api_key || has_oauth_file;
1023 ProviderAuthStatus {
1024 provider: provider.to_string(),
1025 configured,
1026 status: if configured { "configured" } else { "unknown" }.to_string(),
1027 source: auth,
1028 detail: if has_api_key {
1029 "GEMINI_API_KEY is present.".to_string()
1030 } else if has_oauth_file {
1031 "Gemini local auth/config files are present.".to_string()
1032 } else {
1033 "Gemini CLI does not currently expose a stable headless auth status command; use Social login from Studio or run gemini interactively.".to_string()
1034 },
1035 }
1036 }
1037 _ => ProviderAuthStatus {
1038 provider: provider.to_string(),
1039 configured: false,
1040 status: "unknown".to_string(),
1041 source: auth,
1042 detail: "Unknown provider.".to_string(),
1043 },
1044 }
1045}
1046
1047fn status_from_command(
1048 provider: &str,
1049 auth: &str,
1050 bin: &str,
1051 args: &[String],
1052 cwd: &Path,
1053) -> ProviderAuthStatus {
1054 let result = run_command(bin, args, cwd, None, 15_000, HashMap::new(), None);
1055 let configured = result.code == Some(0);
1056 ProviderAuthStatus {
1057 provider: provider.to_string(),
1058 configured,
1059 status: if configured {
1060 "configured"
1061 } else if result.error.is_some() {
1062 "missing-cli"
1063 } else {
1064 "not-configured"
1065 }
1066 .to_string(),
1067 source: auth.to_string(),
1068 detail: sanitize_status_detail(&compact_failure(&result)),
1069 }
1070}
1071
1072fn sanitize_status_detail(detail: &str) -> String {
1073 let detail = detail.trim();
1074 if detail.is_empty() {
1075 return "No status detail returned.".to_string();
1076 }
1077 let detail = detail
1078 .lines()
1079 .take(6)
1080 .map(str::trim)
1081 .filter(|line| !line.is_empty())
1082 .map(redact_possible_secret)
1083 .collect::<Vec<_>>()
1084 .join(" ");
1085 redact_local_paths(&detail)
1086}
1087
1088fn redact_possible_secret(value: &str) -> String {
1089 value
1090 .split_whitespace()
1091 .map(|token| {
1092 if looks_sensitive_token(token) {
1093 "[redacted]"
1094 } else if looks_like_email(token) {
1095 "[email]"
1096 } else {
1097 token
1098 }
1099 })
1100 .collect::<Vec<_>>()
1101 .join(" ")
1102}
1103
1104fn looks_sensitive_token(token: &str) -> bool {
1105 let trimmed =
1106 token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_');
1107 trimmed.len() >= 28
1108 && (trimmed.starts_with("sk-")
1109 || trimmed.starts_with("sk_")
1110 || trimmed.starts_with("gho_")
1111 || trimmed.starts_with("ghp_")
1112 || trimmed.starts_with("ghs_")
1113 || trimmed.starts_with("ghu_")
1114 || trimmed.starts_with("AIza")
1115 || trimmed.starts_with("AQ."))
1116}
1117
1118fn looks_like_email(token: &str) -> bool {
1119 let trimmed = token.trim_matches(|ch: char| {
1120 !ch.is_ascii_alphanumeric() && !matches!(ch, '@' | '.' | '_' | '-' | '+')
1121 });
1122 trimmed.contains('@') && trimmed.rsplit_once('.').is_some()
1123}
1124
1125fn redact_local_paths(value: &str) -> String {
1126 let mut redacted = value.to_string();
1127 if let Ok(home) = std::env::var("HOME") {
1128 if !home.trim().is_empty() {
1129 redacted = redacted.replace(&home, "~");
1130 }
1131 }
1132 redacted
1133}
1134
1135fn render_auth_statuses(statuses: &[ProviderAuthStatus]) -> String {
1136 let mut lines = vec!["Provider auth status".to_string()];
1137 for status in statuses {
1138 lines.push(format!(
1139 "- {}: {} via {} ({})",
1140 status.provider, status.status, status.source, status.detail
1141 ));
1142 }
1143 lines.join("\n")
1144}
1145
1146fn collect_provider_capability_statuses(resolved: &ResolvedArgs) -> Vec<ProviderCapabilityStatus> {
1147 resolved
1148 .members
1149 .iter()
1150 .map(|provider| provider_capability_status(provider, resolved))
1151 .collect()
1152}
1153
1154fn provider_capability_status(provider: &str, resolved: &ResolvedArgs) -> ProviderCapabilityStatus {
1155 match provider {
1156 "codex" => {
1157 let bin = resolve_binary("codex");
1158 let mcp = run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd);
1159 ProviderCapabilityStatus {
1160 provider: provider.to_string(),
1161 mcp,
1162 skills: None,
1163 tools: Some(run_capability_probe(
1164 &bin,
1165 &["plugin", "marketplace", "--help"],
1166 &resolved.cwd,
1167 )),
1168 detail:
1169 "Codex inherits ~/.codex config unless --codex-capabilities override is set."
1170 .to_string(),
1171 }
1172 }
1173 "claude" => {
1174 let bin = resolve_binary("claude");
1175 ProviderCapabilityStatus {
1176 provider: provider.to_string(),
1177 mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd),
1178 skills: Some(run_capability_probe(&bin, &["plugin", "list"], &resolved.cwd)),
1179 tools: Some(run_capability_probe(&bin, &["agents"], &resolved.cwd)),
1180 detail: "Claude override can manage MCP config, tools, agents, plugin dirs, and slash-command skills.".to_string(),
1181 }
1182 }
1183 "gemini" => {
1184 let bin = resolve_binary("gemini");
1185 ProviderCapabilityStatus {
1186 provider: provider.to_string(),
1187 mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd),
1188 skills: Some(run_capability_probe(&bin, &["skills", "list"], &resolved.cwd)),
1189 tools: Some(run_capability_probe(&bin, &["extensions", "list"], &resolved.cwd)),
1190 detail: "Gemini override can manage settings, extensions, MCP server allowlists, and policy files.".to_string(),
1191 }
1192 }
1193 _ => ProviderCapabilityStatus {
1194 provider: provider.to_string(),
1195 mcp: CommandTelemetry {
1196 command: provider.to_string(),
1197 status: "unknown".to_string(),
1198 detail: "Unknown provider.".to_string(),
1199 exit_code: None,
1200 duration_ms: 0,
1201 stdout_chars: 0,
1202 stderr_chars: 0,
1203 timed_out: false,
1204 },
1205 skills: None,
1206 tools: None,
1207 detail: "Unknown provider.".to_string(),
1208 },
1209 }
1210}
1211
1212fn run_capability_probe(bin: &str, args: &[&str], cwd: &Path) -> CommandTelemetry {
1213 let args = args
1214 .iter()
1215 .map(|arg| (*arg).to_string())
1216 .collect::<Vec<_>>();
1217 command_telemetry(&run_command(
1218 bin,
1219 &args,
1220 cwd,
1221 None,
1222 20_000,
1223 HashMap::new(),
1224 None,
1225 ))
1226}
1227
1228fn emit_runtime_event(
1229 progress: &Option<ProgressSink>,
1230 fallback_to_stderr: bool,
1231 event: ProgressEvent,
1232) {
1233 if let Some(progress) = progress {
1234 progress(event);
1235 } else if fallback_to_stderr {
1236 eprintln!("{}", event.message);
1237 }
1238}
1239
1240fn progress_event(
1241 provider: Option<&str>,
1242 role: Option<&str>,
1243 stage: ProgressStage,
1244 status: Option<&str>,
1245 message: impl Into<String>,
1246) -> ProgressEvent {
1247 ProgressEvent {
1248 provider: provider.map(ToString::to_string),
1249 role: role.map(ToString::to_string),
1250 stage,
1251 status: status.map(ToString::to_string),
1252 message: message.into(),
1253 }
1254}
1255
1256fn command_progress(label: Option<&str>, sink: Option<ProgressSink>) -> Option<CommandProgress> {
1257 label.map(|label| CommandProgress {
1258 label: label.to_string(),
1259 sink,
1260 })
1261}
1262
1263fn render_provider_capability_statuses(statuses: &[ProviderCapabilityStatus]) -> String {
1264 let mut lines = vec!["Provider capabilities".to_string()];
1265 for status in statuses {
1266 lines.push(format!(
1267 "- {} MCP: {} ({})",
1268 status.provider, status.mcp.status, status.mcp.command
1269 ));
1270 if !status.mcp.detail.trim().is_empty() {
1271 lines.push(format!(" {}", status.mcp.detail));
1272 }
1273 if let Some(skills) = &status.skills {
1274 lines.push(format!(
1275 " skills/plugins: {} ({})",
1276 skills.status, skills.command
1277 ));
1278 if !skills.detail.trim().is_empty() {
1279 lines.push(format!(" {}", skills.detail));
1280 }
1281 }
1282 if let Some(tools) = &status.tools {
1283 lines.push(format!(
1284 " tools/extensions: {} ({})",
1285 tools.status, tools.command
1286 ));
1287 if !tools.detail.trim().is_empty() {
1288 lines.push(format!(" {}", tools.detail));
1289 }
1290 }
1291 lines.push(format!(" {}", status.detail));
1292 }
1293 lines.join("\n")
1294}
1295
1296fn run_social_login(resolved: &ResolvedArgs) -> Result<(), String> {
1297 let providers = if resolved.raw.auth_login_providers.is_empty() {
1298 resolved.members.clone()
1299 } else {
1300 resolved.raw.auth_login_providers.clone()
1301 };
1302
1303 for provider in providers {
1304 validate_engine_name(&provider, false, "--auth-login-providers")?;
1305 let (bin, args, instruction): (String, Vec<String>, &str) =
1306 match provider.as_str() {
1307 "codex" => {
1308 let mut args = vec!["login".to_string()];
1309 if resolved.raw.auth_device_code {
1310 args.push("--device-auth".to_string());
1311 }
1312 (
1313 resolve_binary("codex"),
1314 args,
1315 "Complete the Codex browser login. Deeplinks and pasted codes are supported by the provider CLI when prompted.",
1316 )
1317 }
1318 "claude" => {
1319 let mut args = vec!["auth".to_string(), "login".to_string()];
1320 match resolved.raw.claude_login_mode.as_str() {
1321 "console" => args.push("--console".to_string()),
1322 "sso" => args.push("--sso".to_string()),
1323 _ => args.push("--claudeai".to_string()),
1324 }
1325 if let Some(email) = &resolved.raw.claude_login_email {
1326 push_arg(&mut args, "--email", email.clone());
1327 }
1328 (
1329 resolve_binary("claude"),
1330 args,
1331 "Complete the Claude browser login. Paste any shown login code into this terminal when prompted; deeplinks are opened when the CLI emits them.",
1332 )
1333 }
1334 "gemini" => (
1335 resolve_binary("gemini"),
1336 vec![],
1337 "Use the Gemini CLI auth selector, choose browser/social login, and complete local callback or code paste when prompted.",
1338 ),
1339 _ => unreachable!(),
1340 };
1341 eprintln!(
1342 "[auth] launching {provider}: {}",
1343 format_command(&bin, &args)
1344 );
1345 eprintln!("[auth] {provider}: {instruction}");
1346 let result = run_interactive_auth_command(
1347 &bin,
1348 &args,
1349 &resolved.cwd,
1350 resolved.raw.auth_timeout * 1000,
1351 !resolved.raw.no_auth_open_browser,
1352 &provider,
1353 );
1354 if result.code.unwrap_or(1) != 0 {
1355 return Err(format!(
1356 "{provider} social login failed: {}",
1357 compact_failure(&result)
1358 ));
1359 }
1360 let status = provider_auth_status(&provider, resolved);
1361 eprintln!("[auth] {provider}: {} ({})", status.status, status.detail);
1362 }
1363 Ok(())
1364}
1365
1366fn run_interactive_auth_command(
1367 command: &str,
1368 args: &[String],
1369 cwd: &Path,
1370 timeout_ms: u64,
1371 open_browser: bool,
1372 provider: &str,
1373) -> CommandResult {
1374 let started = Instant::now();
1375 let mut child = match Command::new(command)
1376 .args(args)
1377 .current_dir(cwd)
1378 .stdin(Stdio::inherit())
1379 .stdout(Stdio::piped())
1380 .stderr(Stdio::piped())
1381 .spawn()
1382 {
1383 Ok(child) => child,
1384 Err(error) => {
1385 return CommandResult {
1386 command: command.to_string(),
1387 args: args.to_vec(),
1388 code: None,
1389 stdout: String::new(),
1390 stderr: String::new(),
1391 timed_out: false,
1392 error: Some(error.to_string()),
1393 timeout_ms,
1394 duration_ms: started.elapsed().as_millis(),
1395 }
1396 }
1397 };
1398
1399 let seen_urls = Arc::new(Mutex::new(HashSet::new()));
1400 let stdout = child.stdout.take().map(|pipe| {
1401 read_auth_pipe(
1402 pipe,
1403 true,
1404 provider.to_string(),
1405 open_browser,
1406 Arc::clone(&seen_urls),
1407 )
1408 });
1409 let stderr = child.stderr.take().map(|pipe| {
1410 read_auth_pipe(
1411 pipe,
1412 false,
1413 provider.to_string(),
1414 open_browser,
1415 Arc::clone(&seen_urls),
1416 )
1417 });
1418 let timeout = Duration::from_millis(timeout_ms);
1419 let mut timed_out = false;
1420 let code;
1421 loop {
1422 match child.try_wait() {
1423 Ok(Some(status)) => {
1424 code = status.code();
1425 break;
1426 }
1427 Ok(None) => {
1428 if timeout_ms > 0 && started.elapsed() >= timeout {
1429 timed_out = true;
1430 let _ = child.kill();
1431 let status = child.wait().ok();
1432 code = status.and_then(|status| status.code());
1433 break;
1434 }
1435 thread::sleep(Duration::from_millis(50));
1436 }
1437 Err(error) => {
1438 return CommandResult {
1439 command: command.to_string(),
1440 args: args.to_vec(),
1441 code: None,
1442 stdout: String::new(),
1443 stderr: String::new(),
1444 timed_out,
1445 error: Some(error.to_string()),
1446 timeout_ms,
1447 duration_ms: started.elapsed().as_millis(),
1448 }
1449 }
1450 }
1451 }
1452
1453 CommandResult {
1454 command: command.to_string(),
1455 args: args.to_vec(),
1456 code,
1457 stdout: stdout
1458 .and_then(|handle| handle.join().ok())
1459 .unwrap_or_default(),
1460 stderr: stderr
1461 .and_then(|handle| handle.join().ok())
1462 .unwrap_or_default(),
1463 timed_out,
1464 error: None,
1465 timeout_ms,
1466 duration_ms: started.elapsed().as_millis(),
1467 }
1468}
1469
1470fn read_auth_pipe<R>(
1471 mut pipe: R,
1472 stdout: bool,
1473 provider: String,
1474 open_browser: bool,
1475 seen_urls: Arc<Mutex<HashSet<String>>>,
1476) -> thread::JoinHandle<String>
1477where
1478 R: Read + Send + 'static,
1479{
1480 thread::spawn(move || {
1481 let mut text = String::new();
1482 let mut buffer = [0u8; 4096];
1483 loop {
1484 let read = match pipe.read(&mut buffer) {
1485 Ok(0) => break,
1486 Ok(read) => read,
1487 Err(_) => break,
1488 };
1489 let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
1490 text.push_str(&chunk);
1491 if stdout {
1492 print!("{chunk}");
1493 let _ = io::stdout().flush();
1494 } else {
1495 eprint!("{chunk}");
1496 let _ = io::stderr().flush();
1497 }
1498 if open_browser {
1499 for url in extract_auth_urls(&chunk) {
1500 let mut seen = seen_urls.lock().ok();
1501 if seen.as_mut().is_some_and(|seen| !seen.insert(url.clone())) {
1502 continue;
1503 }
1504 eprintln!("[auth] {provider}: opening {url}");
1505 if let Err(error) = open_browser_url(&url) {
1506 eprintln!("[auth] {provider}: failed to open {url}: {error}");
1507 }
1508 }
1509 }
1510 }
1511 text
1512 })
1513}
1514
1515fn extract_auth_urls(text: &str) -> Vec<String> {
1516 text.split_whitespace()
1517 .map(|token| {
1518 token.trim_matches(|ch: char| {
1519 matches!(ch, '<' | '>' | '"' | '\'' | ')' | '(' | ',' | ';')
1520 })
1521 })
1522 .map(|token| token.trim_end_matches(['.', ':', ',', ';']))
1523 .filter(|token| {
1524 token.starts_with("http://")
1525 || token.starts_with("https://")
1526 || token.starts_with("codex://")
1527 || token.starts_with("openai://")
1528 || token.starts_with("claude://")
1529 || token.starts_with("anthropic://")
1530 || token.starts_with("gemini://")
1531 || token.starts_with("google://")
1532 })
1533 .map(ToString::to_string)
1534 .collect()
1535}
1536
1537fn open_browser_url(url: &str) -> Result<(), String> {
1538 let (command, args): (&str, Vec<String>) = if cfg!(target_os = "macos") {
1539 ("open", vec![url.to_string()])
1540 } else if cfg!(target_os = "windows") {
1541 (
1542 "cmd",
1543 vec![
1544 "/c".to_string(),
1545 "start".to_string(),
1546 "".to_string(),
1547 url.to_string(),
1548 ],
1549 )
1550 } else {
1551 ("xdg-open", vec![url.to_string()])
1552 };
1553 Command::new(command)
1554 .args(args)
1555 .stdin(Stdio::null())
1556 .stdout(Stdio::null())
1557 .stderr(Stdio::null())
1558 .spawn()
1559 .map(|mut child| {
1560 let _ = child.try_wait();
1561 })
1562 .map_err(|error| error.to_string())
1563}
1564
1565fn build_prompt_context(resolved: &ResolvedArgs) -> Result<PromptContext, String> {
1566 build_prompt_context_with_progress(resolved, None)
1567}
1568
1569fn build_prompt_context_with_progress(
1570 resolved: &ResolvedArgs,
1571 progress: Option<ProgressSink>,
1572) -> Result<PromptContext, String> {
1573 let mut prompt = resolved.prompt.trim().to_string();
1574 let mut sections = Vec::new();
1575 let mut commands = Vec::new();
1576
1577 for file in &resolved.raw.files {
1578 let path = if file.is_absolute() {
1579 file.clone()
1580 } else {
1581 resolved.cwd.join(file)
1582 };
1583 let content = fs::read_to_string(&path)
1584 .map_err(|error| format!("Failed to read tagged file {}: {error}", path.display()))?;
1585 sections.push(format!("--- file: {} ---\n{}", file.display(), content));
1586 }
1587
1588 for command in &resolved.raw.commands {
1589 let shell = if cfg!(windows) { "cmd" } else { "sh" };
1590 let args = if cfg!(windows) {
1591 vec!["/C".to_string(), command.clone()]
1592 } else {
1593 vec!["-lc".to_string(), command.clone()]
1594 };
1595 let live_label = (resolved.raw.verbose || progress.is_some())
1596 .then(|| format!("context command `{}`", truncate(command, 80)));
1597 emit_runtime_event(
1598 &progress,
1599 false,
1600 progress_event(
1601 None,
1602 None,
1603 ProgressStage::Context,
1604 None,
1605 format!("[amon-hen] context command `{}`", truncate(command, 80)),
1606 ),
1607 );
1608 let result = run_command(
1609 shell,
1610 &args,
1611 &resolved.cwd,
1612 None,
1613 resolved.raw.timeout * 1000,
1614 HashMap::new(),
1615 command_progress(live_label.as_deref(), progress.clone()),
1616 );
1617 let telemetry = command_telemetry(&result);
1618 emit_runtime_event(
1619 &progress,
1620 false,
1621 progress_event(
1622 None,
1623 None,
1624 ProgressStage::Done,
1625 Some(&telemetry.status),
1626 format!(
1627 "[amon-hen] context command `{}` {} in {:.1}s",
1628 truncate(command, 80),
1629 telemetry.status,
1630 result.duration_ms as f64 / 1000.0
1631 ),
1632 ),
1633 );
1634 commands.push(telemetry);
1635 sections.push(format!(
1636 "--- command: {} (exit {}) ---\nstdout:\n{}\nstderr:\n{}",
1637 command,
1638 result
1639 .code
1640 .map(|code| code.to_string())
1641 .unwrap_or_else(|| "unknown".to_string()),
1642 result.stdout.trim(),
1643 result.stderr.trim()
1644 ));
1645 }
1646
1647 if !sections.is_empty() {
1648 prompt.push_str("\n\nPrompt context:\n");
1649 prompt.push_str(§ions.join("\n\n"));
1650 }
1651 Ok(PromptContext { prompt, commands })
1652}
1653
1654fn run_amon_hen(
1655 resolved: &ResolvedArgs,
1656 query: String,
1657 prompt_commands: Vec<CommandTelemetry>,
1658) -> AmonHenResult {
1659 run_amon_hen_with_progress(resolved, query, prompt_commands, None)
1660}
1661
1662fn run_amon_hen_with_progress(
1663 resolved: &ResolvedArgs,
1664 query: String,
1665 prompt_commands: Vec<CommandTelemetry>,
1666 progress: Option<ProgressSink>,
1667) -> AmonHenResult {
1668 let workflow = build_workflow(resolved);
1669 let mut previous_iteration = Vec::new();
1670 let mut final_members = Vec::new();
1671
1672 for iteration in 1..=workflow.iterations {
1673 emit_runtime_event(
1674 &progress,
1675 resolved.raw.verbose,
1676 progress_event(
1677 None,
1678 None,
1679 ProgressStage::Start,
1680 None,
1681 format!(
1682 "[amon-hen] iteration {iteration}/{} started",
1683 workflow.iterations
1684 ),
1685 ),
1686 );
1687 final_members = run_iteration(
1688 resolved,
1689 &query,
1690 &workflow,
1691 iteration,
1692 &previous_iteration,
1693 progress.clone(),
1694 );
1695 previous_iteration = final_members.clone();
1696 }
1697
1698 let successes = final_members
1699 .iter()
1700 .filter(|result| result.status == "ok")
1701 .cloned()
1702 .collect::<Vec<_>>();
1703 let summary = if successes.is_empty() {
1704 EngineResult {
1705 name: resolved.raw.summarizer.clone(),
1706 bin: None,
1707 status: "error".to_string(),
1708 duration_ms: 0,
1709 detail: "No Amon Hen member produced a response.".to_string(),
1710 exit_code: None,
1711 stdout: String::new(),
1712 stderr: String::new(),
1713 output: String::new(),
1714 command: String::new(),
1715 token_usage: token_usage("", ""),
1716 tool_calls: vec![],
1717 sub_agents: vec![],
1718 role: "summary".to_string(),
1719 iteration: workflow.iterations,
1720 total_iterations: workflow.iterations,
1721 team_size: 0,
1722 }
1723 } else {
1724 let summary_prompt =
1725 build_summary_prompt(&query, &successes, &workflow, resolved.raw.max_member_chars);
1726 let summarizer = pick_summarizer(resolved, &successes);
1727 let mut options = engine_options(
1728 resolved,
1729 &summarizer,
1730 summary_prompt,
1731 "summary",
1732 workflow.iterations,
1733 &workflow,
1734 progress.clone(),
1735 );
1736 options.role = "summary".to_string();
1737 options.team_size = 0;
1738 run_engine(&summarizer, options)
1739 };
1740
1741 AmonHenResult {
1742 query,
1743 cwd: resolved.cwd.display().to_string(),
1744 members_requested: resolved.members.clone(),
1745 summarizer_requested: resolved.raw.summarizer.clone(),
1746 workflow,
1747 prompt_commands,
1748 members: final_members,
1749 summary,
1750 }
1751}
1752
1753fn build_workflow(resolved: &ResolvedArgs) -> Workflow {
1754 let mut teams = HashMap::new();
1755 for member in ENGINES {
1756 teams.insert(member.to_string(), resolved.raw.team_work);
1757 }
1758 if let Some(value) = resolved.raw.codex_sub_agents {
1759 teams.insert("codex".to_string(), value);
1760 }
1761 if let Some(value) = resolved.raw.claude_sub_agents {
1762 teams.insert("claude".to_string(), value);
1763 }
1764 if let Some(value) = resolved.raw.gemini_sub_agents {
1765 teams.insert("gemini".to_string(), value);
1766 }
1767 Workflow {
1768 handoff: resolved.raw.handoff,
1769 lead: resolved.raw.lead.clone(),
1770 planner: resolved.raw.planner.clone(),
1771 iterations: resolved.raw.iterations.max(1),
1772 team_work: resolved.raw.team_work,
1773 teams,
1774 }
1775}
1776
1777fn run_iteration(
1778 resolved: &ResolvedArgs,
1779 query: &str,
1780 workflow: &Workflow,
1781 iteration: usize,
1782 previous_iteration: &[EngineResult],
1783 progress: Option<ProgressSink>,
1784) -> Vec<EngineResult> {
1785 let ordered = execution_order(&resolved.members, workflow.planner.as_deref());
1786 if workflow.handoff {
1787 let mut results = Vec::new();
1788 for member in ordered {
1789 let role = role_for(&member, workflow);
1790 let prompt = build_member_prompt(
1791 query,
1792 &role,
1793 workflow,
1794 iteration,
1795 previous_iteration,
1796 &results,
1797 "",
1798 );
1799 let options = engine_options(
1800 resolved,
1801 &member,
1802 prompt,
1803 &role,
1804 iteration,
1805 workflow,
1806 progress.clone(),
1807 );
1808 results.push(run_engine(&member, options));
1809 }
1810 return results;
1811 }
1812
1813 let planner = workflow
1814 .planner
1815 .as_ref()
1816 .filter(|planner| ordered.contains(planner))
1817 .cloned();
1818 let mut results = Vec::new();
1819 let mut plan_output = String::new();
1820 if let Some(planner_name) = planner.as_ref() {
1821 let role = role_for(planner_name, workflow);
1822 let prompt = build_member_prompt(
1823 query,
1824 &role,
1825 workflow,
1826 iteration,
1827 previous_iteration,
1828 &[],
1829 "",
1830 );
1831 let options = engine_options(
1832 resolved,
1833 planner_name,
1834 prompt,
1835 &role,
1836 iteration,
1837 workflow,
1838 progress.clone(),
1839 );
1840 let result = run_engine(planner_name, options);
1841 if result.status == "ok" {
1842 plan_output = result.output.clone();
1843 }
1844 results.push(result);
1845 }
1846
1847 let (tx, rx) = mpsc::channel();
1848 let executors = ordered
1849 .into_iter()
1850 .filter(|member| Some(member) != planner.as_ref())
1851 .collect::<Vec<_>>();
1852 let resolved = Arc::new(resolved.clone());
1853 let workflow = Arc::new(workflow.clone());
1854 let previous_iteration = Arc::new(previous_iteration.to_vec());
1855 let plan_output = Arc::new(plan_output);
1856 let progress = Arc::new(progress);
1857 for member in executors.clone() {
1858 let tx = tx.clone();
1859 let resolved = Arc::clone(&resolved);
1860 let workflow = Arc::clone(&workflow);
1861 let query = query.to_string();
1862 let previous_iteration = Arc::clone(&previous_iteration);
1863 let plan_output = Arc::clone(&plan_output);
1864 let progress = Arc::clone(&progress);
1865 thread::spawn(move || {
1866 let role = role_for(&member, &workflow);
1867 let prompt = build_member_prompt(
1868 &query,
1869 &role,
1870 &workflow,
1871 iteration,
1872 previous_iteration.as_slice(),
1873 &[],
1874 &plan_output,
1875 );
1876 let options = engine_options(
1877 &resolved,
1878 &member,
1879 prompt,
1880 &role,
1881 iteration,
1882 &workflow,
1883 (*progress).clone(),
1884 );
1885 let _ = tx.send(run_engine(&member, options));
1886 });
1887 }
1888 drop(tx);
1889 let mut executor_results = rx.into_iter().collect::<Vec<_>>();
1890 executor_results.sort_by_key(|result| {
1891 executors
1892 .iter()
1893 .position(|member| member == &result.name)
1894 .unwrap_or(usize::MAX)
1895 });
1896 results.extend(executor_results);
1897 results
1898}
1899
1900fn engine_options(
1901 resolved: &ResolvedArgs,
1902 member: &str,
1903 prompt: String,
1904 role: &str,
1905 iteration: usize,
1906 workflow: &Workflow,
1907 progress: Option<ProgressSink>,
1908) -> EngineRunOptions {
1909 EngineRunOptions {
1910 prompt,
1911 cwd: resolved.cwd.clone(),
1912 timeout_ms: resolved.raw.timeout * 1000,
1913 effort: provider_effort(resolved, member),
1914 model: provider_model(resolved, member),
1915 permission: provider_permission(resolved, member),
1916 auth: provider_auth(resolved, member),
1917 capability: provider_capability(resolved, member),
1918 role: role.to_string(),
1919 iteration,
1920 total_iterations: workflow.iterations,
1921 team_size: *workflow.teams.get(member).unwrap_or(&DEFAULT_TEAM_SIZE),
1922 is_sub_agent: false,
1923 live: resolved.raw.verbose || progress.is_some(),
1924 progress,
1925 }
1926}
1927
1928fn provider_effort(resolved: &ResolvedArgs, member: &str) -> Option<String> {
1929 provider_option(
1930 member,
1931 &resolved.raw.codex_effort,
1932 &resolved.raw.claude_effort,
1933 &resolved.raw.gemini_effort,
1934 )
1935 .or_else(|| {
1936 resolved
1937 .raw
1938 .effort
1939 .map(|effort| effort.as_str().to_string())
1940 })
1941}
1942
1943fn provider_model(resolved: &ResolvedArgs, member: &str) -> Option<String> {
1944 provider_option(
1945 member,
1946 &resolved.raw.codex_model,
1947 &resolved.raw.claude_model,
1948 &resolved.raw.gemini_model,
1949 )
1950}
1951
1952fn provider_permission(resolved: &ResolvedArgs, member: &str) -> Option<String> {
1953 match Engine::parse(member) {
1954 Some(Engine::Codex) => Some(resolved.raw.codex_sandbox.clone()),
1955 Some(Engine::Claude) => Some(resolved.raw.claude_permission_mode.clone()),
1956 Some(Engine::Gemini) | None => None,
1957 }
1958}
1959
1960fn provider_auth(resolved: &ResolvedArgs, member: &str) -> String {
1961 match Engine::parse(member) {
1962 Some(Engine::Codex) => resolved.raw.codex_auth.clone(),
1963 Some(Engine::Claude) => resolved.raw.claude_auth.clone(),
1964 Some(Engine::Gemini) => resolved.raw.gemini_auth.clone(),
1965 None => DEFAULT_AUTH_MODE.to_string(),
1966 }
1967}
1968
1969fn provider_capability(resolved: &ResolvedArgs, member: &str) -> ProviderCapability {
1970 match Engine::parse(member).expect("provider capabilities use validated engines") {
1971 Engine::Codex => ProviderCapability {
1972 mode: inferred_capability_mode(
1973 &resolved.raw.codex_capabilities,
1974 !resolved.raw.codex_config.is_empty() || resolved.raw.codex_mcp_profile.is_some(),
1975 ),
1976 config: resolved.raw.codex_config.clone(),
1977 mcp_profile: resolved.raw.codex_mcp_profile.clone(),
1978 mcp_config: vec![],
1979 allowed_tools: vec![],
1980 disallowed_tools: vec![],
1981 tools: vec![],
1982 agent: None,
1983 agents_json: None,
1984 plugin_dirs: vec![],
1985 strict_mcp_config: false,
1986 disable_slash_commands: false,
1987 settings: None,
1988 tools_profile: vec![],
1989 allowed_mcp_servers: vec![],
1990 policy: vec![],
1991 admin_policy: vec![],
1992 },
1993 Engine::Claude => ProviderCapability {
1994 mode: inferred_capability_mode(
1995 &resolved.raw.claude_capabilities,
1996 !resolved.raw.claude_mcp_config.is_empty()
1997 || !resolved.raw.claude_allowed_tools.is_empty()
1998 || !resolved.raw.claude_disallowed_tools.is_empty()
1999 || !resolved.raw.claude_tools.is_empty()
2000 || resolved.raw.claude_agent.is_some()
2001 || resolved.raw.claude_agents_json.is_some()
2002 || !resolved.raw.claude_plugin_dir.is_empty()
2003 || resolved.raw.claude_strict_mcp_config
2004 || resolved.raw.claude_disable_slash_commands,
2005 ),
2006 config: vec![],
2007 mcp_profile: None,
2008 mcp_config: resolved.raw.claude_mcp_config.clone(),
2009 allowed_tools: resolved.raw.claude_allowed_tools.clone(),
2010 disallowed_tools: resolved.raw.claude_disallowed_tools.clone(),
2011 tools: resolved.raw.claude_tools.clone(),
2012 agent: resolved.raw.claude_agent.clone(),
2013 agents_json: resolved.raw.claude_agents_json.clone(),
2014 plugin_dirs: resolved.raw.claude_plugin_dir.clone(),
2015 strict_mcp_config: resolved.raw.claude_strict_mcp_config,
2016 disable_slash_commands: resolved.raw.claude_disable_slash_commands,
2017 settings: None,
2018 tools_profile: vec![],
2019 allowed_mcp_servers: vec![],
2020 policy: vec![],
2021 admin_policy: vec![],
2022 },
2023 Engine::Gemini => ProviderCapability {
2024 mode: inferred_capability_mode(
2025 &resolved.raw.gemini_capabilities,
2026 resolved.raw.gemini_settings.is_some()
2027 || !resolved.raw.gemini_tools_profile.is_empty()
2028 || !resolved.raw.gemini_allowed_mcp_servers.is_empty()
2029 || !resolved.raw.gemini_policy.is_empty()
2030 || !resolved.raw.gemini_admin_policy.is_empty(),
2031 ),
2032 config: vec![],
2033 mcp_profile: None,
2034 mcp_config: vec![],
2035 allowed_tools: vec![],
2036 disallowed_tools: vec![],
2037 tools: vec![],
2038 agent: None,
2039 agents_json: None,
2040 plugin_dirs: vec![],
2041 strict_mcp_config: false,
2042 disable_slash_commands: false,
2043 settings: resolved.raw.gemini_settings.clone(),
2044 tools_profile: resolved.raw.gemini_tools_profile.clone(),
2045 allowed_mcp_servers: resolved.raw.gemini_allowed_mcp_servers.clone(),
2046 policy: resolved.raw.gemini_policy.clone(),
2047 admin_policy: resolved.raw.gemini_admin_policy.clone(),
2048 },
2049 }
2050}
2051
2052fn inferred_capability_mode(configured: &str, has_override_flags: bool) -> String {
2053 if configured == CAPABILITY_INHERIT && has_override_flags {
2054 CAPABILITY_OVERRIDE.to_string()
2055 } else {
2056 configured.to_string()
2057 }
2058}
2059
2060fn provider_option(
2061 member: &str,
2062 codex: &Option<String>,
2063 claude: &Option<String>,
2064 gemini: &Option<String>,
2065) -> Option<String> {
2066 match Engine::parse(member) {
2067 Some(Engine::Codex) => codex.clone(),
2068 Some(Engine::Claude) => claude.clone(),
2069 Some(Engine::Gemini) => gemini.clone(),
2070 None => None,
2071 }
2072}
2073
2074fn build_sub_agent_prompt(original: &str, role: &str, index: usize, total: usize) -> String {
2075 format!(
2076 "You are sub-agent {index} of {total} for an Amon Hen provider assigned role `{role}`.\n\
2077 Work independently on a useful slice of the task. Inspect, reason, or verify as needed, \
2078 then return concise findings, risks, and concrete recommendations for the provider lead.\n\n\
2079 Original provider prompt:\n{original}"
2080 )
2081}
2082
2083fn build_team_lead_prompt(original: &str, sub_agents: &[EngineResult]) -> String {
2084 let handoff = sub_agents
2085 .iter()
2086 .map(|agent| {
2087 format!(
2088 "### {} [{}]\n{}",
2089 agent.role,
2090 agent.status,
2091 if agent.output.trim().is_empty() {
2092 agent.detail.trim()
2093 } else {
2094 agent.output.trim()
2095 }
2096 )
2097 })
2098 .collect::<Vec<_>>()
2099 .join("\n\n");
2100 format!(
2101 "You are the provider lead. Use the sub-agent handoffs below, resolve disagreements, \
2102 and produce the final provider response for the original Amon Hen role.\n\n\
2103 Sub-agent handoffs:\n{handoff}\n\nOriginal provider prompt:\n{original}"
2104 )
2105}
2106
2107fn run_engine(name: &str, options: EngineRunOptions) -> EngineResult {
2108 if let Some(result) = missing_engine_if_unavailable(name, &options) {
2109 return result;
2110 }
2111 if options.team_size > 0 && options.role != "summary" && !options.is_sub_agent {
2112 return run_engine_team(name, options);
2113 }
2114 run_engine_single(name, options, vec![])
2115}
2116
2117fn missing_engine_if_unavailable(name: &str, options: &EngineRunOptions) -> Option<EngineResult> {
2118 let bin = resolve_binary(name);
2119 if command_available(&bin) {
2120 return None;
2121 }
2122 let detail = format!(
2123 "Provider CLI `{bin}` was not found in PATH. Install the {name} CLI on this machine or set {} to the executable path.",
2124 Engine::parse(name)
2125 .map(Engine::binary_env_var)
2126 .unwrap_or("AMON_HEN_PROVIDER_BIN")
2127 );
2128 let message = format!("[amon-hen] {name} missing: {detail}");
2129 emit_runtime_event(
2130 &options.progress,
2131 options.live && options.progress.is_none(),
2132 progress_event(
2133 Some(name),
2134 Some(&options.role),
2135 ProgressStage::Done,
2136 Some("missing"),
2137 message,
2138 ),
2139 );
2140 Some(EngineResult {
2141 name: name.to_string(),
2142 bin: Some(bin.clone()),
2143 status: "missing".to_string(),
2144 duration_ms: 0,
2145 detail: detail.clone(),
2146 exit_code: None,
2147 stdout: String::new(),
2148 stderr: detail,
2149 output: String::new(),
2150 command: bin,
2151 token_usage: token_usage(&options.prompt, ""),
2152 tool_calls: vec![],
2153 sub_agents: vec![],
2154 role: options.role.clone(),
2155 iteration: options.iteration,
2156 total_iterations: options.total_iterations,
2157 team_size: options.team_size,
2158 })
2159}
2160
2161fn command_available(command: &str) -> bool {
2162 let path = Path::new(command);
2163 if path.components().count() > 1 || path.is_absolute() {
2164 return path.is_file();
2165 }
2166 let Some(paths) = std::env::var_os("PATH") else {
2167 return false;
2168 };
2169 std::env::split_paths(&paths).any(|dir| dir.join(command).is_file())
2170}
2171
2172fn run_engine_team(name: &str, options: EngineRunOptions) -> EngineResult {
2173 let team_size = options.team_size;
2174 let (tx, rx) = mpsc::channel();
2175 for index in 1..=team_size {
2176 let tx = tx.clone();
2177 let name = name.to_string();
2178 let mut sub_options = options.clone();
2179 sub_options.team_size = 0;
2180 sub_options.is_sub_agent = true;
2181 sub_options.role = format!("{}:sub-agent-{index}", options.role);
2182 sub_options.prompt =
2183 build_sub_agent_prompt(&options.prompt, &options.role, index, team_size);
2184 thread::spawn(move || {
2185 let _ = tx.send((index, run_engine_single(&name, sub_options, vec![])));
2186 });
2187 }
2188 drop(tx);
2189
2190 let mut indexed = rx.into_iter().collect::<Vec<_>>();
2191 indexed.sort_by_key(|(index, _)| *index);
2192 let sub_agents = indexed
2193 .into_iter()
2194 .map(|(_, result)| result)
2195 .collect::<Vec<_>>();
2196 let mut lead_options = options.clone();
2197 lead_options.prompt = build_team_lead_prompt(&options.prompt, &sub_agents);
2198 lead_options.is_sub_agent = true;
2199 run_engine_single(name, lead_options, sub_agents)
2200}
2201
2202fn run_engine_single(
2203 name: &str,
2204 options: EngineRunOptions,
2205 sub_agents: Vec<EngineResult>,
2206) -> EngineResult {
2207 let started = Instant::now();
2208 let bin = resolve_binary(name);
2209 let live = options.live;
2210 let start_message = format!(
2211 "[amon-hen] start {} role={} iteration={}/{}{}",
2212 name,
2213 options.role,
2214 options.iteration,
2215 options.total_iterations,
2216 if options.is_sub_agent {
2217 " sub-agent"
2218 } else {
2219 ""
2220 }
2221 );
2222 emit_runtime_event(
2223 &options.progress,
2224 live && options.progress.is_none(),
2225 progress_event(
2226 Some(name),
2227 Some(&options.role),
2228 ProgressStage::Start,
2229 Some("running"),
2230 start_message,
2231 ),
2232 );
2233 let result = match Engine::parse(name) {
2234 Some(Engine::Codex) => run_codex(&bin, &options),
2235 Some(Engine::Claude) => run_claude(&bin, &options),
2236 Some(Engine::Gemini) => run_gemini(&bin, &options),
2237 None => CommandResult {
2238 command: name.to_string(),
2239 args: vec![],
2240 code: None,
2241 stdout: String::new(),
2242 stderr: String::new(),
2243 timed_out: false,
2244 error: Some(format!("Unknown engine: {name}")),
2245 timeout_ms: options.timeout_ms,
2246 duration_ms: started.elapsed().as_millis(),
2247 },
2248 };
2249 let engine_progress = options.progress.clone();
2250 let engine = finalize_engine(
2251 name,
2252 &bin,
2253 started.elapsed().as_millis(),
2254 result,
2255 options,
2256 sub_agents,
2257 );
2258 if live {
2259 let done_message = format!(
2260 "[amon-hen] done {} role={} status={} elapsed={:.1}s tokens={} tools={} sub-agents={}",
2261 engine.name,
2262 engine.role,
2263 engine.status,
2264 engine.duration_ms as f64 / 1000.0,
2265 engine.token_usage.total,
2266 engine.tool_calls.len(),
2267 engine.sub_agents.len()
2268 );
2269 emit_runtime_event(
2270 &engine_progress,
2271 live && engine_progress.is_none(),
2272 progress_event(
2273 Some(&engine.name),
2274 Some(&engine.role),
2275 ProgressStage::Done,
2276 Some(&engine.status),
2277 done_message,
2278 ),
2279 );
2280 }
2281 engine
2282}
2283
2284fn push_arg(args: &mut Vec<String>, flag: &str, value: impl Into<String>) {
2285 args.push(flag.to_string());
2286 args.push(value.into());
2287}
2288
2289fn push_optional_arg(args: &mut Vec<String>, flag: &str, value: &Option<String>) {
2290 if let Some(value) = value {
2291 push_arg(args, flag, value.clone());
2292 }
2293}
2294
2295fn push_repeated_flag(args: &mut Vec<String>, flag: &str, values: &[String]) {
2296 if !values.is_empty() {
2297 args.push(flag.to_string());
2298 args.extend(values.iter().cloned());
2299 }
2300}
2301
2302fn push_each_arg(args: &mut Vec<String>, flag: &str, values: &[String]) {
2303 for value in values {
2304 push_arg(args, flag, value.clone());
2305 }
2306}
2307
2308fn run_codex(bin: &str, options: &EngineRunOptions) -> CommandResult {
2309 let temp = match tempfile::tempdir() {
2310 Ok(temp) => temp,
2311 Err(error) => {
2312 return CommandResult {
2313 command: bin.to_string(),
2314 args: vec![],
2315 code: None,
2316 stdout: String::new(),
2317 stderr: String::new(),
2318 timed_out: false,
2319 error: Some(error.to_string()),
2320 timeout_ms: options.timeout_ms,
2321 duration_ms: 0,
2322 }
2323 }
2324 };
2325 let output_path = temp.path().join("last-message.txt");
2326 let mut args = vec!["exec".to_string()];
2327 push_optional_arg(&mut args, "--model", &options.model);
2328 if let Some(effort) = &options.effort {
2329 push_arg(&mut args, "-c", format!("model_reasoning_effort={effort}"));
2330 }
2331 if options.capability.mode == CAPABILITY_OVERRIDE {
2332 for config in &options.capability.config {
2333 push_arg(&mut args, "-c", config.clone());
2334 }
2335 push_optional_arg(&mut args, "--profile", &options.capability.mcp_profile);
2336 }
2337 args.extend([
2338 "--skip-git-repo-check".to_string(),
2339 "--sandbox".to_string(),
2340 options
2341 .permission
2342 .clone()
2343 .unwrap_or_else(|| "read-only".to_string()),
2344 "--ephemeral".to_string(),
2345 "--json".to_string(),
2346 "-o".to_string(),
2347 output_path.display().to_string(),
2348 "-".to_string(),
2349 ]);
2350 let mut result = run_command(
2351 bin,
2352 &args,
2353 &options.cwd,
2354 Some(&options.prompt),
2355 options.timeout_ms,
2356 HashMap::new(),
2357 command_progress(
2358 live_label("codex", options).as_deref(),
2359 options.progress.clone(),
2360 ),
2361 );
2362 if let Ok(output) = fs::read_to_string(output_path) {
2363 if !output.trim().is_empty() {
2364 result.stdout = output;
2365 }
2366 }
2367 result
2368}
2369
2370fn run_claude(bin: &str, options: &EngineRunOptions) -> CommandResult {
2371 let mut args = Vec::new();
2372 if should_use_claude_bare_mode(&options.auth) {
2373 args.push("--bare".to_string());
2374 }
2375 args.extend([
2376 "-p".to_string(),
2377 "--permission-mode".to_string(),
2378 options
2379 .permission
2380 .clone()
2381 .unwrap_or_else(|| "plan".to_string()),
2382 "--verbose".to_string(),
2383 "--output-format".to_string(),
2384 "stream-json".to_string(),
2385 "--include-partial-messages".to_string(),
2386 "--no-session-persistence".to_string(),
2387 ]);
2388 push_optional_arg(&mut args, "--model", &options.model);
2389 if let Some(effort) = options
2390 .effort
2391 .clone()
2392 .or_else(|| std::env::var("CLAUDE_CODE_EFFORT_LEVEL").ok())
2393 {
2394 push_arg(&mut args, "--effort", effort);
2395 }
2396 if options.capability.mode == CAPABILITY_OVERRIDE {
2397 push_repeated_flag(&mut args, "--mcp-config", &options.capability.mcp_config);
2398 push_repeated_flag(
2399 &mut args,
2400 "--allowedTools",
2401 &options.capability.allowed_tools,
2402 );
2403 push_repeated_flag(
2404 &mut args,
2405 "--disallowedTools",
2406 &options.capability.disallowed_tools,
2407 );
2408 push_repeated_flag(&mut args, "--tools", &options.capability.tools);
2409 push_optional_arg(&mut args, "--agent", &options.capability.agent);
2410 push_optional_arg(&mut args, "--agents", &options.capability.agents_json);
2411 push_each_arg(&mut args, "--plugin-dir", &options.capability.plugin_dirs);
2412 if options.capability.strict_mcp_config {
2413 args.push("--strict-mcp-config".to_string());
2414 }
2415 if options.capability.disable_slash_commands {
2416 args.push("--disable-slash-commands".to_string());
2417 }
2418 }
2419 run_command(
2420 bin,
2421 &args,
2422 &options.cwd,
2423 Some(&options.prompt),
2424 options.timeout_ms,
2425 HashMap::new(),
2426 command_progress(
2427 live_label("claude", options).as_deref(),
2428 options.progress.clone(),
2429 ),
2430 )
2431}
2432
2433fn run_gemini(bin: &str, options: &EngineRunOptions) -> CommandResult {
2434 let mut args = Vec::new();
2435 push_optional_arg(&mut args, "--model", &options.model);
2436 if options.capability.mode == CAPABILITY_OVERRIDE {
2437 push_repeated_flag(&mut args, "--extensions", &options.capability.tools_profile);
2438 push_repeated_flag(
2439 &mut args,
2440 "--allowed-mcp-server-names",
2441 &options.capability.allowed_mcp_servers,
2442 );
2443 push_repeated_flag(&mut args, "--policy", &options.capability.policy);
2444 push_repeated_flag(
2445 &mut args,
2446 "--admin-policy",
2447 &options.capability.admin_policy,
2448 );
2449 }
2450 args.extend([
2451 "-p".to_string(),
2452 options.prompt.clone(),
2453 "--skip-trust".to_string(),
2454 "--approval-mode".to_string(),
2455 "plan".to_string(),
2456 "--output-format".to_string(),
2457 "json".to_string(),
2458 ]);
2459 let mut envs = HashMap::new();
2460 let effort_settings = prepare_gemini_settings(options);
2461 if let Some(path) = effort_settings.as_ref() {
2462 envs.insert(
2463 "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2464 path.path.display().to_string(),
2465 );
2466 } else if options.capability.mode == CAPABILITY_OVERRIDE {
2467 if let Some(settings) = &options.capability.settings {
2468 envs.insert(
2469 "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2470 settings.clone(),
2471 );
2472 }
2473 }
2474 run_command(
2475 bin,
2476 &args,
2477 &options.cwd,
2478 None,
2479 options.timeout_ms,
2480 envs,
2481 command_progress(
2482 live_label("gemini", options).as_deref(),
2483 options.progress.clone(),
2484 ),
2485 )
2486}
2487
2488fn live_label(name: &str, options: &EngineRunOptions) -> Option<String> {
2489 options.live.then(|| {
2490 format!(
2491 "{} {} iteration {}/{}",
2492 name, options.role, options.iteration, options.total_iterations
2493 )
2494 })
2495}
2496
2497fn provider_from_live_label(label: &str) -> Option<&str> {
2498 let provider = label.split_whitespace().next()?;
2499 ENGINES.contains(&provider).then_some(provider)
2500}
2501
2502fn role_from_live_label(label: &str) -> Option<&str> {
2503 if provider_from_live_label(label).is_some() {
2504 return label.split_whitespace().nth(1);
2505 }
2506 None
2507}
2508
2509fn prepare_gemini_settings(options: &EngineRunOptions) -> Option<TempSettings> {
2510 let effort = options.effort.as_deref()?;
2511 let budget = match effort {
2512 "low" => 1024,
2513 "medium" => 8192,
2514 "high" => 24576,
2515 _ => return None,
2516 };
2517 let dir = tempfile::tempdir().ok()?;
2518 let path = dir.path().join("settings.json");
2519 let mut settings = if options.capability.mode == CAPABILITY_OVERRIDE {
2520 options
2521 .capability
2522 .settings
2523 .as_ref()
2524 .and_then(|path| fs::read_to_string(path).ok())
2525 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
2526 .unwrap_or_else(|| Value::Object(Default::default()))
2527 } else {
2528 Value::Object(Default::default())
2529 };
2530 if let Value::Object(object) = &mut settings {
2531 object.insert("thinkingBudget".to_string(), Value::from(budget));
2532 }
2533 let _ = fs::write(&path, serde_json::to_vec(&settings).ok()?);
2534 Some(TempSettings { _dir: dir, path })
2535}
2536
2537fn should_use_claude_bare_mode(auth: &str) -> bool {
2538 if auth == "api-key" {
2539 return true;
2540 }
2541 if matches!(auth, "social-login" | "oauth" | "keychain") {
2542 return false;
2543 }
2544 if std::env::var("CLAUDE_CODE_OAUTH_TOKEN")
2545 .ok()
2546 .filter(|value| !value.trim().is_empty())
2547 .is_some()
2548 {
2549 return false;
2550 }
2551 std::env::var("ANTHROPIC_API_KEY")
2552 .ok()
2553 .filter(|value| !value.trim().is_empty())
2554 .is_some()
2555}
2556
2557fn run_command(
2558 command: &str,
2559 args: &[String],
2560 cwd: &Path,
2561 stdin_text: Option<&str>,
2562 timeout_ms: u64,
2563 envs: HashMap<String, String>,
2564 progress: Option<CommandProgress>,
2565) -> CommandResult {
2566 let started = Instant::now();
2567 if let Some(progress) = &progress {
2568 emit_runtime_event(
2569 &progress.sink,
2570 progress.sink.is_none(),
2571 progress_event(
2572 provider_from_live_label(&progress.label),
2573 role_from_live_label(&progress.label),
2574 ProgressStage::Spawn,
2575 Some("running"),
2576 format!("[amon-hen] spawn {}", progress.label),
2577 ),
2578 );
2579 }
2580 let mut child = match Command::new(command)
2581 .args(args)
2582 .current_dir(cwd)
2583 .envs(envs)
2584 .stdin(Stdio::piped())
2585 .stdout(Stdio::piped())
2586 .stderr(Stdio::piped())
2587 .spawn()
2588 {
2589 Ok(child) => child,
2590 Err(error) => {
2591 if let Some(progress) = &progress {
2592 emit_runtime_event(
2593 &progress.sink,
2594 progress.sink.is_none(),
2595 progress_event(
2596 provider_from_live_label(&progress.label),
2597 role_from_live_label(&progress.label),
2598 ProgressStage::Done,
2599 Some("missing"),
2600 format!("[amon-hen] spawn failed {}: {error}", progress.label),
2601 ),
2602 );
2603 }
2604 return CommandResult {
2605 command: command.to_string(),
2606 args: args.to_vec(),
2607 code: None,
2608 stdout: String::new(),
2609 stderr: String::new(),
2610 timed_out: false,
2611 error: Some(error.to_string()),
2612 timeout_ms,
2613 duration_ms: started.elapsed().as_millis(),
2614 };
2615 }
2616 };
2617
2618 if let Some(mut stdin) = child.stdin.take() {
2619 if let Some(text) = stdin_text {
2620 let _ = stdin.write_all(text.as_bytes());
2621 }
2622 }
2623
2624 let stdout = child.stdout.take().map(read_pipe);
2625 let stderr = child.stderr.take().map(read_pipe);
2626 let timeout = Duration::from_millis(timeout_ms);
2627 let mut next_live_tick = Duration::from_secs(10);
2628 let mut timed_out = false;
2629 let code;
2630 loop {
2631 match child.try_wait() {
2632 Ok(Some(status)) => {
2633 code = status.code();
2634 break;
2635 }
2636 Ok(None) => {
2637 if timeout_ms > 0 && started.elapsed() >= timeout {
2638 timed_out = true;
2639 let _ = child.kill();
2640 let status = child.wait().ok();
2641 code = status.and_then(|status| status.code());
2642 break;
2643 }
2644 if let Some(progress) = &progress {
2645 let elapsed = started.elapsed();
2646 if elapsed >= next_live_tick {
2647 let timeout_detail = if timeout_ms > 0 {
2648 format!("/{}s", timeout_ms / 1000)
2649 } else {
2650 String::new()
2651 };
2652 emit_runtime_event(
2653 &progress.sink,
2654 progress.sink.is_none(),
2655 progress_event(
2656 provider_from_live_label(&progress.label),
2657 role_from_live_label(&progress.label),
2658 ProgressStage::Heartbeat,
2659 Some("running"),
2660 format!(
2661 "[amon-hen] running {} for {}s{}",
2662 progress.label,
2663 elapsed.as_secs(),
2664 timeout_detail
2665 ),
2666 ),
2667 );
2668 next_live_tick += Duration::from_secs(10);
2669 }
2670 }
2671 thread::sleep(Duration::from_millis(25));
2672 }
2673 Err(error) => {
2674 return CommandResult {
2675 command: command.to_string(),
2676 args: args.to_vec(),
2677 code: None,
2678 stdout: String::new(),
2679 stderr: String::new(),
2680 timed_out,
2681 error: Some(error.to_string()),
2682 timeout_ms,
2683 duration_ms: started.elapsed().as_millis(),
2684 }
2685 }
2686 }
2687 }
2688
2689 CommandResult {
2690 command: command.to_string(),
2691 args: args.to_vec(),
2692 code,
2693 stdout: stdout
2694 .and_then(|handle| handle.join().ok())
2695 .unwrap_or_default(),
2696 stderr: stderr
2697 .and_then(|handle| handle.join().ok())
2698 .unwrap_or_default(),
2699 timed_out,
2700 error: None,
2701 timeout_ms,
2702 duration_ms: started.elapsed().as_millis(),
2703 }
2704}
2705
2706fn read_pipe<R>(mut pipe: R) -> thread::JoinHandle<String>
2707where
2708 R: Read + Send + 'static,
2709{
2710 thread::spawn(move || {
2711 let mut text = String::new();
2712 let _ = pipe.read_to_string(&mut text);
2713 text
2714 })
2715}
2716
2717fn finalize_engine(
2718 name: &str,
2719 bin: &str,
2720 duration_ms: u128,
2721 command_result: CommandResult,
2722 options: EngineRunOptions,
2723 sub_agents: Vec<EngineResult>,
2724) -> EngineResult {
2725 let output = match Engine::parse(name) {
2726 Some(Engine::Claude) => parse_claude_output(&command_result.stdout),
2727 Some(Engine::Gemini) => parse_gemini_output(&command_result.stdout),
2728 Some(Engine::Codex) => parse_codex_output(&command_result.stdout),
2729 None => command_result.stdout.trim().to_string(),
2730 };
2731 let status = if command_result.error.is_some() {
2732 "missing"
2733 } else if command_result.timed_out {
2734 "timeout"
2735 } else if command_result.code != Some(0) || output.trim().is_empty() {
2736 "error"
2737 } else {
2738 "ok"
2739 };
2740 let detail = if status == "ok" {
2741 String::new()
2742 } else if command_result.timed_out {
2743 format!("Timed out after {}s.", command_result.timeout_ms / 1000)
2744 } else {
2745 compact_failure(&command_result)
2746 };
2747 let usage = aggregate_token_usage(
2748 extract_token_usage(&command_result.stdout, &options.prompt, &output)
2749 .unwrap_or_else(|| token_usage(&options.prompt, &output)),
2750 &sub_agents,
2751 );
2752 let tool_calls = extract_tool_usage(name, &command_result.stdout, &command_result.stderr);
2753 EngineResult {
2754 name: name.to_string(),
2755 bin: Some(bin.to_string()),
2756 status: status.to_string(),
2757 duration_ms,
2758 detail,
2759 exit_code: command_result.code,
2760 stdout: command_result.stdout,
2761 stderr: command_result.stderr,
2762 output,
2763 command: format_command(&command_result.command, &command_result.args),
2764 token_usage: usage,
2765 tool_calls,
2766 sub_agents,
2767 role: options.role,
2768 iteration: options.iteration,
2769 total_iterations: options.total_iterations,
2770 team_size: options.team_size,
2771 }
2772}
2773
2774fn compact_failure(result: &CommandResult) -> String {
2775 if let Some(error) = &result.error {
2776 return error.clone();
2777 }
2778 if !result.stderr.trim().is_empty() {
2779 return result.stderr.trim().to_string();
2780 }
2781 if !result.stdout.trim().is_empty() {
2782 return result.stdout.trim().to_string();
2783 }
2784 result
2785 .code
2786 .map(|code| format!("Exited with code {code}."))
2787 .unwrap_or_else(|| "Command failed.".to_string())
2788}
2789
2790fn command_telemetry(result: &CommandResult) -> CommandTelemetry {
2791 let status = if result.error.is_some() {
2792 "missing"
2793 } else if result.timed_out {
2794 "timeout"
2795 } else if result.code == Some(0) {
2796 "ok"
2797 } else {
2798 "error"
2799 };
2800 CommandTelemetry {
2801 command: format_command(&result.command, &result.args),
2802 status: status.to_string(),
2803 detail: truncate(&sanitize_status_detail(&compact_failure(result)), 600),
2804 exit_code: result.code,
2805 duration_ms: result.duration_ms,
2806 stdout_chars: result.stdout.len(),
2807 stderr_chars: result.stderr.len(),
2808 timed_out: result.timed_out,
2809 }
2810}
2811
2812fn parse_codex_output(stdout: &str) -> String {
2813 stdout.trim().to_string()
2814}
2815
2816fn parse_claude_output(stdout: &str) -> String {
2817 let trimmed = stdout.trim();
2818 if trimmed.is_empty() {
2819 return String::new();
2820 }
2821 if let Some(value) = extract_json(trimmed) {
2822 if let Some(result) = value.get("result").and_then(Value::as_str) {
2823 return result.trim().to_string();
2824 }
2825 }
2826 let mut latest = String::new();
2827 for line in trimmed.lines() {
2828 let Ok(value) = serde_json::from_str::<Value>(line) else {
2829 continue;
2830 };
2831 if value.get("type").and_then(Value::as_str) == Some("result") {
2832 if let Some(result) = value.get("result").and_then(Value::as_str) {
2833 latest = result.trim().to_string();
2834 }
2835 }
2836 if value.get("type").and_then(Value::as_str) == Some("assistant") {
2837 if let Some(content) = value.pointer("/message/content").and_then(Value::as_array) {
2838 let text = content
2839 .iter()
2840 .filter_map(|block| {
2841 (block.get("type").and_then(Value::as_str) == Some("text"))
2842 .then(|| block.get("text").and_then(Value::as_str))
2843 .flatten()
2844 })
2845 .collect::<String>();
2846 if !text.trim().is_empty() {
2847 latest = text.trim().to_string();
2848 }
2849 }
2850 }
2851 }
2852 if latest.is_empty() {
2853 trimmed.to_string()
2854 } else {
2855 latest
2856 }
2857}
2858
2859fn parse_gemini_output(stdout: &str) -> String {
2860 let trimmed = stdout.trim();
2861 if trimmed.is_empty() {
2862 return String::new();
2863 }
2864 if let Some(value) = extract_json(trimmed) {
2865 if let Some(response) = value.get("response").and_then(Value::as_str) {
2866 return response.trim().to_string();
2867 }
2868 }
2869 trimmed.to_string()
2870}
2871
2872fn extract_json(text: &str) -> Option<Value> {
2873 serde_json::from_str::<Value>(text).ok().or_else(|| {
2874 let start = text.find('{')?;
2875 let end = text.rfind('}')?;
2876 serde_json::from_str::<Value>(&text[start..=end]).ok()
2877 })
2878}
2879
2880fn build_member_prompt(
2881 query: &str,
2882 role: &str,
2883 workflow: &Workflow,
2884 iteration: usize,
2885 previous_iteration: &[EngineResult],
2886 handoff_results: &[EngineResult],
2887 plan_output: &str,
2888) -> String {
2889 let mut sections = vec![
2890 "You are one member of a multi-model Amon Hen run.".to_string(),
2891 format!(
2892 "Amon Hen workflow: iteration {iteration} of {}.",
2893 workflow.iterations
2894 ),
2895 workflow
2896 .lead
2897 .as_ref()
2898 .map(|lead| format!("Lead model: {lead}."))
2899 .unwrap_or_else(|| "Lead model: auto.".to_string()),
2900 workflow
2901 .planner
2902 .as_ref()
2903 .map(|planner| format!("Planner model: {planner}."))
2904 .unwrap_or_else(|| "Planner model: none.".to_string()),
2905 format!("Your assigned role: {role}."),
2906 role_instruction(role).to_string(),
2907 "Answer the user query directly.".to_string(),
2908 "Do not introduce yourself.".to_string(),
2909 "Do not describe your tools, environment, or capabilities unless the user explicitly asks."
2910 .to_string(),
2911 "Be concise unless the user asks for depth.".to_string(),
2912 ];
2913 if workflow.team_work > 0 {
2914 sections.push(format!(
2915 "Team work: you may coordinate up to {} internal sub-agents or subtasks inside your own CLI if that helps.",
2916 workflow.team_work
2917 ));
2918 }
2919 if workflow.handoff {
2920 sections.push(
2921 "Handoff mode is enabled. Treat earlier Amon Hen outputs as working context."
2922 .to_string(),
2923 );
2924 }
2925 if !plan_output.trim().is_empty() {
2926 sections.push(format!("Planner handoff:\n{}", plan_output.trim()));
2927 }
2928 let context = previous_iteration
2929 .iter()
2930 .chain(handoff_results.iter())
2931 .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
2932 .map(|result| format!("### {}\n{}", result.name, result.output.trim()))
2933 .collect::<Vec<_>>()
2934 .join("\n\n");
2935 if !context.is_empty() {
2936 sections.push(format!("Earlier Amon Hen handoffs:\n{context}"));
2937 }
2938 sections.push(format!("Current user query:\n{}", query.trim()));
2939 sections.join("\n\n")
2940}
2941
2942fn build_summary_prompt(
2943 query: &str,
2944 responses: &[EngineResult],
2945 workflow: &Workflow,
2946 max_member_chars: usize,
2947) -> String {
2948 let blocks = responses
2949 .iter()
2950 .map(|response| {
2951 let output = truncate(&response.output, max_member_chars);
2952 format!("### {}\n{}", response.name, output.trim())
2953 })
2954 .collect::<Vec<_>>()
2955 .join("\n\n");
2956 format!(
2957 "You are synthesizing answers from multiple AI CLI tools.\n\nAmon Hen workflow: {} iteration{}, {}.\nLead model: {}.\nPlanner model: {}.\nProduce one final answer to the original user query.\nAnswer directly. Call out meaningful disagreement or uncertainty when it exists.\n\nCurrent user query:\n{}\n\nAmon Hen member responses:\n{}",
2958 workflow.iterations,
2959 if workflow.iterations == 1 { "" } else { "s" },
2960 if workflow.handoff { "handoff enabled" } else { "parallel consultation" },
2961 workflow.lead.as_deref().unwrap_or("auto"),
2962 workflow.planner.as_deref().unwrap_or("none"),
2963 query.trim(),
2964 blocks
2965 )
2966}
2967
2968fn role_instruction(role: &str) -> &'static str {
2969 match role {
2970 "planner" => "Plan the work: identify the approach, risks, checkpoints, and useful handoffs for the executors.",
2971 "lead" => "Lead the work: make the strongest direct attempt while watching for conflicts you may need to resolve in synthesis.",
2972 "lead+planner" => "Plan and lead the work: produce a practical plan, then make the strongest direct attempt from that plan.",
2973 _ => "Execute the work: use any plan or handoff context, then produce your independent best answer.",
2974 }
2975}
2976
2977fn execution_order(members: &[String], planner: Option<&str>) -> Vec<String> {
2978 let mut ordered = Vec::new();
2979 if let Some(planner) = planner {
2980 if members.iter().any(|member| member == planner) {
2981 ordered.push(planner.to_string());
2982 }
2983 }
2984 ordered.extend(
2985 members
2986 .iter()
2987 .filter(|member| Some(member.as_str()) != planner)
2988 .cloned(),
2989 );
2990 ordered
2991}
2992
2993fn role_for(member: &str, workflow: &Workflow) -> String {
2994 let is_lead = workflow.lead.as_deref() == Some(member);
2995 let is_planner = workflow.planner.as_deref() == Some(member);
2996 match (is_lead, is_planner) {
2997 (true, true) => "lead+planner",
2998 (true, false) => "lead",
2999 (false, true) => "planner",
3000 (false, false) => "executor",
3001 }
3002 .to_string()
3003}
3004
3005fn pick_summarizer(resolved: &ResolvedArgs, successes: &[EngineResult]) -> String {
3006 if resolved.raw.summarizer != "auto" {
3007 return resolved.raw.summarizer.clone();
3008 }
3009 if let Some(lead) = &resolved.raw.lead {
3010 if successes.iter().any(|result| &result.name == lead) {
3011 return lead.clone();
3012 }
3013 }
3014 DEFAULT_SUMMARIZER_ORDER
3015 .iter()
3016 .find(|name| successes.iter().any(|result| result.name == **name))
3017 .unwrap_or(&successes[0].name.as_str())
3018 .to_string()
3019}
3020
3021fn resolve_binary(name: &str) -> String {
3022 let Some(engine) = Engine::parse(name) else {
3023 return name.to_string();
3024 };
3025 std::env::var(engine.binary_env_var())
3026 .ok()
3027 .filter(|value| !value.trim().is_empty())
3028 .unwrap_or_else(|| engine.as_str().to_string())
3029}
3030
3031fn token_usage(prompt: &str, output: &str) -> TokenUsage {
3032 let input = estimate_tokens(prompt);
3033 let output = estimate_tokens(output);
3034 TokenUsage {
3035 input,
3036 output,
3037 total: input + output,
3038 estimated: true,
3039 source: "estimate".to_string(),
3040 }
3041}
3042
3043fn aggregate_token_usage(mut usage: TokenUsage, sub_agents: &[EngineResult]) -> TokenUsage {
3044 if sub_agents.is_empty() {
3045 return usage;
3046 }
3047 for agent in sub_agents {
3048 usage.input += agent.token_usage.input;
3049 usage.output += agent.token_usage.output;
3050 usage.total += agent.token_usage.total;
3051 usage.estimated |= agent.token_usage.estimated;
3052 }
3053 usage.source = format!("{}+sub-agents", usage.source);
3054 usage
3055}
3056
3057fn extract_token_usage(stdout: &str, prompt: &str, output: &str) -> Option<TokenUsage> {
3058 let mut usage = TokenAccumulator::default();
3059 for value in json_values(stdout) {
3060 collect_token_counts(&value, &mut usage);
3061 }
3062 if usage.input.is_none() && usage.output.is_none() && usage.total.is_none() {
3063 return None;
3064 }
3065 let input = usage.input.unwrap_or_else(|| estimate_tokens(prompt));
3066 let output = usage.output.unwrap_or_else(|| estimate_tokens(output));
3067 let total = usage.total.unwrap_or(input + output);
3068 Some(TokenUsage {
3069 input,
3070 output,
3071 total,
3072 estimated: usage.input.is_none() || usage.output.is_none(),
3073 source: "provider".to_string(),
3074 })
3075}
3076
3077#[derive(Default)]
3078struct TokenAccumulator {
3079 input: Option<usize>,
3080 output: Option<usize>,
3081 total: Option<usize>,
3082}
3083
3084fn collect_token_counts(value: &Value, usage: &mut TokenAccumulator) {
3085 match value {
3086 Value::Object(map) => {
3087 for (key, value) in map {
3088 let normalized = key.replace(['_', '-'], "").to_ascii_lowercase();
3089 if let Some(count) = value.as_u64().map(|value| value as usize) {
3090 match normalized.as_str() {
3091 "inputtokens" | "inputtokencount" | "prompttokens" | "prompttokencount" => {
3092 usage.input = Some(count)
3093 }
3094 "outputtokens"
3095 | "outputtokencount"
3096 | "completiontokens"
3097 | "candidatestokencount" => usage.output = Some(count),
3098 "totaltokens" | "totaltokencount" => usage.total = Some(count),
3099 _ => {}
3100 }
3101 }
3102 collect_token_counts(value, usage);
3103 }
3104 }
3105 Value::Array(values) => {
3106 for value in values {
3107 collect_token_counts(value, usage);
3108 }
3109 }
3110 _ => {}
3111 }
3112}
3113
3114fn extract_tool_usage(provider: &str, stdout: &str, stderr: &str) -> Vec<ToolUsage> {
3115 let mut tools = Vec::new();
3116 for value in json_values(stdout) {
3117 collect_tool_usage(&value, &mut tools);
3118 }
3119 for line in stdout.lines().chain(stderr.lines()) {
3120 if let Some(tool) = line_tool_usage(provider, line) {
3121 tools.push(tool);
3122 }
3123 }
3124 dedupe_tools(tools)
3125}
3126
3127fn collect_tool_usage(value: &Value, tools: &mut Vec<ToolUsage>) {
3128 match value {
3129 Value::Object(map) => {
3130 let type_name = map.get("type").and_then(Value::as_str).unwrap_or_default();
3131 let name = map
3132 .get("name")
3133 .or_else(|| map.get("tool"))
3134 .or_else(|| map.get("tool_name"))
3135 .or_else(|| map.get("toolName"))
3136 .and_then(Value::as_str);
3137 if let Some(name) = name.filter(|_| {
3138 type_name.contains("tool")
3139 || map.contains_key("input")
3140 || map.contains_key("arguments")
3141 || map.contains_key("toolCall")
3142 }) {
3143 tools.push(ToolUsage {
3144 name: name.to_string(),
3145 kind: if type_name.is_empty() {
3146 "tool"
3147 } else {
3148 type_name
3149 }
3150 .to_string(),
3151 status: "observed".to_string(),
3152 detail: short_json_detail(value),
3153 });
3154 }
3155 for value in map.values() {
3156 collect_tool_usage(value, tools);
3157 }
3158 }
3159 Value::Array(values) => {
3160 for value in values {
3161 collect_tool_usage(value, tools);
3162 }
3163 }
3164 _ => {}
3165 }
3166}
3167
3168fn line_tool_usage(provider: &str, line: &str) -> Option<ToolUsage> {
3169 let trimmed = line.trim();
3170 let lowered = trimmed.to_ascii_lowercase();
3171 let marker = ["running shell:", "tool:", "tool_use", "mcp:", "command:"]
3172 .iter()
3173 .find(|marker| lowered.contains(**marker))?;
3174 Some(ToolUsage {
3175 name: marker.trim_end_matches(':').to_string(),
3176 kind: provider.to_string(),
3177 status: "observed".to_string(),
3178 detail: truncate(trimmed, 240),
3179 })
3180}
3181
3182fn short_json_detail(value: &Value) -> String {
3183 serde_json::to_string(value)
3184 .map(|text| truncate(&text, 240))
3185 .unwrap_or_default()
3186}
3187
3188fn dedupe_tools(tools: Vec<ToolUsage>) -> Vec<ToolUsage> {
3189 let mut seen = HashSet::new();
3190 tools
3191 .into_iter()
3192 .filter(|tool| seen.insert(format!("{}:{}:{}", tool.kind, tool.name, tool.detail)))
3193 .collect()
3194}
3195
3196fn json_values(text: &str) -> Vec<Value> {
3197 let trimmed = text.trim();
3198 if trimmed.is_empty() {
3199 return vec![];
3200 }
3201 let mut values = Vec::new();
3202 if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
3203 values.push(value);
3204 return values;
3205 }
3206 for line in trimmed.lines() {
3207 if let Ok(value) = serde_json::from_str::<Value>(line.trim()) {
3208 values.push(value);
3209 }
3210 }
3211 values
3212}
3213
3214fn estimate_tokens(text: &str) -> usize {
3215 if text.trim().is_empty() {
3216 0
3217 } else {
3218 text.len().div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN)
3219 }
3220}
3221
3222fn format_command(command: &str, args: &[String]) -> String {
3223 std::iter::once(command.to_string())
3224 .chain(args.iter().map(|arg| shell_quote(arg)))
3225 .collect::<Vec<_>>()
3226 .join(" ")
3227}
3228
3229fn shell_quote(value: &str) -> String {
3230 if value
3231 .chars()
3232 .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
3233 {
3234 value.to_string()
3235 } else {
3236 serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
3237 }
3238}
3239
3240fn truncate(text: &str, max_chars: usize) -> String {
3241 if text.chars().count() <= max_chars {
3242 text.to_string()
3243 } else {
3244 let mut truncated = text.chars().take(max_chars).collect::<String>();
3245 truncated.push_str("\n...[truncated]");
3246 truncated
3247 }
3248}
3249
3250fn should_show_banner(raw: &CliArgs) -> bool {
3251 !raw.no_banner && !raw.headless && !raw.json && !raw.json_stream && !raw.plain
3252}
3253
3254fn linear_delivery_requested(raw: &CliArgs) -> bool {
3255 raw.deliver_linear
3256 || raw.linear_until_complete
3257 || raw.linear_watch
3258 || !raw.linear_issue.is_empty()
3259 || raw
3260 .linear_query
3261 .as_ref()
3262 .is_some_and(|value| !value.trim().is_empty())
3263 || !raw.linear_project.is_empty()
3264 || !raw.linear_epic.is_empty()
3265}
3266
3267fn render_banner() -> &'static str {
3268 " _ _ _ \n / \\ _ __ ___ ___ _ __ | | | | ___ _ __ \n / _ \\ | '_ ` _ \\ / _ \\| '_ \\| |_| |/ _ \\ '_ \\ \n / ___ \\| | | | | | (_) | | | | _ | __/ | | |\n/_/ \\_\\_| |_| |_|\\___/|_| |_|_| |_|\\___|_| |_|"
3269}
3270
3271fn render_human_result(result: &AmonHenResult, verbose: bool) -> String {
3272 let mut lines = Vec::new();
3273 if verbose {
3274 lines.push(format!(
3275 "Amon Hen consulted: {}",
3276 result.members_requested.join(", ")
3277 ));
3278 for command in &result.prompt_commands {
3279 lines.push(format!(
3280 "cmd [{}] {} ({:.1}s)",
3281 command.status,
3282 command.command,
3283 command.duration_ms as f64 / 1000.0
3284 ));
3285 }
3286 for (index, member) in result.members.iter().enumerate() {
3287 lines.push(format!(
3288 "{}. [{}] {} ({:.1}s, tokens:{}, tools:{}, sub-agents:{}){}",
3289 index + 1,
3290 member.status,
3291 member.name,
3292 member.duration_ms as f64 / 1000.0,
3293 member.token_usage.total,
3294 member.tool_calls.len(),
3295 member.sub_agents.len(),
3296 if member.detail.is_empty() { "" } else { ": " }
3297 ));
3298 if !member.detail.is_empty() {
3299 lines.push(format!(" {}", member.detail));
3300 }
3301 for sub_agent in &member.sub_agents {
3302 lines.push(format!(
3303 " - {} [{}] tokens:{} tools:{}",
3304 sub_agent.role,
3305 sub_agent.status,
3306 sub_agent.token_usage.total,
3307 sub_agent.tool_calls.len()
3308 ));
3309 }
3310 if member.status == "ok" {
3311 lines.push(indent(&member.output, " "));
3312 }
3313 }
3314 lines.push("----------- synthesis -----------".to_string());
3315 }
3316 if result.summary.status == "ok" {
3317 lines.push(result.summary.output.trim().to_string());
3318 } else {
3319 lines.push(format!("Synthesis failed: {}", result.summary.detail));
3320 }
3321 lines.join("\n")
3322}
3323
3324fn indent(text: &str, prefix: &str) -> String {
3325 text.lines()
3326 .map(|line| format!("{prefix}{line}"))
3327 .collect::<Vec<_>>()
3328 .join("\n")
3329}
3330
3331fn is_success(result: &AmonHenResult) -> bool {
3332 result.members.iter().any(|member| member.status == "ok") && result.summary.status == "ok"
3333}
3334
3335#[cfg(test)]
3336mod tests {
3337 use super::*;
3338
3339 #[test]
3340 fn help_flags_use_success_exit_code() {
3341 let long_help = CliArgs::try_parse_from(["amon-hen", "--help"]).unwrap_err();
3342 let short_help = CliArgs::try_parse_from(["amon-hen", "-h"]).unwrap_err();
3343
3344 assert_eq!(long_help.kind(), ErrorKind::DisplayHelp);
3345 assert_eq!(short_help.kind(), ErrorKind::DisplayHelp);
3346 assert_eq!(parse_error_exit_code(long_help.kind()), 0);
3347 assert_eq!(parse_error_exit_code(short_help.kind()), 0);
3348 }
3349
3350 #[test]
3351 fn rendered_help_is_grouped_and_compact() {
3352 let help = render_cli_help();
3353
3354 assert!(help.contains("Core run:"));
3355 assert!(help.contains("Models, effort, and permissions:"));
3356 assert!(help.contains("Auth and provider capabilities:"));
3357 assert!(help.contains("Linear delivery:"));
3358 assert!(help.contains("--members codex,claude,gemini"));
3359 assert!(help.contains("--claude-permission-mode MODE"));
3360 assert!(!help.contains("\n\n\n"));
3361 }
3362
3363 #[test]
3364 fn parses_members_and_provider_flags() {
3365 let args = CliArgs::try_parse_from([
3366 "amon-hen",
3367 "--members",
3368 "codex,claude",
3369 "--planner",
3370 "codex",
3371 "--lead",
3372 "claude",
3373 "--codex-effort",
3374 "xhigh",
3375 "--claude-capabilities",
3376 "override",
3377 "--claude-allowed-tools",
3378 "Read,Bash(git:*)",
3379 "--claude-tools",
3380 "Read,Edit",
3381 "--claude-agent",
3382 "reviewer",
3383 "--gemini-allowed-mcp-servers",
3384 "linear,github",
3385 "ship it",
3386 ])
3387 .unwrap();
3388 let resolved = resolve_args(args).unwrap();
3389 assert_eq!(resolved.members, vec!["codex", "claude"]);
3390 assert_eq!(resolved.raw.codex_effort.as_deref(), Some("xhigh"));
3391 assert_eq!(
3392 resolved.raw.claude_allowed_tools,
3393 vec!["Read", "Bash(git:*)"]
3394 );
3395 assert_eq!(resolved.raw.claude_tools, vec!["Read", "Edit"]);
3396 assert_eq!(resolved.raw.claude_agent.as_deref(), Some("reviewer"));
3397 assert_eq!(
3398 resolved.raw.gemini_allowed_mcp_servers,
3399 vec!["linear", "github"]
3400 );
3401 }
3402
3403 #[test]
3404 fn parses_historical_linear_flags_in_rust() {
3405 let args = CliArgs::try_parse_from([
3406 "amon-hen",
3407 "--banner",
3408 "--auth-open-browser",
3409 "--members",
3410 "codex,claude,gemini",
3411 "--linear-project",
3412 "ENG",
3413 "--linear-endpoint",
3414 "https://linear.example/graphql",
3415 "--linear-auth",
3416 "oauth",
3417 "--linear-max-polls",
3418 "2",
3419 "--linear-max-concurrency",
3420 "3",
3421 "--linear-workflow-file",
3422 "docs/linear-workflow.md",
3423 "--linear-workspace-strategy",
3424 "copy",
3425 "--linear-completion-gate",
3426 "review-or-ci",
3427 "--delivery-phases",
3428 "plan,implement,verify,ship",
3429 "deliver it",
3430 ])
3431 .unwrap();
3432 let resolved = resolve_args(args).unwrap();
3433
3434 assert!(linear_delivery_requested(&resolved.raw));
3435 assert_eq!(
3436 resolved.raw.linear_endpoint.as_deref(),
3437 Some("https://linear.example/graphql")
3438 );
3439 assert_eq!(resolved.raw.linear_auth, "oauth");
3440 assert_eq!(resolved.raw.linear_max_polls, Some(2));
3441 assert_eq!(resolved.raw.linear_max_concurrency, 3);
3442 assert_eq!(
3443 resolved.raw.linear_workflow_file.as_deref(),
3444 Some(Path::new("docs/linear-workflow.md"))
3445 );
3446 assert_eq!(resolved.raw.linear_workspace_strategy, "copy");
3447 assert_eq!(resolved.raw.linear_completion_gate, "review-or-ci");
3448 assert_eq!(
3449 resolved.raw.delivery_phases,
3450 vec!["plan", "implement", "verify", "ship"]
3451 );
3452 }
3453
3454 #[test]
3455 fn builds_member_prompt_with_roles_and_handoff() {
3456 let workflow = Workflow {
3457 handoff: true,
3458 lead: Some("claude".to_string()),
3459 planner: Some("codex".to_string()),
3460 iterations: 2,
3461 team_work: 1,
3462 teams: HashMap::new(),
3463 };
3464 let prompt = build_member_prompt("Fix the bug", "planner", &workflow, 1, &[], &[], "");
3465 assert!(prompt.contains("Amon Hen workflow: iteration 1 of 2."));
3466 assert!(prompt.contains("Lead model: claude."));
3467 assert!(prompt.contains("Planner model: codex."));
3468 assert!(prompt.contains("Your assigned role: planner."));
3469 assert!(prompt.contains("Current user query:"));
3470 }
3471
3472 #[test]
3473 fn parses_claude_stream_json_result() {
3474 let text = r#"{"type":"system","subtype":"status","status":"requesting"}
3475{"type":"result","result":"done"}"#;
3476 assert_eq!(parse_claude_output(text), "done");
3477 }
3478
3479 #[test]
3480 fn parses_gemini_json_response() {
3481 assert_eq!(parse_gemini_output(r#"{"response":"hello"}"#), "hello");
3482 }
3483
3484 #[test]
3485 fn estimates_tokens_with_ceiling_chunks() {
3486 assert_eq!(estimate_tokens(""), 0);
3487 assert_eq!(estimate_tokens("abc"), 1);
3488 assert_eq!(estimate_tokens("abcd"), 1);
3489 assert_eq!(estimate_tokens("abcde"), 2);
3490 }
3491
3492 #[test]
3493 fn extracts_social_login_urls() {
3494 assert_eq!(
3495 extract_auth_urls("Open https://example.com/callback?code=123, then continue"),
3496 vec!["https://example.com/callback?code=123"]
3497 );
3498 assert_eq!(
3499 extract_auth_urls("deeplink: claude://login/complete."),
3500 vec!["claude://login/complete"]
3501 );
3502 }
3503
3504 #[test]
3505 fn parses_provider_token_usage() {
3506 let stdout = r#"{"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}"#;
3507 let usage = extract_token_usage(stdout, "hello", "world").unwrap();
3508 assert_eq!(usage.input, 12);
3509 assert_eq!(usage.output, 8);
3510 assert_eq!(usage.total, 20);
3511 assert!(!usage.estimated);
3512 }
3513
3514 #[test]
3515 fn extracts_tool_usage_from_provider_streams() {
3516 let stdout = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}"#;
3517 let tools = extract_tool_usage("claude", stdout, "");
3518 assert!(tools.iter().any(|tool| tool.name == "Bash"));
3519 }
3520
3521 #[test]
3522 fn builds_real_sub_agent_handoff_prompt() {
3523 let agent = EngineResult {
3524 name: "codex".to_string(),
3525 bin: Some("codex".to_string()),
3526 status: "ok".to_string(),
3527 duration_ms: 1,
3528 detail: String::new(),
3529 exit_code: Some(0),
3530 stdout: String::new(),
3531 stderr: String::new(),
3532 output: "inspect parser".to_string(),
3533 command: "codex exec".to_string(),
3534 token_usage: token_usage("a", "b"),
3535 tool_calls: vec![],
3536 sub_agents: vec![],
3537 role: "executor:sub-agent-1".to_string(),
3538 iteration: 1,
3539 total_iterations: 1,
3540 team_size: 0,
3541 };
3542 let prompt = build_team_lead_prompt("ship it", &[agent]);
3543 assert!(prompt.contains("Sub-agent handoffs"));
3544 assert!(prompt.contains("inspect parser"));
3545 assert!(prompt.contains("Original provider prompt"));
3546 }
3547
3548 #[test]
3549 fn missing_provider_preflight_avoids_team_fanout() {
3550 let args = CliArgs::try_parse_from([
3551 "amon-hen",
3552 "--members",
3553 "codex",
3554 "--team-work",
3555 "5",
3556 "hello",
3557 ])
3558 .unwrap();
3559 let resolved = resolve_args(args).unwrap();
3560 let options = EngineRunOptions {
3561 prompt: "hello".to_string(),
3562 cwd: resolved.cwd.clone(),
3563 timeout_ms: 1_000,
3564 effort: None,
3565 model: None,
3566 permission: None,
3567 auth: DEFAULT_AUTH_MODE.to_string(),
3568 capability: provider_capability(&resolved, "codex"),
3569 role: "planner".to_string(),
3570 iteration: 1,
3571 total_iterations: 1,
3572 team_size: 5,
3573 is_sub_agent: false,
3574 live: false,
3575 progress: None,
3576 };
3577
3578 let result = run_engine("definitely-missing-amon-hen-test-bin", options);
3579
3580 assert_eq!(result.status, "missing");
3581 assert!(result.sub_agents.is_empty());
3582 assert!(result.detail.contains("not found in PATH"));
3583 }
3584
3585 #[test]
3586 fn system_time_is_available_for_test_environment() {
3587 assert!(std::time::SystemTime::now()
3588 .duration_since(std::time::UNIX_EPOCH)
3589 .is_ok());
3590 }
3591}