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::{Child, Command, Stdio};
10use std::sync::{
11 atomic::{AtomicBool, AtomicU64, Ordering},
12 mpsc, Arc, Mutex,
13};
14use std::thread;
15use std::time::{Duration, Instant};
16
17mod linear_delivery;
18mod studio;
19
20const VERSION: &str = env!("CARGO_PKG_VERSION");
21const DEFAULT_TIMEOUT_MS: u64 = 600_000;
22const DEFAULT_MAX_MEMBER_CHARS: usize = 12_000;
23const DEFAULT_ITERATIONS: usize = 1;
24const DEFAULT_TEAM_SIZE: usize = 0;
25const TOKEN_ESTIMATE_CHARS_PER_TOKEN: usize = 4;
26
27const ENGINES: [&str; 3] = ["codex", "claude", "gemini"];
28const DEFAULT_SUMMARIZER_ORDER: [&str; 3] = ["codex", "claude", "gemini"];
29const DEFAULT_AUTH_MODE: &str = "auto";
30const CAPABILITY_INHERIT: &str = "inherit";
31const CAPABILITY_OVERRIDE: &str = "override";
32
33#[derive(Debug, Copy, Clone, Eq, PartialEq)]
34enum Engine {
35 Codex,
36 Claude,
37 Gemini,
38}
39
40impl Engine {
41 fn parse(name: &str) -> Option<Self> {
42 match name {
43 "codex" => Some(Self::Codex),
44 "claude" => Some(Self::Claude),
45 "gemini" => Some(Self::Gemini),
46 _ => None,
47 }
48 }
49
50 fn as_str(self) -> &'static str {
51 match self {
52 Self::Codex => "codex",
53 Self::Claude => "claude",
54 Self::Gemini => "gemini",
55 }
56 }
57
58 fn binary_env_var(self) -> &'static str {
59 match self {
60 Self::Codex => "AMON_HEN_CODEX_BIN",
61 Self::Claude => "AMON_HEN_CLAUDE_BIN",
62 Self::Gemini => "AMON_HEN_GEMINI_BIN",
63 }
64 }
65
66 fn allowed_efforts(self) -> &'static [&'static str] {
67 match self {
68 Self::Codex => &["low", "medium", "high", "xhigh", ""],
69 Self::Claude => &["low", "medium", "high", "xhigh", "max"],
70 Self::Gemini => &["low", "medium", "high", ""],
71 }
72 }
73}
74
75#[derive(Parser, Debug, Clone)]
76#[command(
77 name = "amon-hen",
78 version,
79 about = "Ask multiple AI CLIs the same question, then synthesize their answers.",
80 trailing_var_arg = true,
81 disable_version_flag = true
82)]
83pub struct CliArgs {
84 #[arg(short = 'v', long = "version", action = ArgAction::SetTrue)]
85 version: bool,
86
87 #[arg(long, action = ArgAction::SetTrue)]
88 json: bool,
89
90 #[arg(long = "json-stream", alias = "ndjson", action = ArgAction::SetTrue)]
91 json_stream: bool,
92
93 #[arg(long, action = ArgAction::SetTrue)]
94 headless: bool,
95
96 #[arg(long, action = ArgAction::SetTrue)]
97 studio: bool,
98
99 #[arg(long, action = ArgAction::SetTrue)]
100 plain: bool,
101
102 #[arg(long = "no-banner", action = ArgAction::SetTrue)]
103 no_banner: bool,
104 #[arg(long = "banner", hide = true, action = ArgAction::SetTrue)]
105 banner: bool,
106
107 #[arg(short = 'q', long = "quiet", alias = "summary-only", action = ArgAction::SetTrue)]
108 summary_only: bool,
109
110 #[arg(short = 'd', long, action = ArgAction::SetTrue)]
111 verbose: bool,
112
113 #[arg(long, action = ArgAction::SetTrue)]
114 all: bool,
115
116 #[arg(long, action = ArgAction::SetTrue)]
117 codex: bool,
118 #[arg(long = "no-codex", action = ArgAction::SetTrue)]
119 no_codex: bool,
120 #[arg(long, action = ArgAction::SetTrue)]
121 claude: bool,
122 #[arg(long = "no-claude", action = ArgAction::SetTrue)]
123 no_claude: bool,
124 #[arg(long, action = ArgAction::SetTrue)]
125 gemini: bool,
126 #[arg(long = "no-gemini", action = ArgAction::SetTrue)]
127 no_gemini: bool,
128
129 #[arg(long, value_delimiter = ',')]
130 members: Vec<String>,
131
132 #[arg(long, default_value = "auto")]
133 summarizer: String,
134
135 #[arg(long, value_enum)]
136 effort: Option<Effort>,
137
138 #[arg(long = "codex-model")]
139 codex_model: Option<String>,
140 #[arg(long = "claude-model")]
141 claude_model: Option<String>,
142 #[arg(long = "gemini-model")]
143 gemini_model: Option<String>,
144
145 #[arg(long = "codex-effort")]
146 codex_effort: Option<String>,
147 #[arg(long = "claude-effort")]
148 claude_effort: Option<String>,
149 #[arg(long = "gemini-effort")]
150 gemini_effort: Option<String>,
151
152 #[arg(long = "codex-sandbox", default_value = "read-only")]
153 codex_sandbox: String,
154 #[arg(long = "claude-permission-mode", default_value = "plan")]
155 claude_permission_mode: String,
156
157 #[arg(long = "codex-auth", default_value = "auto")]
158 codex_auth: String,
159 #[arg(long = "claude-auth", default_value = "auto")]
160 claude_auth: String,
161 #[arg(long = "gemini-auth", default_value = "auto")]
162 gemini_auth: String,
163
164 #[arg(long = "codex-capabilities", default_value = "inherit")]
165 codex_capabilities: String,
166 #[arg(long = "claude-capabilities", default_value = "inherit")]
167 claude_capabilities: String,
168 #[arg(long = "gemini-capabilities", default_value = "inherit")]
169 gemini_capabilities: String,
170
171 #[arg(long = "codex-config", action = ArgAction::Append)]
172 codex_config: Vec<String>,
173 #[arg(long = "codex-mcp-profile")]
174 codex_mcp_profile: Option<String>,
175 #[arg(long = "claude-mcp-config", action = ArgAction::Append)]
176 claude_mcp_config: Vec<String>,
177 #[arg(long = "claude-allowed-tools", value_delimiter = ',', action = ArgAction::Append)]
178 claude_allowed_tools: Vec<String>,
179 #[arg(long = "claude-disallowed-tools", value_delimiter = ',', action = ArgAction::Append)]
180 claude_disallowed_tools: Vec<String>,
181 #[arg(long = "claude-tools", value_delimiter = ',', action = ArgAction::Append)]
182 claude_tools: Vec<String>,
183 #[arg(long = "claude-agent")]
184 claude_agent: Option<String>,
185 #[arg(long = "claude-agents-json")]
186 claude_agents_json: Option<String>,
187 #[arg(long = "claude-plugin-dir", action = ArgAction::Append)]
188 claude_plugin_dir: Vec<String>,
189 #[arg(long = "claude-strict-mcp-config", action = ArgAction::SetTrue)]
190 claude_strict_mcp_config: bool,
191 #[arg(long = "claude-disable-slash-commands", action = ArgAction::SetTrue)]
192 claude_disable_slash_commands: bool,
193 #[arg(long = "gemini-settings")]
194 gemini_settings: Option<String>,
195 #[arg(long = "gemini-tools-profile", value_delimiter = ',', action = ArgAction::Append)]
196 gemini_tools_profile: Vec<String>,
197 #[arg(long = "gemini-allowed-mcp-servers", value_delimiter = ',', action = ArgAction::Append)]
198 gemini_allowed_mcp_servers: Vec<String>,
199 #[arg(long = "gemini-policy", value_delimiter = ',', action = ArgAction::Append)]
200 gemini_policy: Vec<String>,
201 #[arg(long = "gemini-admin-policy", value_delimiter = ',', action = ArgAction::Append)]
202 gemini_admin_policy: Vec<String>,
203 #[arg(long = "capabilities-status", action = ArgAction::SetTrue)]
204 capabilities_status: bool,
205
206 #[arg(long = "auth-login", action = ArgAction::SetTrue)]
207 auth_login: bool,
208 #[arg(long = "auth-status", action = ArgAction::SetTrue)]
209 auth_status: bool,
210 #[arg(long = "auth-login-providers", value_delimiter = ',')]
211 auth_login_providers: Vec<String>,
212 #[arg(long = "auth-device-code", action = ArgAction::SetTrue)]
213 auth_device_code: bool,
214 #[arg(long = "no-auth-open-browser", action = ArgAction::SetTrue)]
215 no_auth_open_browser: bool,
216 #[arg(long = "auth-open-browser", hide = true, action = ArgAction::SetTrue)]
217 auth_open_browser: bool,
218 #[arg(long = "auth-timeout", default_value_t = 300)]
219 auth_timeout: u64,
220 #[arg(long = "claude-login-mode", default_value = "claudeai")]
221 claude_login_mode: String,
222 #[arg(long = "claude-login-email")]
223 claude_login_email: Option<String>,
224
225 #[arg(long = "file", alias = "tag-file", action = ArgAction::Append)]
226 files: Vec<PathBuf>,
227 #[arg(long = "cmd", alias = "prompt-command", action = ArgAction::Append)]
228 commands: Vec<String>,
229
230 #[arg(long, action = ArgAction::SetTrue)]
231 handoff: bool,
232 #[arg(long)]
233 lead: Option<String>,
234 #[arg(long)]
235 planner: Option<String>,
236 #[arg(long, default_value_t = DEFAULT_ITERATIONS)]
237 iterations: usize,
238 #[arg(long = "team-work", alias = "teamwork", alias = "sub-agents", default_value_t = DEFAULT_TEAM_SIZE)]
239 team_work: usize,
240 #[arg(long = "codex-sub-agents")]
241 codex_sub_agents: Option<usize>,
242 #[arg(long = "claude-sub-agents")]
243 claude_sub_agents: Option<usize>,
244 #[arg(long = "gemini-sub-agents")]
245 gemini_sub_agents: Option<usize>,
246
247 #[arg(long = "deliver-linear", alias = "linear", action = ArgAction::SetTrue)]
248 deliver_linear: bool,
249 #[arg(long = "linear-setup", action = ArgAction::SetTrue)]
250 linear_setup: bool,
251 #[arg(long = "linear-status", action = ArgAction::SetTrue)]
252 linear_status: bool,
253 #[arg(long = "linear-watch", action = ArgAction::SetTrue)]
254 linear_watch: bool,
255 #[arg(long = "linear-until-complete", action = ArgAction::SetTrue)]
256 linear_until_complete: bool,
257 #[arg(long = "linear-issue", value_delimiter = ',', action = ArgAction::Append)]
258 linear_issue: Vec<String>,
259 #[arg(long = "linear-query")]
260 linear_query: Option<String>,
261 #[arg(long = "linear-project", value_delimiter = ',', action = ArgAction::Append)]
262 linear_project: Vec<String>,
263 #[arg(long = "linear-epic", value_delimiter = ',', action = ArgAction::Append)]
264 linear_epic: Vec<String>,
265 #[arg(long = "linear-team")]
266 linear_team: Option<String>,
267 #[arg(long = "linear-state")]
268 linear_state: Option<String>,
269 #[arg(long = "linear-assignee")]
270 linear_assignee: Option<String>,
271 #[arg(long = "linear-limit", default_value_t = 3)]
272 linear_limit: usize,
273 #[arg(long = "linear-endpoint")]
274 linear_endpoint: Option<String>,
275 #[arg(long = "linear-auth", default_value = "api-key")]
276 linear_auth: String,
277 #[arg(long = "linear-api-key-env", default_value = "LINEAR_API_KEY")]
278 linear_api_key_env: String,
279 #[arg(long = "linear-oauth-token-env", default_value = "LINEAR_OAUTH_TOKEN")]
280 linear_oauth_token_env: String,
281 #[arg(long = "linear-completion-gate", default_value = "delivered")]
282 linear_completion_gate: String,
283 #[arg(long = "linear-review-state")]
284 linear_review_state: Option<String>,
285 #[arg(long = "linear-ci-timeout", default_value_t = 900)]
286 linear_ci_timeout: u64,
287 #[arg(long = "linear-ci-poll-interval", default_value_t = 30)]
288 linear_ci_poll_interval: u64,
289 #[arg(long = "linear-poll-interval", default_value_t = 60)]
290 linear_poll_interval: u64,
291 #[arg(long = "linear-max-polls")]
292 linear_max_polls: Option<usize>,
293 #[arg(long = "linear-max-concurrency", default_value_t = 1)]
294 linear_max_concurrency: usize,
295 #[arg(long = "linear-max-attempts", default_value_t = 3)]
296 linear_max_attempts: usize,
297 #[arg(long = "linear-retry-base", default_value_t = 60)]
298 linear_retry_base: u64,
299 #[arg(long = "linear-workspace-strategy", default_value = "worktree")]
300 linear_workspace_strategy: String,
301 #[arg(long = "linear-state-file")]
302 linear_state_file: Option<PathBuf>,
303 #[arg(long = "linear-workspace-root")]
304 linear_workspace_root: Option<PathBuf>,
305 #[arg(long = "linear-observability-dir")]
306 linear_observability_dir: Option<PathBuf>,
307 #[arg(long = "linear-workflow-file")]
308 linear_workflow_file: Option<PathBuf>,
309 #[arg(long = "linear-attach-media", value_delimiter = ',', action = ArgAction::Append)]
310 linear_attach_media: Vec<String>,
311 #[arg(long = "linear-attachment-title")]
312 linear_attachment_title: Option<String>,
313 #[arg(long = "no-linear-comments", action = ArgAction::SetTrue)]
314 no_linear_comments: bool,
315 #[arg(long = "linear-update-review-state", action = ArgAction::SetTrue)]
316 linear_update_review_state: bool,
317 #[arg(long = "delivery-phases", value_delimiter = ',')]
318 delivery_phases: Vec<String>,
319
320 #[arg(long, default_value_t = DEFAULT_TIMEOUT_MS / 1000)]
321 timeout: u64,
322 #[arg(long = "max-member-chars", default_value_t = DEFAULT_MAX_MEMBER_CHARS)]
323 max_member_chars: usize,
324 #[arg(long, default_value = ".")]
325 cwd: PathBuf,
326 #[arg(long, default_value = "auto")]
327 color: String,
328 #[arg(long = "no-color", action = ArgAction::SetTrue)]
329 no_color: bool,
330
331 #[arg(value_name = "QUERY")]
332 prompt: Vec<String>,
333}
334
335#[derive(Copy, Clone, Debug, ValueEnum)]
336enum Effort {
337 Low,
338 Medium,
339 High,
340}
341
342impl Effort {
343 fn as_str(self) -> &'static str {
344 match self {
345 Effort::Low => "low",
346 Effort::Medium => "medium",
347 Effort::High => "high",
348 }
349 }
350}
351
352#[derive(Debug, Clone)]
353struct ResolvedArgs {
354 raw: CliArgs,
355 members: Vec<String>,
356 prompt: String,
357 cwd: PathBuf,
358}
359
360#[derive(Debug, Clone, Serialize)]
361pub struct AmonHenResult {
362 query: String,
363 cwd: String,
364 members_requested: Vec<String>,
365 summarizer_requested: String,
366 workflow: Workflow,
367 prompt_commands: Vec<CommandTelemetry>,
368 iterations: Vec<IterationRecord>,
369 members: Vec<EngineResult>,
370 summary: EngineResult,
371}
372
373#[derive(Debug, Clone, Serialize)]
374struct Workflow {
375 handoff: bool,
376 lead: Option<String>,
377 planner: Option<String>,
378 iterations: usize,
379 team_work: usize,
380 teams: HashMap<String, usize>,
381}
382
383#[derive(Debug, Clone, Serialize)]
384struct IterationRecord {
385 iteration: usize,
386 total_iterations: usize,
387 status: String,
388 duration_ms: u128,
389 members: Vec<EngineResult>,
390 sub_agents: Vec<EngineResult>,
391 handoff_context: Option<String>,
392 summary_context: Option<String>,
393 token_usage: TokenUsage,
394 tool_calls: Vec<ToolUsage>,
395}
396
397#[derive(Debug, Clone, Serialize)]
398pub struct EngineResult {
399 name: String,
400 bin: Option<String>,
401 status: String,
402 duration_ms: u128,
403 detail: String,
404 exit_code: Option<i32>,
405 stdout: String,
406 stderr: String,
407 output: String,
408 command: String,
409 token_usage: TokenUsage,
410 tool_calls: Vec<ToolUsage>,
411 sub_agents: Vec<EngineResult>,
412 role: String,
413 iteration: usize,
414 total_iterations: usize,
415 team_size: usize,
416}
417
418#[derive(Debug, Clone, Serialize)]
419struct TokenUsage {
420 input: usize,
421 output: usize,
422 total: usize,
423 estimated: bool,
424 source: String,
425}
426
427#[derive(Debug, Clone, Serialize)]
428struct ToolUsage {
429 name: String,
430 kind: String,
431 status: String,
432 detail: String,
433}
434
435#[derive(Debug, Clone, Serialize)]
436struct CommandTelemetry {
437 command: String,
438 status: String,
439 detail: String,
440 exit_code: Option<i32>,
441 duration_ms: u128,
442 stdout_chars: usize,
443 stderr_chars: usize,
444 timed_out: bool,
445}
446
447#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)]
448#[serde(rename_all = "snake_case")]
449enum ProgressStage {
450 Context,
451 Start,
452 Spawn,
453 Heartbeat,
454 Done,
455}
456
457#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)]
458#[serde(rename_all = "snake_case")]
459enum RuntimeEventKind {
460 Context,
461 Iteration,
462 Provider,
463 TokenUsage,
464 ToolUsage,
465 Result,
466}
467
468#[derive(Debug, Clone, Serialize)]
469struct ProgressEvent {
470 kind: RuntimeEventKind,
471 provider: Option<String>,
472 role: Option<String>,
473 stage: ProgressStage,
474 status: Option<String>,
475 iteration: Option<usize>,
476 total_iterations: Option<usize>,
477 is_sub_agent: bool,
478 duration_ms: Option<u128>,
479 token_usage: Option<TokenUsage>,
480 tool_calls: Vec<ToolUsage>,
481 message: String,
482}
483
484type ProgressSink = Arc<dyn Fn(ProgressEvent) + Send + Sync + 'static>;
485type RuntimeEventSink = Arc<dyn Fn(RuntimeEvent) + Send + Sync + 'static>;
486
487#[derive(Debug, Clone, Serialize)]
488struct RuntimeEvent {
489 sequence: u64,
490 elapsed_ms: u128,
491 #[serde(flatten)]
492 progress: ProgressEvent,
493 #[serde(skip_serializing_if = "Option::is_none")]
494 result: Option<Box<AmonHenResult>>,
495 #[serde(skip_serializing_if = "Option::is_none", rename = "linearResult")]
496 linear_result: Option<Box<linear_delivery::LinearDeliveryResult>>,
497}
498
499#[derive(Clone)]
500struct RuntimeEventBus {
501 started: Instant,
502 sequence: Arc<AtomicU64>,
503 emit_lock: Arc<Mutex<()>>,
504 sinks: Arc<Mutex<Vec<RuntimeEventSink>>>,
505}
506
507#[derive(Debug)]
508struct PromptContext {
509 prompt: String,
510 commands: Vec<CommandTelemetry>,
511}
512
513#[derive(Debug)]
514struct CommandResult {
515 command: String,
516 args: Vec<String>,
517 code: Option<i32>,
518 stdout: String,
519 stderr: String,
520 timed_out: bool,
521 cancelled: bool,
522 error: Option<String>,
523 timeout_ms: u64,
524 duration_ms: u128,
525}
526
527#[derive(Clone)]
528struct CommandProgress {
529 label: String,
530 sink: Option<ProgressSink>,
531}
532
533struct CommandRequest<'a> {
534 command: &'a str,
535 args: &'a [String],
536 cwd: &'a Path,
537 stdin_text: Option<&'a str>,
538 timeout_ms: u64,
539 envs: HashMap<String, String>,
540 progress: Option<CommandProgress>,
541 cancel: Option<Arc<AtomicBool>>,
542}
543
544impl<'a> CommandRequest<'a> {
545 fn new(command: &'a str, args: &'a [String], cwd: &'a Path, timeout_ms: u64) -> Self {
546 Self {
547 command,
548 args,
549 cwd,
550 stdin_text: None,
551 timeout_ms,
552 envs: HashMap::new(),
553 progress: None,
554 cancel: None,
555 }
556 }
557
558 fn stdin_text(mut self, stdin_text: Option<&'a str>) -> Self {
559 self.stdin_text = stdin_text;
560 self
561 }
562
563 fn envs(mut self, envs: HashMap<String, String>) -> Self {
564 self.envs = envs;
565 self
566 }
567
568 fn progress(mut self, progress: Option<CommandProgress>) -> Self {
569 self.progress = progress;
570 self
571 }
572
573 fn cancel(mut self, cancel: Option<Arc<AtomicBool>>) -> Self {
574 self.cancel = cancel;
575 self
576 }
577}
578
579#[derive(Clone)]
580struct EngineRunOptions {
581 prompt: String,
582 cwd: PathBuf,
583 timeout_ms: u64,
584 effort: Option<String>,
585 model: Option<String>,
586 permission: Option<String>,
587 auth: String,
588 capability: ProviderCapability,
589 role: String,
590 iteration: usize,
591 total_iterations: usize,
592 team_size: usize,
593 is_sub_agent: bool,
594 live: bool,
595 progress: Option<ProgressSink>,
596 cancel: Option<Arc<AtomicBool>>,
597}
598
599struct EngineOptionsInput<'a> {
600 member: &'a str,
601 prompt: String,
602 role: &'a str,
603 iteration: usize,
604 workflow: &'a Workflow,
605 progress: Option<ProgressSink>,
606 cancel: Option<Arc<AtomicBool>>,
607}
608
609struct MemberPromptInput<'a> {
610 query: &'a str,
611 role: &'a str,
612 workflow: &'a Workflow,
613 iteration: usize,
614 team_size: usize,
615 previous_iteration: &'a [EngineResult],
616 handoff_results: &'a [EngineResult],
617 plan_output: &'a str,
618}
619
620#[derive(Debug, Clone)]
621struct ProviderCapability {
622 mode: String,
623 config: Vec<String>,
624 mcp_profile: Option<String>,
625 mcp_config: Vec<String>,
626 allowed_tools: Vec<String>,
627 disallowed_tools: Vec<String>,
628 tools: Vec<String>,
629 agent: Option<String>,
630 agents_json: Option<String>,
631 plugin_dirs: Vec<String>,
632 strict_mcp_config: bool,
633 disable_slash_commands: bool,
634 settings: Option<String>,
635 tools_profile: Vec<String>,
636 allowed_mcp_servers: Vec<String>,
637 policy: Vec<String>,
638 admin_policy: Vec<String>,
639}
640
641#[derive(Debug, Clone, Serialize)]
642struct ProviderAuthStatus {
643 provider: String,
644 configured: bool,
645 status: String,
646 source: String,
647 detail: String,
648}
649
650#[derive(Debug, Clone, Serialize)]
651struct ProviderCapabilityStatus {
652 provider: String,
653 mcp: CommandTelemetry,
654 skills: Option<CommandTelemetry>,
655 tools: Option<CommandTelemetry>,
656 detail: String,
657}
658
659struct TempSettings {
660 _dir: tempfile::TempDir,
661 path: PathBuf,
662}
663
664pub fn run_from_env() -> i32 {
665 let args: Vec<OsString> = std::env::args_os().skip(1).collect();
666 run_with_args(args)
667}
668
669pub fn run_with_args<I, T>(args: I) -> i32
670where
671 I: IntoIterator<Item = T>,
672 T: Into<OsString>,
673{
674 let raw_args: Vec<OsString> = args.into_iter().map(Into::into).collect();
675 if raw_args.first().and_then(|value| value.to_str()) == Some("help") {
676 println!("{}", render_cli_help());
677 return 0;
678 }
679
680 let mut parse_args = vec![OsString::from("amon-hen")];
681 parse_args.extend(raw_args);
682 let parsed = match CliArgs::try_parse_from(parse_args) {
683 Ok(parsed) => parsed,
684 Err(error) => {
685 if error.kind() == ErrorKind::DisplayHelp {
686 println!("{}", render_cli_help());
687 return 0;
688 }
689 if error.kind() == ErrorKind::DisplayVersion {
690 println!("{VERSION}");
691 return 0;
692 }
693 let exit_code = parse_error_exit_code(error.kind());
694 let _ = error.print();
695 return exit_code;
696 }
697 };
698
699 if parsed.version {
700 println!("{VERSION}");
701 return 0;
702 }
703
704 let resolved = match resolve_args(parsed) {
705 Ok(resolved) => resolved,
706 Err(error) => {
707 eprintln!("{error}");
708 return 64;
709 }
710 };
711
712 if resolved.raw.studio {
713 return studio::run_studio(&resolved);
714 }
715
716 let event_bus = resolved.raw.json_stream.then(RuntimeEventBus::new);
717 if let Some(bus) = &event_bus {
718 let stdout_lock = Arc::new(Mutex::new(()));
719 bus.add_sink(Arc::new(move |event| {
720 let Ok(line) = serde_json::to_string(&event) else {
721 return;
722 };
723 let _guard = stdout_lock.lock().ok();
724 println!("{line}");
725 let _ = io::stdout().flush();
726 }));
727 }
728 let progress = event_bus.as_ref().map(RuntimeEventBus::progress_sink);
729
730 if resolved.raw.auth_status {
731 println!(
732 "{}",
733 render_auth_statuses(&collect_auth_statuses(&resolved))
734 );
735 if resolved.prompt.trim().is_empty()
736 && !resolved.raw.auth_login
737 && !resolved.raw.deliver_linear
738 && !resolved.raw.capabilities_status
739 {
740 return 0;
741 }
742 }
743
744 if resolved.raw.capabilities_status {
745 println!(
746 "{}",
747 render_provider_capability_statuses(&collect_provider_capability_statuses(&resolved))
748 );
749 if resolved.prompt.trim().is_empty()
750 && !resolved.raw.auth_login
751 && !resolved.raw.deliver_linear
752 {
753 return 0;
754 }
755 }
756
757 if resolved.raw.auth_login {
758 if let Err(error) = run_social_login(&resolved) {
759 eprintln!("{error}");
760 return 1;
761 }
762 if resolved.prompt.trim().is_empty() && !resolved.raw.deliver_linear {
763 return 0;
764 }
765 }
766
767 if resolved.raw.linear_setup || resolved.raw.linear_status {
768 match linear_delivery::get_linear_status(&resolved) {
769 Ok(status) => {
770 println!("{}", linear_delivery::render_linear_status(&status));
771 return 0;
772 }
773 Err(error) => {
774 eprintln!("{error}");
775 return 1;
776 }
777 }
778 }
779
780 if linear_delivery_requested(&resolved.raw) {
781 match linear_delivery::run_linear_delivery_with_progress(&resolved, progress.clone(), None)
782 {
783 Ok(result) => {
784 if let Some(bus) = &event_bus {
785 bus.emit_linear_result(&result);
786 } else if resolved.raw.json {
787 let serialized = serde_json::to_string_pretty(&result);
788 println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
789 } else {
790 println!(
791 "{}",
792 linear_delivery::render_linear_delivery_result(&result)
793 );
794 }
795 return if result.success { 0 } else { 1 };
796 }
797 Err(error) => {
798 eprintln!("{error}");
799 return 1;
800 }
801 }
802 }
803
804 if resolved.prompt.trim().is_empty() {
805 eprintln!("No query provided.\n\n{}", render_cli_help());
806 return 64;
807 }
808
809 let prompt_context = match build_prompt_context_with_progress(&resolved, progress.clone()) {
810 Ok(context) => context,
811 Err(error) => {
812 eprintln!("{error}");
813 return 1;
814 }
815 };
816
817 if should_show_banner(&resolved.raw) {
818 eprintln!("{}", render_banner());
819 }
820
821 let result = run_amon_hen_with_progress(
822 &resolved,
823 prompt_context.prompt,
824 prompt_context.commands,
825 progress,
826 );
827 if let Some(bus) = &event_bus {
828 bus.emit_result(&result);
829 } else if resolved.raw.json {
830 let serialized = serde_json::to_string_pretty(&result);
831 println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
832 } else {
833 println!("{}", render_human_result(&result, resolved.raw.verbose));
834 }
835
836 if is_success(&result) {
837 0
838 } else {
839 1
840 }
841}
842
843fn parse_error_exit_code(kind: ErrorKind) -> i32 {
844 match kind {
845 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
846 _ => 64,
847 }
848}
849
850fn render_cli_help() -> &'static str {
851 r#"Amon Hen
852Rust-native orchestration for Codex, Claude, Gemini, and Linear delivery.
853
854Usage:
855 amon-hen [OPTIONS] "your task"
856 amon-hen --studio --members codex,claude,gemini "your task"
857 amon-hen --deliver-linear --linear-project "Project" --linear-until-complete
858
859Core run:
860 --members codex,claude,gemini Providers to consult. Also supports --codex, --claude, --gemini, --all.
861 --summarizer auto|codex|claude|gemini
862 Provider used for synthesis. auto tries the configured order.
863 --studio Open the interactive native Studio.
864 --plain Disable rich terminal output for script-friendly runs.
865 --json, --json-stream Emit JSON or newline-delimited JSON.
866 -q, --quiet Print the synthesis only.
867 -d, --verbose Include provider details, telemetry, and tool usage.
868
869Models, effort, and permissions:
870 --codex-model NAME Model passed to Codex.
871 --claude-model NAME Model passed to Claude.
872 --gemini-model NAME Model passed to Gemini.
873 --effort low|medium|high Shared default effort.
874 --codex-effort low|medium|high|xhigh
875 --claude-effort low|medium|high|xhigh|max
876 --gemini-effort low|medium|high
877 --codex-sandbox read-only|workspace-write|danger-full-access
878 --claude-permission-mode MODE Claude permission mode such as plan, acceptEdits, bypassPermissions.
879
880Auth and provider capabilities:
881 --auth-login Start provider social-login flows.
882 --auth-status Show configured auth sources for each provider.
883 --codex-auth auto|api-key|social-login|oauth|keychain
884 --claude-auth auto|api-key|social-login|oauth|keychain
885 --gemini-auth auto|api-key|social-login|oauth|keychain
886 --capabilities-status Probe provider Skills / MCP / Tools support.
887 --codex-capabilities inherit|override
888 --claude-capabilities inherit|override
889 --gemini-capabilities inherit|override
890 --codex-config PATH Extra Codex config file, repeatable.
891 --codex-mcp-profile NAME Codex MCP profile.
892 --claude-mcp-config PATH Claude MCP config file, repeatable.
893 --claude-allowed-tools LIST Allowed Claude tools.
894 --claude-disallowed-tools LIST Disallowed Claude tools.
895 --claude-tools LIST Claude tool list override.
896 --claude-agent NAME Claude agent profile.
897 --claude-agents-json JSON Claude agents JSON override.
898 --claude-plugin-dir PATH Claude plugin directory, repeatable.
899 --gemini-settings PATH Gemini settings file.
900 --gemini-tools-profile LIST Gemini tool profile list.
901 --gemini-allowed-mcp-servers LIST Gemini MCP allow-list.
902 --gemini-policy LIST Gemini policy values.
903 --gemini-admin-policy LIST Gemini admin policy values.
904
905Team workflow:
906 --handoff Feed planner/lead context between providers.
907 --planner codex|claude|gemini Assign the planning role.
908 --lead codex|claude|gemini Assign the lead reviewer/synthesizer role.
909 --iterations N Run multiple provider rounds per prompt.
910 --team-work N Spawn N same-provider sub-agents per provider.
911 --codex-sub-agents N Override Codex sub-agent count.
912 --claude-sub-agents N Override Claude sub-agent count.
913 --gemini-sub-agents N Override Gemini sub-agent count.
914 --file PATH Tag a local file into the prompt context, repeatable.
915 --cmd COMMAND Run a local command and include its telemetry, repeatable.
916
917Linear delivery:
918 --deliver-linear Deliver Linear work instead of a one-shot prompt.
919 --linear-watch Poll Linear for matching work.
920 --linear-until-complete Continue until review/delivery gate is met.
921 --linear-issue ID,ID Target specific Linear issues.
922 --linear-project NAME,NAME Target Linear projects.
923 --linear-epic NAME,NAME Target Linear epics.
924 --linear-team KEY Restrict to a Linear team.
925 --linear-state NAME Restrict by Linear workflow state.
926 --linear-completion-gate delivered|review|ci
927 --linear-max-concurrency N Isolated workspace concurrency.
928 --linear-max-attempts N Retry attempts per issue.
929 --linear-workspace-strategy worktree|local
930 --linear-workspace-root PATH Root for per-issue workspaces.
931 --linear-observability-dir PATH Logs, reconciliation, and run telemetry.
932 --linear-attach-media PATH,PATH Attach generated media back to Linear.
933
934Runtime:
935 --cwd PATH Working directory for provider CLIs.
936 --timeout SECONDS Per-provider timeout.
937 --max-member-chars N Max provider output included in synthesis.
938 --color auto|always|never Color mode.
939 --no-color Disable color.
940 -v, --version Print version.
941 -h, --help Show this help.
942
943Examples:
944 amon-hen --studio --members codex,claude,gemini "Inspect this repo"
945 amon-hen --members codex,claude,gemini --planner codex --lead claude --handoff --iterations 2 "Suggest the cleanest next patch"
946 amon-hen --deliver-linear --linear-project "Developer Platform" --linear-until-complete --team-work 2
947 amon-hen --auth-login --auth-login-providers codex,claude,gemini"#
948}
949
950fn resolve_args(raw: CliArgs) -> Result<ResolvedArgs, String> {
951 let members = resolve_members(&raw)?;
952 validate_engine_name(&raw.summarizer, true, "--summarizer")?;
953 if let Some(lead) = &raw.lead {
954 validate_engine_name(lead, false, "--lead")?;
955 if !members.contains(lead) {
956 return Err(format!(
957 "--lead must be one of the enabled members: {}",
958 members.join(", ")
959 ));
960 }
961 }
962 if let Some(planner) = &raw.planner {
963 validate_engine_name(planner, false, "--planner")?;
964 if !members.contains(planner) {
965 return Err(format!(
966 "--planner must be one of the enabled members: {}",
967 members.join(", ")
968 ));
969 }
970 }
971 validate_provider_effort("codex", raw.codex_effort.as_deref())?;
972 validate_provider_effort("claude", raw.claude_effort.as_deref())?;
973 validate_provider_effort("gemini", raw.gemini_effort.as_deref())?;
974 validate_choice(
975 "--claude-login-mode",
976 &raw.claude_login_mode,
977 &["claudeai", "console", "sso"],
978 )?;
979 validate_choice(
980 "--codex-capabilities",
981 &raw.codex_capabilities,
982 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
983 )?;
984 validate_choice(
985 "--claude-capabilities",
986 &raw.claude_capabilities,
987 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
988 )?;
989 validate_choice(
990 "--gemini-capabilities",
991 &raw.gemini_capabilities,
992 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
993 )?;
994 validate_choice("--linear-auth", &raw.linear_auth, &["api-key", "oauth"])?;
995 validate_choice(
996 "--linear-completion-gate",
997 &raw.linear_completion_gate,
998 &["delivered", "human-review", "ci-success", "review-or-ci"],
999 )?;
1000 validate_choice(
1001 "--linear-workspace-strategy",
1002 &raw.linear_workspace_strategy,
1003 &["worktree", "copy", "none"],
1004 )?;
1005 if raw.linear_limit == 0 {
1006 return Err("--linear-limit requires a positive integer.".to_string());
1007 }
1008 if raw.linear_max_concurrency == 0 {
1009 return Err("--linear-max-concurrency requires a positive integer.".to_string());
1010 }
1011 if raw.linear_max_polls == Some(0) {
1012 return Err("--linear-max-polls requires a positive integer.".to_string());
1013 }
1014 if raw
1015 .linear_endpoint
1016 .as_ref()
1017 .is_some_and(|value| value.trim().is_empty())
1018 {
1019 return Err("--linear-endpoint requires a non-empty value.".to_string());
1020 }
1021 for phase in raw
1022 .delivery_phases
1023 .iter()
1024 .map(|phase| phase.trim())
1025 .filter(|phase| !phase.is_empty())
1026 {
1027 validate_choice(
1028 "--delivery-phases",
1029 phase,
1030 &["plan", "implement", "verify", "ship"],
1031 )?;
1032 }
1033
1034 let mut prompt = raw.prompt.join(" ");
1035 if prompt.trim().is_empty() && !io::stdin().is_terminal() {
1036 let mut stdin = String::new();
1037 io::stdin()
1038 .read_to_string(&mut stdin)
1039 .map_err(|error| format!("Failed to read stdin: {error}"))?;
1040 prompt = stdin;
1041 }
1042 let cwd = raw.cwd.canonicalize().unwrap_or_else(|_| raw.cwd.clone());
1043
1044 Ok(ResolvedArgs {
1045 raw,
1046 members,
1047 prompt,
1048 cwd,
1049 })
1050}
1051
1052fn resolve_members(raw: &CliArgs) -> Result<Vec<String>, String> {
1053 let mut members = if raw.members.is_empty() {
1054 ENGINES
1055 .iter()
1056 .map(|name| (*name).to_string())
1057 .collect::<Vec<_>>()
1058 } else {
1059 raw.members
1060 .iter()
1061 .map(|name| name.trim().to_string())
1062 .filter(|name| !name.is_empty())
1063 .collect::<Vec<_>>()
1064 };
1065
1066 if raw.all {
1067 members = ENGINES.iter().map(|name| (*name).to_string()).collect();
1068 }
1069 if raw.no_codex {
1070 members.retain(|name| name != "codex");
1071 }
1072 if raw.no_claude {
1073 members.retain(|name| name != "claude");
1074 }
1075 if raw.no_gemini {
1076 members.retain(|name| name != "gemini");
1077 }
1078 for enabled in [
1079 (raw.codex, "codex"),
1080 (raw.claude, "claude"),
1081 (raw.gemini, "gemini"),
1082 ] {
1083 if enabled.0 && !members.iter().any(|name| name == enabled.1) {
1084 members.push(enabled.1.to_string());
1085 }
1086 }
1087
1088 let mut seen = HashSet::new();
1089 members.retain(|member| seen.insert(member.clone()));
1090 for member in &members {
1091 validate_engine_name(member, false, "--members")?;
1092 }
1093 if members.is_empty() {
1094 return Err("At least one member must be enabled.".to_string());
1095 }
1096 Ok(members)
1097}
1098
1099fn validate_engine_name(name: &str, allow_auto: bool, flag: &str) -> Result<(), String> {
1100 if allow_auto && name == "auto" {
1101 return Ok(());
1102 }
1103 if Engine::parse(name).is_some() {
1104 Ok(())
1105 } else {
1106 Err(format!(
1107 "{flag} must be one of: {}",
1108 if allow_auto {
1109 "auto, codex, claude, gemini"
1110 } else {
1111 "codex, claude, gemini"
1112 }
1113 ))
1114 }
1115}
1116
1117fn validate_provider_effort(provider: &str, value: Option<&str>) -> Result<(), String> {
1118 let Some(value) = value else {
1119 return Ok(());
1120 };
1121 let engine = Engine::parse(provider).expect("provider effort validation uses known engines");
1122 if engine.allowed_efforts().contains(&value) {
1123 Ok(())
1124 } else {
1125 Err(format!("Unsupported --{provider}-effort value: {value}"))
1126 }
1127}
1128
1129fn validate_choice(flag: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
1130 if allowed.contains(&value) {
1131 Ok(())
1132 } else {
1133 Err(format!("{flag} must be one of: {}", allowed.join(", ")))
1134 }
1135}
1136
1137fn collect_auth_statuses(resolved: &ResolvedArgs) -> Vec<ProviderAuthStatus> {
1138 collect_auth_statuses_with_cancel(resolved, None)
1139}
1140
1141fn collect_auth_statuses_with_cancel(
1142 resolved: &ResolvedArgs,
1143 cancel: Option<Arc<AtomicBool>>,
1144) -> Vec<ProviderAuthStatus> {
1145 resolved
1146 .members
1147 .iter()
1148 .map(|provider| provider_auth_status_with_cancel(provider, resolved, cancel.clone()))
1149 .collect()
1150}
1151
1152fn provider_auth_status(provider: &str, resolved: &ResolvedArgs) -> ProviderAuthStatus {
1153 provider_auth_status_with_cancel(provider, resolved, None)
1154}
1155
1156fn provider_auth_status_with_cancel(
1157 provider: &str,
1158 resolved: &ResolvedArgs,
1159 cancel: Option<Arc<AtomicBool>>,
1160) -> ProviderAuthStatus {
1161 let auth = provider_auth(resolved, provider);
1162 if cancel
1163 .as_ref()
1164 .is_some_and(|cancel| cancel.load(Ordering::Relaxed))
1165 {
1166 return ProviderAuthStatus {
1167 provider: provider.to_string(),
1168 configured: false,
1169 status: "cancelled".to_string(),
1170 source: auth,
1171 detail: "Auth status refresh cancelled.".to_string(),
1172 };
1173 }
1174 match provider {
1175 "codex" => {
1176 let bin = resolve_binary("codex");
1177 let args = vec!["login".to_string(), "status".to_string()];
1178 status_from_command(provider, &auth, &bin, &args, &resolved.cwd, cancel)
1179 }
1180 "claude" => {
1181 let bin = resolve_binary("claude");
1182 let args = vec![
1183 "auth".to_string(),
1184 "status".to_string(),
1185 "--text".to_string(),
1186 ];
1187 status_from_command(provider, &auth, &bin, &args, &resolved.cwd, cancel)
1188 }
1189 "gemini" => {
1190 let has_api_key = std::env::var("GEMINI_API_KEY")
1191 .ok()
1192 .is_some_and(|value| !value.trim().is_empty());
1193 let home = std::env::var("HOME").unwrap_or_default();
1194 let has_oauth_file = !home.is_empty()
1195 && [
1196 ".gemini/oauth_creds.json",
1197 ".gemini/oauth_tokens.json",
1198 ".gemini/settings.json",
1199 ]
1200 .iter()
1201 .any(|path| Path::new(&home).join(path).exists());
1202 let configured = has_api_key || has_oauth_file;
1203 ProviderAuthStatus {
1204 provider: provider.to_string(),
1205 configured,
1206 status: if configured { "configured" } else { "unknown" }.to_string(),
1207 source: auth,
1208 detail: if has_api_key {
1209 "GEMINI_API_KEY is present.".to_string()
1210 } else if has_oauth_file {
1211 "Gemini local auth/config files are present.".to_string()
1212 } else {
1213 "Gemini CLI does not currently expose a stable headless auth status command; use Social login from Studio or run gemini interactively.".to_string()
1214 },
1215 }
1216 }
1217 _ => ProviderAuthStatus {
1218 provider: provider.to_string(),
1219 configured: false,
1220 status: "unknown".to_string(),
1221 source: auth,
1222 detail: "Unknown provider.".to_string(),
1223 },
1224 }
1225}
1226
1227fn status_from_command(
1228 provider: &str,
1229 auth: &str,
1230 bin: &str,
1231 args: &[String],
1232 cwd: &Path,
1233 cancel: Option<Arc<AtomicBool>>,
1234) -> ProviderAuthStatus {
1235 let result = run_command(CommandRequest::new(bin, args, cwd, 15_000).cancel(cancel));
1236 let configured = result.code == Some(0);
1237 ProviderAuthStatus {
1238 provider: provider.to_string(),
1239 configured,
1240 status: if configured {
1241 "configured"
1242 } else if result.cancelled {
1243 "cancelled"
1244 } else if result.error.is_some() {
1245 "missing-cli"
1246 } else {
1247 "not-configured"
1248 }
1249 .to_string(),
1250 source: auth.to_string(),
1251 detail: sanitize_status_detail(&compact_failure(&result)),
1252 }
1253}
1254
1255fn sanitize_status_detail(detail: &str) -> String {
1256 let detail = detail.trim();
1257 if detail.is_empty() {
1258 return "No status detail returned.".to_string();
1259 }
1260 let detail = detail
1261 .lines()
1262 .take(6)
1263 .map(str::trim)
1264 .filter(|line| !line.is_empty())
1265 .map(redact_possible_secret)
1266 .collect::<Vec<_>>()
1267 .join(" ");
1268 redact_local_paths(&detail)
1269}
1270
1271fn redact_possible_secret(value: &str) -> String {
1272 value
1273 .split_whitespace()
1274 .map(|token| {
1275 if looks_sensitive_token(token) {
1276 "[redacted]"
1277 } else if looks_like_email(token) {
1278 "[email]"
1279 } else {
1280 token
1281 }
1282 })
1283 .collect::<Vec<_>>()
1284 .join(" ")
1285}
1286
1287fn looks_sensitive_token(token: &str) -> bool {
1288 let trimmed =
1289 token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_');
1290 trimmed.len() >= 28
1291 && (trimmed.starts_with("sk-")
1292 || trimmed.starts_with("sk_")
1293 || trimmed.starts_with("gho_")
1294 || trimmed.starts_with("ghp_")
1295 || trimmed.starts_with("ghs_")
1296 || trimmed.starts_with("ghu_")
1297 || trimmed.starts_with("AIza")
1298 || trimmed.starts_with("AQ."))
1299}
1300
1301fn looks_like_email(token: &str) -> bool {
1302 let trimmed = token.trim_matches(|ch: char| {
1303 !ch.is_ascii_alphanumeric() && !matches!(ch, '@' | '.' | '_' | '-' | '+')
1304 });
1305 trimmed.contains('@') && trimmed.rsplit_once('.').is_some()
1306}
1307
1308fn redact_local_paths(value: &str) -> String {
1309 let mut redacted = value.to_string();
1310 if let Ok(home) = std::env::var("HOME") {
1311 if !home.trim().is_empty() {
1312 redacted = redacted.replace(&home, "~");
1313 }
1314 }
1315 redacted
1316}
1317
1318fn render_auth_statuses(statuses: &[ProviderAuthStatus]) -> String {
1319 let mut lines = vec!["Provider auth status".to_string()];
1320 for status in statuses {
1321 lines.push(format!(
1322 "- {}: {} via {} ({})",
1323 status.provider, status.status, status.source, status.detail
1324 ));
1325 }
1326 lines.join("\n")
1327}
1328
1329fn collect_provider_capability_statuses(resolved: &ResolvedArgs) -> Vec<ProviderCapabilityStatus> {
1330 collect_provider_capability_statuses_with_cancel(resolved, None)
1331}
1332
1333fn collect_provider_capability_statuses_with_cancel(
1334 resolved: &ResolvedArgs,
1335 cancel: Option<Arc<AtomicBool>>,
1336) -> Vec<ProviderCapabilityStatus> {
1337 resolved
1338 .members
1339 .iter()
1340 .map(|provider| provider_capability_status(provider, resolved, cancel.clone()))
1341 .collect()
1342}
1343
1344fn provider_capability_status(
1345 provider: &str,
1346 resolved: &ResolvedArgs,
1347 cancel: Option<Arc<AtomicBool>>,
1348) -> ProviderCapabilityStatus {
1349 match provider {
1350 "codex" => {
1351 let bin = resolve_binary("codex");
1352 let mcp = run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone());
1353 ProviderCapabilityStatus {
1354 provider: provider.to_string(),
1355 mcp,
1356 skills: None,
1357 tools: Some(run_capability_probe(
1358 &bin,
1359 &["plugin", "marketplace", "--help"],
1360 &resolved.cwd,
1361 cancel,
1362 )),
1363 detail:
1364 "Codex inherits ~/.codex config unless --codex-capabilities override is set."
1365 .to_string(),
1366 }
1367 }
1368 "claude" => {
1369 let bin = resolve_binary("claude");
1370 ProviderCapabilityStatus {
1371 provider: provider.to_string(),
1372 mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
1373 skills: Some(run_capability_probe(
1374 &bin,
1375 &["plugin", "list"],
1376 &resolved.cwd,
1377 cancel.clone(),
1378 )),
1379 tools: Some(run_capability_probe(
1380 &bin,
1381 &["agents"],
1382 &resolved.cwd,
1383 cancel,
1384 )),
1385 detail: "Claude override can manage MCP config, tools, agents, plugin dirs, and slash-command skills.".to_string(),
1386 }
1387 }
1388 "gemini" => {
1389 let bin = resolve_binary("gemini");
1390 ProviderCapabilityStatus {
1391 provider: provider.to_string(),
1392 mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
1393 skills: Some(run_capability_probe(
1394 &bin,
1395 &["skills", "list"],
1396 &resolved.cwd,
1397 cancel.clone(),
1398 )),
1399 tools: Some(run_capability_probe(
1400 &bin,
1401 &["extensions", "list"],
1402 &resolved.cwd,
1403 cancel,
1404 )),
1405 detail: "Gemini override can manage settings, extensions, MCP server allowlists, and policy files.".to_string(),
1406 }
1407 }
1408 _ => ProviderCapabilityStatus {
1409 provider: provider.to_string(),
1410 mcp: CommandTelemetry {
1411 command: provider.to_string(),
1412 status: "unknown".to_string(),
1413 detail: "Unknown provider.".to_string(),
1414 exit_code: None,
1415 duration_ms: 0,
1416 stdout_chars: 0,
1417 stderr_chars: 0,
1418 timed_out: false,
1419 },
1420 skills: None,
1421 tools: None,
1422 detail: "Unknown provider.".to_string(),
1423 },
1424 }
1425}
1426
1427fn run_capability_probe(
1428 bin: &str,
1429 args: &[&str],
1430 cwd: &Path,
1431 cancel: Option<Arc<AtomicBool>>,
1432) -> CommandTelemetry {
1433 let args = args
1434 .iter()
1435 .map(|arg| (*arg).to_string())
1436 .collect::<Vec<_>>();
1437 command_telemetry(&run_command(
1438 CommandRequest::new(bin, &args, cwd, 20_000).cancel(cancel),
1439 ))
1440}
1441
1442fn emit_runtime_event(
1443 progress: &Option<ProgressSink>,
1444 fallback_to_stderr: bool,
1445 event: ProgressEvent,
1446) {
1447 if let Some(progress) = progress {
1448 progress(event);
1449 } else if fallback_to_stderr {
1450 eprintln!("{}", event.message);
1451 }
1452}
1453
1454impl RuntimeEventBus {
1455 fn new() -> Self {
1456 Self {
1457 started: Instant::now(),
1458 sequence: Arc::new(AtomicU64::new(1)),
1459 emit_lock: Arc::new(Mutex::new(())),
1460 sinks: Arc::new(Mutex::new(Vec::new())),
1461 }
1462 }
1463
1464 fn add_sink(&self, sink: RuntimeEventSink) {
1465 if let Ok(mut sinks) = self.sinks.lock() {
1466 sinks.push(sink);
1467 }
1468 }
1469
1470 fn progress_sink(&self) -> ProgressSink {
1471 let bus = self.clone();
1472 Arc::new(move |event| bus.emit(event, None, None))
1473 }
1474
1475 fn emit_result(&self, result: &AmonHenResult) {
1476 let status = if is_success(result) { "ok" } else { "error" };
1477 self.emit(
1478 ProgressEvent {
1479 kind: RuntimeEventKind::Result,
1480 provider: Some(result.summary.name.clone()),
1481 role: Some(result.summary.role.clone()),
1482 stage: ProgressStage::Done,
1483 status: Some(status.to_string()),
1484 iteration: Some(result.workflow.iterations),
1485 total_iterations: Some(result.workflow.iterations),
1486 is_sub_agent: false,
1487 duration_ms: Some(result.summary.duration_ms),
1488 token_usage: Some(result.summary.token_usage.clone()),
1489 tool_calls: result.summary.tool_calls.clone(),
1490 message: format!("[amon-hen] result {status}"),
1491 },
1492 Some(Box::new(result.clone())),
1493 None,
1494 );
1495 }
1496
1497 fn emit_linear_result(&self, result: &linear_delivery::LinearDeliveryResult) {
1498 let status = if result.success { "ok" } else { "error" };
1499 self.emit(
1500 progress_event_with_context(
1501 Some("linear"),
1502 Some("delivery"),
1503 ProgressStage::Done,
1504 Some(status),
1505 None,
1506 None,
1507 false,
1508 Some(result.duration_ms),
1509 None,
1510 vec![],
1511 format!("[amon-hen] linear delivery {status}"),
1512 ),
1513 None,
1514 Some(Box::new(result.clone())),
1515 );
1516 }
1517
1518 fn emit(
1519 &self,
1520 progress: ProgressEvent,
1521 result: Option<Box<AmonHenResult>>,
1522 linear_result: Option<Box<linear_delivery::LinearDeliveryResult>>,
1523 ) {
1524 let _emit_guard = self.emit_lock.lock().ok();
1525 let event = RuntimeEvent {
1526 sequence: self.sequence.fetch_add(1, Ordering::SeqCst),
1527 elapsed_ms: self.started.elapsed().as_millis(),
1528 progress,
1529 result,
1530 linear_result,
1531 };
1532 let sinks = self
1533 .sinks
1534 .lock()
1535 .map(|sinks| sinks.clone())
1536 .unwrap_or_default();
1537 for sink in sinks {
1538 sink(event.clone());
1539 }
1540 }
1541}
1542
1543fn progress_event(
1544 provider: Option<&str>,
1545 role: Option<&str>,
1546 stage: ProgressStage,
1547 status: Option<&str>,
1548 message: impl Into<String>,
1549) -> ProgressEvent {
1550 progress_event_with_context(
1551 provider,
1552 role,
1553 stage,
1554 status,
1555 None,
1556 None,
1557 false,
1558 None,
1559 None,
1560 vec![],
1561 message,
1562 )
1563}
1564
1565#[allow(clippy::too_many_arguments)]
1566fn progress_event_with_context(
1567 provider: Option<&str>,
1568 role: Option<&str>,
1569 stage: ProgressStage,
1570 status: Option<&str>,
1571 iteration: Option<usize>,
1572 total_iterations: Option<usize>,
1573 is_sub_agent: bool,
1574 duration_ms: Option<u128>,
1575 token_usage: Option<TokenUsage>,
1576 tool_calls: Vec<ToolUsage>,
1577 message: impl Into<String>,
1578) -> ProgressEvent {
1579 ProgressEvent {
1580 kind: runtime_event_kind(provider, stage, token_usage.as_ref(), &tool_calls),
1581 provider: provider.map(ToString::to_string),
1582 role: role.map(ToString::to_string),
1583 stage,
1584 status: status.map(ToString::to_string),
1585 iteration,
1586 total_iterations,
1587 is_sub_agent,
1588 duration_ms,
1589 token_usage,
1590 tool_calls,
1591 message: message.into(),
1592 }
1593}
1594
1595fn runtime_event_kind(
1596 provider: Option<&str>,
1597 stage: ProgressStage,
1598 token_usage: Option<&TokenUsage>,
1599 tool_calls: &[ToolUsage],
1600) -> RuntimeEventKind {
1601 if !tool_calls.is_empty() {
1602 RuntimeEventKind::ToolUsage
1603 } else if token_usage.is_some() {
1604 RuntimeEventKind::TokenUsage
1605 } else if provider.is_some() {
1606 RuntimeEventKind::Provider
1607 } else if matches!(stage, ProgressStage::Context) {
1608 RuntimeEventKind::Context
1609 } else {
1610 RuntimeEventKind::Iteration
1611 }
1612}
1613
1614fn command_progress(label: Option<&str>, sink: Option<ProgressSink>) -> Option<CommandProgress> {
1615 label.map(|label| CommandProgress {
1616 label: label.to_string(),
1617 sink,
1618 })
1619}
1620
1621fn render_provider_capability_statuses(statuses: &[ProviderCapabilityStatus]) -> String {
1622 let mut lines = vec!["Provider capabilities".to_string()];
1623 for status in statuses {
1624 lines.push(format!(
1625 "- {} MCP: {} ({})",
1626 status.provider, status.mcp.status, status.mcp.command
1627 ));
1628 if !status.mcp.detail.trim().is_empty() {
1629 lines.push(format!(" {}", status.mcp.detail));
1630 }
1631 if let Some(skills) = &status.skills {
1632 lines.push(format!(
1633 " skills/plugins: {} ({})",
1634 skills.status, skills.command
1635 ));
1636 if !skills.detail.trim().is_empty() {
1637 lines.push(format!(" {}", skills.detail));
1638 }
1639 }
1640 if let Some(tools) = &status.tools {
1641 lines.push(format!(
1642 " tools/extensions: {} ({})",
1643 tools.status, tools.command
1644 ));
1645 if !tools.detail.trim().is_empty() {
1646 lines.push(format!(" {}", tools.detail));
1647 }
1648 }
1649 lines.push(format!(" {}", status.detail));
1650 }
1651 lines.join("\n")
1652}
1653
1654fn run_social_login(resolved: &ResolvedArgs) -> Result<(), String> {
1655 let providers = if resolved.raw.auth_login_providers.is_empty() {
1656 resolved.members.clone()
1657 } else {
1658 resolved.raw.auth_login_providers.clone()
1659 };
1660
1661 for provider in providers {
1662 validate_engine_name(&provider, false, "--auth-login-providers")?;
1663 let (bin, args, instruction): (String, Vec<String>, &str) =
1664 match provider.as_str() {
1665 "codex" => {
1666 let mut args = vec!["login".to_string()];
1667 if resolved.raw.auth_device_code {
1668 args.push("--device-auth".to_string());
1669 }
1670 (
1671 resolve_binary("codex"),
1672 args,
1673 "Complete the Codex browser login. Deeplinks and pasted codes are supported by the provider CLI when prompted.",
1674 )
1675 }
1676 "claude" => {
1677 let mut args = vec!["auth".to_string(), "login".to_string()];
1678 match resolved.raw.claude_login_mode.as_str() {
1679 "console" => args.push("--console".to_string()),
1680 "sso" => args.push("--sso".to_string()),
1681 _ => args.push("--claudeai".to_string()),
1682 }
1683 if let Some(email) = &resolved.raw.claude_login_email {
1684 push_arg(&mut args, "--email", email.clone());
1685 }
1686 (
1687 resolve_binary("claude"),
1688 args,
1689 "Complete the Claude browser login. Paste any shown login code into this terminal when prompted; deeplinks are opened when the CLI emits them.",
1690 )
1691 }
1692 "gemini" => (
1693 resolve_binary("gemini"),
1694 vec![],
1695 "Use the Gemini CLI auth selector, choose browser/social login, and complete local callback or code paste when prompted.",
1696 ),
1697 _ => unreachable!(),
1698 };
1699 eprintln!(
1700 "[auth] launching {provider}: {}",
1701 format_command(&bin, &args)
1702 );
1703 eprintln!("[auth] {provider}: {instruction}");
1704 let result = run_interactive_auth_command(
1705 &bin,
1706 &args,
1707 &resolved.cwd,
1708 resolved.raw.auth_timeout * 1000,
1709 !resolved.raw.no_auth_open_browser,
1710 &provider,
1711 );
1712 if result.code.unwrap_or(1) != 0 {
1713 return Err(format!(
1714 "{provider} social login failed: {}",
1715 compact_failure(&result)
1716 ));
1717 }
1718 let status = provider_auth_status(&provider, resolved);
1719 eprintln!("[auth] {provider}: {} ({})", status.status, status.detail);
1720 }
1721 Ok(())
1722}
1723
1724fn run_interactive_auth_command(
1725 command: &str,
1726 args: &[String],
1727 cwd: &Path,
1728 timeout_ms: u64,
1729 open_browser: bool,
1730 provider: &str,
1731) -> CommandResult {
1732 let started = Instant::now();
1733 let mut process = Command::new(command);
1734 process
1735 .args(args)
1736 .current_dir(cwd)
1737 .stdin(Stdio::inherit())
1738 .stdout(Stdio::piped())
1739 .stderr(Stdio::piped());
1740 configure_process_group(&mut process);
1741 let mut child = match process.spawn() {
1742 Ok(child) => child,
1743 Err(error) => {
1744 return CommandResult {
1745 command: command.to_string(),
1746 args: args.to_vec(),
1747 code: None,
1748 stdout: String::new(),
1749 stderr: String::new(),
1750 timed_out: false,
1751 cancelled: false,
1752 error: Some(error.to_string()),
1753 timeout_ms,
1754 duration_ms: started.elapsed().as_millis(),
1755 }
1756 }
1757 };
1758
1759 let seen_urls = Arc::new(Mutex::new(HashSet::new()));
1760 let stdout = child.stdout.take().map(|pipe| {
1761 read_auth_pipe(
1762 pipe,
1763 true,
1764 provider.to_string(),
1765 open_browser,
1766 Arc::clone(&seen_urls),
1767 )
1768 });
1769 let stderr = child.stderr.take().map(|pipe| {
1770 read_auth_pipe(
1771 pipe,
1772 false,
1773 provider.to_string(),
1774 open_browser,
1775 Arc::clone(&seen_urls),
1776 )
1777 });
1778 let timeout = Duration::from_millis(timeout_ms);
1779 let mut timed_out = false;
1780 let code;
1781 loop {
1782 match child.try_wait() {
1783 Ok(Some(status)) => {
1784 code = status.code();
1785 break;
1786 }
1787 Ok(None) => {
1788 if timeout_ms > 0 && started.elapsed() >= timeout {
1789 timed_out = true;
1790 terminate_child_tree(&mut child);
1791 let status = child.wait().ok();
1792 code = status.and_then(|status| status.code());
1793 break;
1794 }
1795 thread::sleep(Duration::from_millis(50));
1796 }
1797 Err(error) => {
1798 return CommandResult {
1799 command: command.to_string(),
1800 args: args.to_vec(),
1801 code: None,
1802 stdout: String::new(),
1803 stderr: String::new(),
1804 timed_out,
1805 cancelled: false,
1806 error: Some(error.to_string()),
1807 timeout_ms,
1808 duration_ms: started.elapsed().as_millis(),
1809 }
1810 }
1811 }
1812 }
1813
1814 CommandResult {
1815 command: command.to_string(),
1816 args: args.to_vec(),
1817 code,
1818 stdout: stdout
1819 .and_then(|handle| handle.join().ok())
1820 .unwrap_or_default(),
1821 stderr: stderr
1822 .and_then(|handle| handle.join().ok())
1823 .unwrap_or_default(),
1824 timed_out,
1825 cancelled: false,
1826 error: None,
1827 timeout_ms,
1828 duration_ms: started.elapsed().as_millis(),
1829 }
1830}
1831
1832fn read_auth_pipe<R>(
1833 mut pipe: R,
1834 stdout: bool,
1835 provider: String,
1836 open_browser: bool,
1837 seen_urls: Arc<Mutex<HashSet<String>>>,
1838) -> thread::JoinHandle<String>
1839where
1840 R: Read + Send + 'static,
1841{
1842 thread::spawn(move || {
1843 let mut text = String::new();
1844 let mut buffer = [0u8; 4096];
1845 loop {
1846 let read = match pipe.read(&mut buffer) {
1847 Ok(0) => break,
1848 Ok(read) => read,
1849 Err(_) => break,
1850 };
1851 let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
1852 text.push_str(&chunk);
1853 if stdout {
1854 print!("{chunk}");
1855 let _ = io::stdout().flush();
1856 } else {
1857 eprint!("{chunk}");
1858 let _ = io::stderr().flush();
1859 }
1860 if open_browser {
1861 for url in extract_auth_urls(&chunk) {
1862 let mut seen = seen_urls.lock().ok();
1863 if seen.as_mut().is_some_and(|seen| !seen.insert(url.clone())) {
1864 continue;
1865 }
1866 eprintln!("[auth] {provider}: opening {url}");
1867 if let Err(error) = open_browser_url(&url) {
1868 eprintln!("[auth] {provider}: failed to open {url}: {error}");
1869 }
1870 }
1871 }
1872 }
1873 text
1874 })
1875}
1876
1877fn extract_auth_urls(text: &str) -> Vec<String> {
1878 text.split_whitespace()
1879 .map(|token| {
1880 token.trim_matches(|ch: char| {
1881 matches!(ch, '<' | '>' | '"' | '\'' | ')' | '(' | ',' | ';')
1882 })
1883 })
1884 .map(|token| token.trim_end_matches(['.', ':', ',', ';']))
1885 .filter(|token| {
1886 token.starts_with("http://")
1887 || token.starts_with("https://")
1888 || token.starts_with("codex://")
1889 || token.starts_with("openai://")
1890 || token.starts_with("claude://")
1891 || token.starts_with("anthropic://")
1892 || token.starts_with("gemini://")
1893 || token.starts_with("google://")
1894 })
1895 .map(ToString::to_string)
1896 .collect()
1897}
1898
1899fn open_browser_url(url: &str) -> Result<(), String> {
1900 let (command, args): (&str, Vec<String>) = if cfg!(target_os = "macos") {
1901 ("open", vec![url.to_string()])
1902 } else if cfg!(target_os = "windows") {
1903 (
1904 "cmd",
1905 vec![
1906 "/c".to_string(),
1907 "start".to_string(),
1908 "".to_string(),
1909 url.to_string(),
1910 ],
1911 )
1912 } else {
1913 ("xdg-open", vec![url.to_string()])
1914 };
1915 Command::new(command)
1916 .args(args)
1917 .stdin(Stdio::null())
1918 .stdout(Stdio::null())
1919 .stderr(Stdio::null())
1920 .spawn()
1921 .map(|mut child| {
1922 let _ = child.try_wait();
1923 })
1924 .map_err(|error| error.to_string())
1925}
1926
1927fn build_prompt_context_with_progress(
1928 resolved: &ResolvedArgs,
1929 progress: Option<ProgressSink>,
1930) -> Result<PromptContext, String> {
1931 let mut prompt = resolved.prompt.trim().to_string();
1932 let mut sections = Vec::new();
1933 let mut commands = Vec::new();
1934
1935 for file in &resolved.raw.files {
1936 let path = if file.is_absolute() {
1937 file.clone()
1938 } else {
1939 resolved.cwd.join(file)
1940 };
1941 let content = fs::read_to_string(&path)
1942 .map_err(|error| format!("Failed to read tagged file {}: {error}", path.display()))?;
1943 sections.push(format!("--- file: {} ---\n{}", file.display(), content));
1944 }
1945
1946 for command in &resolved.raw.commands {
1947 let shell = if cfg!(windows) { "cmd" } else { "sh" };
1948 let args = if cfg!(windows) {
1949 vec!["/C".to_string(), command.clone()]
1950 } else {
1951 vec!["-lc".to_string(), command.clone()]
1952 };
1953 let live_label = (resolved.raw.verbose || progress.is_some())
1954 .then(|| format!("context command `{}`", truncate(command, 80)));
1955 emit_runtime_event(
1956 &progress,
1957 false,
1958 progress_event(
1959 None,
1960 None,
1961 ProgressStage::Context,
1962 None,
1963 format!("[amon-hen] context command `{}`", truncate(command, 80)),
1964 ),
1965 );
1966 let result = run_command(
1967 CommandRequest::new(shell, &args, &resolved.cwd, resolved.raw.timeout * 1000)
1968 .progress(command_progress(live_label.as_deref(), progress.clone())),
1969 );
1970 let telemetry = command_telemetry(&result);
1971 let mut event = progress_event(
1972 None,
1973 None,
1974 ProgressStage::Done,
1975 Some(&telemetry.status),
1976 format!(
1977 "[amon-hen] context command `{}` {} in {:.1}s",
1978 truncate(command, 80),
1979 telemetry.status,
1980 result.duration_ms as f64 / 1000.0
1981 ),
1982 );
1983 event.kind = RuntimeEventKind::Context;
1984 emit_runtime_event(&progress, false, event);
1985 commands.push(telemetry);
1986 sections.push(format!(
1987 "--- command: {} (exit {}) ---\nstdout:\n{}\nstderr:\n{}",
1988 command,
1989 result
1990 .code
1991 .map(|code| code.to_string())
1992 .unwrap_or_else(|| "unknown".to_string()),
1993 result.stdout.trim(),
1994 result.stderr.trim()
1995 ));
1996 }
1997
1998 if !sections.is_empty() {
1999 prompt.push_str("\n\nPrompt context:\n");
2000 prompt.push_str(§ions.join("\n\n"));
2001 }
2002 Ok(PromptContext { prompt, commands })
2003}
2004
2005fn run_amon_hen_with_progress(
2006 resolved: &ResolvedArgs,
2007 query: String,
2008 prompt_commands: Vec<CommandTelemetry>,
2009 progress: Option<ProgressSink>,
2010) -> AmonHenResult {
2011 run_amon_hen_with_progress_and_cancel(resolved, query, prompt_commands, progress, None)
2012}
2013
2014fn run_amon_hen_with_progress_and_cancel(
2015 resolved: &ResolvedArgs,
2016 query: String,
2017 prompt_commands: Vec<CommandTelemetry>,
2018 progress: Option<ProgressSink>,
2019 cancel: Option<Arc<AtomicBool>>,
2020) -> AmonHenResult {
2021 let workflow = build_workflow(resolved);
2022 let mut previous_iteration = Vec::new();
2023 let mut final_members = Vec::new();
2024 let mut iterations = Vec::new();
2025
2026 for iteration in 1..=workflow.iterations {
2027 let iteration_started = Instant::now();
2028 let handoff_context =
2029 iteration_handoff_context(&previous_iteration, resolved.raw.max_member_chars);
2030 emit_runtime_event(
2031 &progress,
2032 resolved.raw.verbose,
2033 progress_event_with_context(
2034 None,
2035 None,
2036 ProgressStage::Start,
2037 None,
2038 Some(iteration),
2039 Some(workflow.iterations),
2040 false,
2041 None,
2042 None,
2043 vec![],
2044 format!(
2045 "[amon-hen] iteration {iteration}/{} started",
2046 workflow.iterations
2047 ),
2048 ),
2049 );
2050 final_members = run_iteration(
2051 resolved,
2052 &query,
2053 &workflow,
2054 iteration,
2055 &previous_iteration,
2056 progress.clone(),
2057 cancel.clone(),
2058 );
2059 iterations.push(iteration_record(
2060 iteration,
2061 workflow.iterations,
2062 final_members.clone(),
2063 iteration_started.elapsed().as_millis(),
2064 handoff_context,
2065 None,
2066 ));
2067 previous_iteration = final_members.clone();
2068 }
2069
2070 let successes = final_members
2071 .iter()
2072 .filter(|result| result.status == "ok")
2073 .cloned()
2074 .collect::<Vec<_>>();
2075 let summary = if successes.is_empty() {
2076 EngineResult {
2077 name: resolved.raw.summarizer.clone(),
2078 bin: None,
2079 status: "error".to_string(),
2080 duration_ms: 0,
2081 detail: "No Amon Hen member produced a response.".to_string(),
2082 exit_code: None,
2083 stdout: String::new(),
2084 stderr: String::new(),
2085 output: String::new(),
2086 command: String::new(),
2087 token_usage: token_usage("", ""),
2088 tool_calls: vec![],
2089 sub_agents: vec![],
2090 role: "summary".to_string(),
2091 iteration: workflow.iterations,
2092 total_iterations: workflow.iterations,
2093 team_size: 0,
2094 }
2095 } else {
2096 let summary_prompt =
2097 build_summary_prompt(&query, &successes, &workflow, resolved.raw.max_member_chars);
2098 if let Some(record) = iterations.last_mut() {
2099 record.summary_context = Some(truncate(&summary_prompt, resolved.raw.max_member_chars));
2100 }
2101 let summarizer = pick_summarizer(resolved, &successes);
2102 let mut options = engine_options(
2103 resolved,
2104 EngineOptionsInput {
2105 member: &summarizer,
2106 prompt: summary_prompt,
2107 role: "summary",
2108 iteration: workflow.iterations,
2109 workflow: &workflow,
2110 progress: progress.clone(),
2111 cancel: cancel.clone(),
2112 },
2113 );
2114 options.role = "summary".to_string();
2115 options.team_size = 0;
2116 run_engine(&summarizer, options)
2117 };
2118
2119 AmonHenResult {
2120 query,
2121 cwd: resolved.cwd.display().to_string(),
2122 members_requested: resolved.members.clone(),
2123 summarizer_requested: resolved.raw.summarizer.clone(),
2124 workflow,
2125 prompt_commands,
2126 iterations,
2127 members: final_members,
2128 summary,
2129 }
2130}
2131
2132fn build_workflow(resolved: &ResolvedArgs) -> Workflow {
2133 let mut teams = HashMap::new();
2134 for member in ENGINES {
2135 teams.insert(member.to_string(), resolved.raw.team_work);
2136 }
2137 if let Some(value) = resolved.raw.codex_sub_agents {
2138 teams.insert("codex".to_string(), value);
2139 }
2140 if let Some(value) = resolved.raw.claude_sub_agents {
2141 teams.insert("claude".to_string(), value);
2142 }
2143 if let Some(value) = resolved.raw.gemini_sub_agents {
2144 teams.insert("gemini".to_string(), value);
2145 }
2146 Workflow {
2147 handoff: resolved.raw.handoff,
2148 lead: resolved.raw.lead.clone(),
2149 planner: resolved.raw.planner.clone(),
2150 iterations: resolved.raw.iterations.max(1),
2151 team_work: resolved.raw.team_work,
2152 teams,
2153 }
2154}
2155
2156fn effective_team_size(workflow: &Workflow, member: &str) -> usize {
2157 *workflow.teams.get(member).unwrap_or(&workflow.team_work)
2158}
2159
2160fn iteration_record(
2161 iteration: usize,
2162 total_iterations: usize,
2163 members: Vec<EngineResult>,
2164 duration_ms: u128,
2165 handoff_context: Option<String>,
2166 summary_context: Option<String>,
2167) -> IterationRecord {
2168 let status = if members.iter().any(|member| member.status == "ok") {
2169 "ok"
2170 } else if members.iter().any(|member| member.status == "cancelled") {
2171 "cancelled"
2172 } else if members.iter().any(|member| member.status == "timeout") {
2173 "timeout"
2174 } else if members.iter().any(|member| member.status == "missing") {
2175 "missing"
2176 } else {
2177 "error"
2178 };
2179 let sub_agents = members
2180 .iter()
2181 .flat_map(|member| member.sub_agents.iter().cloned())
2182 .collect::<Vec<_>>();
2183 let token_usage = aggregate_results_token_usage(members.iter());
2184 let tool_calls = members
2185 .iter()
2186 .chain(sub_agents.iter())
2187 .flat_map(|member| member.tool_calls.iter().cloned())
2188 .collect::<Vec<_>>();
2189 IterationRecord {
2190 iteration,
2191 total_iterations,
2192 status: status.to_string(),
2193 duration_ms,
2194 members,
2195 sub_agents,
2196 handoff_context,
2197 summary_context,
2198 token_usage,
2199 tool_calls,
2200 }
2201}
2202
2203fn iteration_handoff_context(
2204 previous_iteration: &[EngineResult],
2205 max_member_chars: usize,
2206) -> Option<String> {
2207 let context = previous_iteration
2208 .iter()
2209 .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
2210 .map(|result| {
2211 format!(
2212 "### {} ({})\n{}",
2213 result.name,
2214 result.role,
2215 truncate(result.output.trim(), max_member_chars)
2216 )
2217 })
2218 .collect::<Vec<_>>()
2219 .join("\n\n");
2220 (!context.is_empty()).then_some(context)
2221}
2222
2223fn run_iteration(
2224 resolved: &ResolvedArgs,
2225 query: &str,
2226 workflow: &Workflow,
2227 iteration: usize,
2228 previous_iteration: &[EngineResult],
2229 progress: Option<ProgressSink>,
2230 cancel: Option<Arc<AtomicBool>>,
2231) -> Vec<EngineResult> {
2232 let ordered = execution_order(
2233 &resolved.members,
2234 workflow.planner.as_deref(),
2235 workflow.lead.as_deref(),
2236 );
2237 if workflow.handoff {
2238 let mut results = Vec::new();
2239 for member in ordered {
2240 let role = role_for(&member, workflow);
2241 let team_size = effective_team_size(workflow, &member);
2242 let prompt = build_member_prompt(MemberPromptInput {
2243 query,
2244 role: &role,
2245 workflow,
2246 iteration,
2247 team_size,
2248 previous_iteration,
2249 handoff_results: &results,
2250 plan_output: "",
2251 });
2252 let options = engine_options(
2253 resolved,
2254 EngineOptionsInput {
2255 member: &member,
2256 prompt,
2257 role: &role,
2258 iteration,
2259 workflow,
2260 progress: progress.clone(),
2261 cancel: cancel.clone(),
2262 },
2263 );
2264 results.push(run_engine(&member, options));
2265 }
2266 return results;
2267 }
2268
2269 let planner = workflow
2270 .planner
2271 .as_ref()
2272 .filter(|planner| ordered.contains(planner))
2273 .cloned();
2274 let mut results = Vec::new();
2275 let mut plan_output = String::new();
2276 if let Some(planner_name) = planner.as_ref() {
2277 let role = role_for(planner_name, workflow);
2278 let team_size = effective_team_size(workflow, planner_name);
2279 let prompt = build_member_prompt(MemberPromptInput {
2280 query,
2281 role: &role,
2282 workflow,
2283 iteration,
2284 team_size,
2285 previous_iteration,
2286 handoff_results: &[],
2287 plan_output: "",
2288 });
2289 let options = engine_options(
2290 resolved,
2291 EngineOptionsInput {
2292 member: planner_name,
2293 prompt,
2294 role: &role,
2295 iteration,
2296 workflow,
2297 progress: progress.clone(),
2298 cancel: cancel.clone(),
2299 },
2300 );
2301 let result = run_engine(planner_name, options);
2302 if result.status == "ok" {
2303 plan_output = result.output.clone();
2304 }
2305 results.push(result);
2306 }
2307
2308 let (tx, rx) = mpsc::channel();
2309 let executors = ordered
2310 .into_iter()
2311 .filter(|member| Some(member) != planner.as_ref())
2312 .collect::<Vec<_>>();
2313 let resolved = Arc::new(resolved.clone());
2314 let workflow = Arc::new(workflow.clone());
2315 let previous_iteration = Arc::new(previous_iteration.to_vec());
2316 let plan_output = Arc::new(plan_output);
2317 let progress = Arc::new(progress);
2318 let cancel = Arc::new(cancel);
2319 for member in executors.clone() {
2320 let tx = tx.clone();
2321 let resolved = Arc::clone(&resolved);
2322 let workflow = Arc::clone(&workflow);
2323 let query = query.to_string();
2324 let previous_iteration = Arc::clone(&previous_iteration);
2325 let plan_output = Arc::clone(&plan_output);
2326 let progress = Arc::clone(&progress);
2327 let cancel = Arc::clone(&cancel);
2328 thread::spawn(move || {
2329 let role = role_for(&member, &workflow);
2330 let team_size = effective_team_size(&workflow, &member);
2331 let prompt = build_member_prompt(MemberPromptInput {
2332 query: &query,
2333 role: &role,
2334 workflow: &workflow,
2335 iteration,
2336 team_size,
2337 previous_iteration: previous_iteration.as_slice(),
2338 handoff_results: &[],
2339 plan_output: &plan_output,
2340 });
2341 let options = engine_options(
2342 &resolved,
2343 EngineOptionsInput {
2344 member: &member,
2345 prompt,
2346 role: &role,
2347 iteration,
2348 workflow: &workflow,
2349 progress: (*progress).clone(),
2350 cancel: (*cancel).clone(),
2351 },
2352 );
2353 let _ = tx.send(run_engine(&member, options));
2354 });
2355 }
2356 drop(tx);
2357 let mut executor_results = rx.into_iter().collect::<Vec<_>>();
2358 executor_results.sort_by_key(|result| {
2359 executors
2360 .iter()
2361 .position(|member| member == &result.name)
2362 .unwrap_or(usize::MAX)
2363 });
2364 results.extend(executor_results);
2365 results
2366}
2367
2368fn engine_options(resolved: &ResolvedArgs, input: EngineOptionsInput<'_>) -> EngineRunOptions {
2369 EngineRunOptions {
2370 prompt: input.prompt,
2371 cwd: resolved.cwd.clone(),
2372 timeout_ms: resolved.raw.timeout * 1000,
2373 effort: provider_effort(resolved, input.member),
2374 model: provider_model(resolved, input.member),
2375 permission: provider_permission(resolved, input.member),
2376 auth: provider_auth(resolved, input.member),
2377 capability: provider_capability(resolved, input.member),
2378 role: input.role.to_string(),
2379 iteration: input.iteration,
2380 total_iterations: input.workflow.iterations,
2381 team_size: effective_team_size(input.workflow, input.member),
2382 is_sub_agent: false,
2383 live: resolved.raw.verbose || input.progress.is_some(),
2384 progress: input.progress,
2385 cancel: input.cancel,
2386 }
2387}
2388
2389fn provider_effort(resolved: &ResolvedArgs, member: &str) -> Option<String> {
2390 provider_option(
2391 member,
2392 &resolved.raw.codex_effort,
2393 &resolved.raw.claude_effort,
2394 &resolved.raw.gemini_effort,
2395 )
2396 .or_else(|| {
2397 resolved
2398 .raw
2399 .effort
2400 .map(|effort| effort.as_str().to_string())
2401 })
2402}
2403
2404fn provider_model(resolved: &ResolvedArgs, member: &str) -> Option<String> {
2405 provider_option(
2406 member,
2407 &resolved.raw.codex_model,
2408 &resolved.raw.claude_model,
2409 &resolved.raw.gemini_model,
2410 )
2411}
2412
2413fn provider_permission(resolved: &ResolvedArgs, member: &str) -> Option<String> {
2414 match Engine::parse(member) {
2415 Some(Engine::Codex) => Some(resolved.raw.codex_sandbox.clone()),
2416 Some(Engine::Claude) => Some(resolved.raw.claude_permission_mode.clone()),
2417 Some(Engine::Gemini) | None => None,
2418 }
2419}
2420
2421fn provider_auth(resolved: &ResolvedArgs, member: &str) -> String {
2422 match Engine::parse(member) {
2423 Some(Engine::Codex) => resolved.raw.codex_auth.clone(),
2424 Some(Engine::Claude) => resolved.raw.claude_auth.clone(),
2425 Some(Engine::Gemini) => resolved.raw.gemini_auth.clone(),
2426 None => DEFAULT_AUTH_MODE.to_string(),
2427 }
2428}
2429
2430fn provider_capability(resolved: &ResolvedArgs, member: &str) -> ProviderCapability {
2431 match Engine::parse(member).expect("provider capabilities use validated engines") {
2432 Engine::Codex => ProviderCapability {
2433 mode: inferred_capability_mode(
2434 &resolved.raw.codex_capabilities,
2435 !resolved.raw.codex_config.is_empty() || resolved.raw.codex_mcp_profile.is_some(),
2436 ),
2437 config: resolved.raw.codex_config.clone(),
2438 mcp_profile: resolved.raw.codex_mcp_profile.clone(),
2439 mcp_config: vec![],
2440 allowed_tools: vec![],
2441 disallowed_tools: vec![],
2442 tools: vec![],
2443 agent: None,
2444 agents_json: None,
2445 plugin_dirs: vec![],
2446 strict_mcp_config: false,
2447 disable_slash_commands: false,
2448 settings: None,
2449 tools_profile: vec![],
2450 allowed_mcp_servers: vec![],
2451 policy: vec![],
2452 admin_policy: vec![],
2453 },
2454 Engine::Claude => ProviderCapability {
2455 mode: inferred_capability_mode(
2456 &resolved.raw.claude_capabilities,
2457 !resolved.raw.claude_mcp_config.is_empty()
2458 || !resolved.raw.claude_allowed_tools.is_empty()
2459 || !resolved.raw.claude_disallowed_tools.is_empty()
2460 || !resolved.raw.claude_tools.is_empty()
2461 || resolved.raw.claude_agent.is_some()
2462 || resolved.raw.claude_agents_json.is_some()
2463 || !resolved.raw.claude_plugin_dir.is_empty()
2464 || resolved.raw.claude_strict_mcp_config
2465 || resolved.raw.claude_disable_slash_commands,
2466 ),
2467 config: vec![],
2468 mcp_profile: None,
2469 mcp_config: resolved.raw.claude_mcp_config.clone(),
2470 allowed_tools: resolved.raw.claude_allowed_tools.clone(),
2471 disallowed_tools: resolved.raw.claude_disallowed_tools.clone(),
2472 tools: resolved.raw.claude_tools.clone(),
2473 agent: resolved.raw.claude_agent.clone(),
2474 agents_json: resolved.raw.claude_agents_json.clone(),
2475 plugin_dirs: resolved.raw.claude_plugin_dir.clone(),
2476 strict_mcp_config: resolved.raw.claude_strict_mcp_config,
2477 disable_slash_commands: resolved.raw.claude_disable_slash_commands,
2478 settings: None,
2479 tools_profile: vec![],
2480 allowed_mcp_servers: vec![],
2481 policy: vec![],
2482 admin_policy: vec![],
2483 },
2484 Engine::Gemini => ProviderCapability {
2485 mode: inferred_capability_mode(
2486 &resolved.raw.gemini_capabilities,
2487 resolved.raw.gemini_settings.is_some()
2488 || !resolved.raw.gemini_tools_profile.is_empty()
2489 || !resolved.raw.gemini_allowed_mcp_servers.is_empty()
2490 || !resolved.raw.gemini_policy.is_empty()
2491 || !resolved.raw.gemini_admin_policy.is_empty(),
2492 ),
2493 config: vec![],
2494 mcp_profile: None,
2495 mcp_config: vec![],
2496 allowed_tools: vec![],
2497 disallowed_tools: vec![],
2498 tools: vec![],
2499 agent: None,
2500 agents_json: None,
2501 plugin_dirs: vec![],
2502 strict_mcp_config: false,
2503 disable_slash_commands: false,
2504 settings: resolved.raw.gemini_settings.clone(),
2505 tools_profile: resolved.raw.gemini_tools_profile.clone(),
2506 allowed_mcp_servers: resolved.raw.gemini_allowed_mcp_servers.clone(),
2507 policy: resolved.raw.gemini_policy.clone(),
2508 admin_policy: resolved.raw.gemini_admin_policy.clone(),
2509 },
2510 }
2511}
2512
2513fn inferred_capability_mode(configured: &str, has_override_flags: bool) -> String {
2514 if configured == CAPABILITY_INHERIT && has_override_flags {
2515 CAPABILITY_OVERRIDE.to_string()
2516 } else {
2517 configured.to_string()
2518 }
2519}
2520
2521fn provider_option(
2522 member: &str,
2523 codex: &Option<String>,
2524 claude: &Option<String>,
2525 gemini: &Option<String>,
2526) -> Option<String> {
2527 match Engine::parse(member) {
2528 Some(Engine::Codex) => codex.clone(),
2529 Some(Engine::Claude) => claude.clone(),
2530 Some(Engine::Gemini) => gemini.clone(),
2531 None => None,
2532 }
2533}
2534
2535fn build_sub_agent_prompt(original: &str, role: &str, index: usize, total: usize) -> String {
2536 format!(
2537 "You are sub-agent {index} of {total} for an Amon Hen provider assigned role `{role}`.\n\
2538 Work independently on a useful slice of the task. Inspect, reason, or verify as needed, \
2539 then return concise findings, risks, and concrete recommendations for the provider lead.\n\n\
2540 Original provider prompt:\n{original}"
2541 )
2542}
2543
2544fn build_team_lead_prompt(original: &str, sub_agents: &[EngineResult]) -> String {
2545 let handoff = sub_agents
2546 .iter()
2547 .map(|agent| {
2548 format!(
2549 "### {} [{}]\n{}",
2550 agent.role,
2551 agent.status,
2552 if agent.output.trim().is_empty() {
2553 agent.detail.trim()
2554 } else {
2555 agent.output.trim()
2556 }
2557 )
2558 })
2559 .collect::<Vec<_>>()
2560 .join("\n\n");
2561 format!(
2562 "You are the provider lead. Use the sub-agent handoffs below, resolve disagreements, \
2563 and produce the final provider response for the original Amon Hen role.\n\n\
2564 Sub-agent handoffs:\n{handoff}\n\nOriginal provider prompt:\n{original}"
2565 )
2566}
2567
2568fn run_engine(name: &str, options: EngineRunOptions) -> EngineResult {
2569 if let Some(result) = missing_engine_if_unavailable(name, &options) {
2570 return result;
2571 }
2572 if options.team_size > 0 && options.role != "summary" && !options.is_sub_agent {
2573 return run_engine_team(name, options);
2574 }
2575 run_engine_single(name, options, vec![])
2576}
2577
2578fn missing_engine_if_unavailable(name: &str, options: &EngineRunOptions) -> Option<EngineResult> {
2579 let bin = resolve_binary(name);
2580 if command_available(&bin) {
2581 return None;
2582 }
2583 let detail = format!(
2584 "Provider CLI `{bin}` was not found in PATH. Install the {name} CLI on this machine or set {} to the executable path.",
2585 Engine::parse(name)
2586 .map(Engine::binary_env_var)
2587 .unwrap_or("AMON_HEN_PROVIDER_BIN")
2588 );
2589 let message = format!("[amon-hen] {name} missing: {detail}");
2590 emit_runtime_event(
2591 &options.progress,
2592 options.live && options.progress.is_none(),
2593 progress_event_with_context(
2594 Some(name),
2595 Some(&options.role),
2596 ProgressStage::Done,
2597 Some("missing"),
2598 Some(options.iteration),
2599 Some(options.total_iterations),
2600 options.is_sub_agent,
2601 Some(0),
2602 Some(token_usage(&options.prompt, "")),
2603 vec![],
2604 message,
2605 ),
2606 );
2607 Some(EngineResult {
2608 name: name.to_string(),
2609 bin: Some(bin.clone()),
2610 status: "missing".to_string(),
2611 duration_ms: 0,
2612 detail: detail.clone(),
2613 exit_code: None,
2614 stdout: String::new(),
2615 stderr: detail,
2616 output: String::new(),
2617 command: bin,
2618 token_usage: token_usage(&options.prompt, ""),
2619 tool_calls: vec![],
2620 sub_agents: vec![],
2621 role: options.role.clone(),
2622 iteration: options.iteration,
2623 total_iterations: options.total_iterations,
2624 team_size: options.team_size,
2625 })
2626}
2627
2628fn command_available(command: &str) -> bool {
2629 let path = Path::new(command);
2630 if path.components().count() > 1 || path.is_absolute() {
2631 return path.is_file();
2632 }
2633 let Some(paths) = std::env::var_os("PATH") else {
2634 return false;
2635 };
2636 std::env::split_paths(&paths).any(|dir| dir.join(command).is_file())
2637}
2638
2639fn run_engine_team(name: &str, options: EngineRunOptions) -> EngineResult {
2640 let team_size = options.team_size;
2641 let (tx, rx) = mpsc::channel();
2642 for index in 1..=team_size {
2643 let tx = tx.clone();
2644 let name = name.to_string();
2645 let mut sub_options = options.clone();
2646 sub_options.team_size = 0;
2647 sub_options.is_sub_agent = true;
2648 sub_options.role = format!("{}:sub-agent-{index}", options.role);
2649 sub_options.prompt =
2650 build_sub_agent_prompt(&options.prompt, &options.role, index, team_size);
2651 thread::spawn(move || {
2652 let _ = tx.send((index, run_engine_single(&name, sub_options, vec![])));
2653 });
2654 }
2655 drop(tx);
2656
2657 let mut indexed = rx.into_iter().collect::<Vec<_>>();
2658 indexed.sort_by_key(|(index, _)| *index);
2659 let sub_agents = indexed
2660 .into_iter()
2661 .map(|(_, result)| result)
2662 .collect::<Vec<_>>();
2663 let mut lead_options = options.clone();
2664 lead_options.prompt = build_team_lead_prompt(&options.prompt, &sub_agents);
2665 lead_options.is_sub_agent = true;
2666 run_engine_single(name, lead_options, sub_agents)
2667}
2668
2669fn run_engine_single(
2670 name: &str,
2671 options: EngineRunOptions,
2672 sub_agents: Vec<EngineResult>,
2673) -> EngineResult {
2674 let started = Instant::now();
2675 let bin = resolve_binary(name);
2676 let live = options.live;
2677 let role_is_sub_agent = options.role.contains(":sub-agent-");
2678 let start_message = format!(
2679 "[amon-hen] start {} role={} iteration={}/{}{}",
2680 name,
2681 options.role,
2682 options.iteration,
2683 options.total_iterations,
2684 if role_is_sub_agent { " sub-agent" } else { "" }
2685 );
2686 emit_runtime_event(
2687 &options.progress,
2688 live && options.progress.is_none(),
2689 progress_event_with_context(
2690 Some(name),
2691 Some(&options.role),
2692 ProgressStage::Start,
2693 Some("running"),
2694 Some(options.iteration),
2695 Some(options.total_iterations),
2696 role_is_sub_agent,
2697 None,
2698 None,
2699 vec![],
2700 start_message,
2701 ),
2702 );
2703 let result = match Engine::parse(name) {
2704 Some(Engine::Codex) => run_codex(&bin, &options),
2705 Some(Engine::Claude) => run_claude(&bin, &options),
2706 Some(Engine::Gemini) => run_gemini(&bin, &options),
2707 None => CommandResult {
2708 command: name.to_string(),
2709 args: vec![],
2710 code: None,
2711 stdout: String::new(),
2712 stderr: String::new(),
2713 timed_out: false,
2714 cancelled: false,
2715 error: Some(format!("Unknown engine: {name}")),
2716 timeout_ms: options.timeout_ms,
2717 duration_ms: started.elapsed().as_millis(),
2718 },
2719 };
2720 let engine_progress = options.progress.clone();
2721 let engine = finalize_engine(
2722 name,
2723 &bin,
2724 started.elapsed().as_millis(),
2725 result,
2726 options,
2727 sub_agents,
2728 );
2729 if live {
2730 let done_message = format!(
2731 "[amon-hen] done {} role={} status={} elapsed={:.1}s tokens={} tools={} sub-agents={}",
2732 engine.name,
2733 engine.role,
2734 engine.status,
2735 engine.duration_ms as f64 / 1000.0,
2736 engine.token_usage.total,
2737 engine.tool_calls.len(),
2738 engine.sub_agents.len()
2739 );
2740 emit_runtime_event(
2741 &engine_progress,
2742 live && engine_progress.is_none(),
2743 progress_event_with_context(
2744 Some(&engine.name),
2745 Some(&engine.role),
2746 ProgressStage::Done,
2747 Some(&engine.status),
2748 Some(engine.iteration),
2749 Some(engine.total_iterations),
2750 engine.role.contains(":sub-agent-"),
2751 Some(engine.duration_ms),
2752 Some(engine.token_usage.clone()),
2753 engine.tool_calls.clone(),
2754 done_message,
2755 ),
2756 );
2757 }
2758 engine
2759}
2760
2761fn push_arg(args: &mut Vec<String>, flag: &str, value: impl Into<String>) {
2762 args.push(flag.to_string());
2763 args.push(value.into());
2764}
2765
2766fn push_optional_arg(args: &mut Vec<String>, flag: &str, value: &Option<String>) {
2767 if let Some(value) = value {
2768 push_arg(args, flag, value.clone());
2769 }
2770}
2771
2772fn push_repeated_flag(args: &mut Vec<String>, flag: &str, values: &[String]) {
2773 if !values.is_empty() {
2774 args.push(flag.to_string());
2775 args.extend(values.iter().cloned());
2776 }
2777}
2778
2779fn push_each_arg(args: &mut Vec<String>, flag: &str, values: &[String]) {
2780 for value in values {
2781 push_arg(args, flag, value.clone());
2782 }
2783}
2784
2785fn run_codex(bin: &str, options: &EngineRunOptions) -> CommandResult {
2786 let temp = match tempfile::tempdir() {
2787 Ok(temp) => temp,
2788 Err(error) => {
2789 return CommandResult {
2790 command: bin.to_string(),
2791 args: vec![],
2792 code: None,
2793 stdout: String::new(),
2794 stderr: String::new(),
2795 timed_out: false,
2796 cancelled: false,
2797 error: Some(error.to_string()),
2798 timeout_ms: options.timeout_ms,
2799 duration_ms: 0,
2800 }
2801 }
2802 };
2803 let output_path = temp.path().join("last-message.txt");
2804 let mut args = vec!["exec".to_string()];
2805 push_optional_arg(&mut args, "--model", &options.model);
2806 if let Some(effort) = &options.effort {
2807 push_arg(&mut args, "-c", format!("model_reasoning_effort={effort}"));
2808 }
2809 if options.capability.mode == CAPABILITY_OVERRIDE {
2810 for config in &options.capability.config {
2811 push_arg(&mut args, "-c", config.clone());
2812 }
2813 push_optional_arg(&mut args, "--profile", &options.capability.mcp_profile);
2814 }
2815 args.extend([
2816 "--skip-git-repo-check".to_string(),
2817 "--sandbox".to_string(),
2818 options
2819 .permission
2820 .clone()
2821 .unwrap_or_else(|| "read-only".to_string()),
2822 "--ephemeral".to_string(),
2823 "--json".to_string(),
2824 "-o".to_string(),
2825 output_path.display().to_string(),
2826 "-".to_string(),
2827 ]);
2828 let mut result = run_command(
2829 CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
2830 .stdin_text(Some(&options.prompt))
2831 .progress(command_progress(
2832 live_label("codex", options).as_deref(),
2833 options.progress.clone(),
2834 ))
2835 .cancel(options.cancel.clone()),
2836 );
2837 if let Ok(output) = fs::read_to_string(output_path) {
2838 if !output.trim().is_empty() {
2839 result.stdout = output;
2840 }
2841 }
2842 result
2843}
2844
2845fn run_claude(bin: &str, options: &EngineRunOptions) -> CommandResult {
2846 let mut args = Vec::new();
2847 if should_use_claude_bare_mode(&options.auth) {
2848 args.push("--bare".to_string());
2849 }
2850 args.extend([
2851 "-p".to_string(),
2852 "--permission-mode".to_string(),
2853 options
2854 .permission
2855 .clone()
2856 .unwrap_or_else(|| "plan".to_string()),
2857 "--verbose".to_string(),
2858 "--output-format".to_string(),
2859 "stream-json".to_string(),
2860 "--include-partial-messages".to_string(),
2861 "--no-session-persistence".to_string(),
2862 ]);
2863 push_optional_arg(&mut args, "--model", &options.model);
2864 if let Some(effort) = options
2865 .effort
2866 .clone()
2867 .or_else(|| std::env::var("CLAUDE_CODE_EFFORT_LEVEL").ok())
2868 {
2869 push_arg(&mut args, "--effort", effort);
2870 }
2871 if options.capability.mode == CAPABILITY_OVERRIDE {
2872 push_repeated_flag(&mut args, "--mcp-config", &options.capability.mcp_config);
2873 push_repeated_flag(
2874 &mut args,
2875 "--allowedTools",
2876 &options.capability.allowed_tools,
2877 );
2878 push_repeated_flag(
2879 &mut args,
2880 "--disallowedTools",
2881 &options.capability.disallowed_tools,
2882 );
2883 push_repeated_flag(&mut args, "--tools", &options.capability.tools);
2884 push_optional_arg(&mut args, "--agent", &options.capability.agent);
2885 push_optional_arg(&mut args, "--agents", &options.capability.agents_json);
2886 push_each_arg(&mut args, "--plugin-dir", &options.capability.plugin_dirs);
2887 if options.capability.strict_mcp_config {
2888 args.push("--strict-mcp-config".to_string());
2889 }
2890 if options.capability.disable_slash_commands {
2891 args.push("--disable-slash-commands".to_string());
2892 }
2893 }
2894 run_command(
2895 CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
2896 .stdin_text(Some(&options.prompt))
2897 .progress(command_progress(
2898 live_label("claude", options).as_deref(),
2899 options.progress.clone(),
2900 ))
2901 .cancel(options.cancel.clone()),
2902 )
2903}
2904
2905fn run_gemini(bin: &str, options: &EngineRunOptions) -> CommandResult {
2906 let mut args = Vec::new();
2907 push_optional_arg(&mut args, "--model", &options.model);
2908 if options.capability.mode == CAPABILITY_OVERRIDE {
2909 push_repeated_flag(&mut args, "--extensions", &options.capability.tools_profile);
2910 push_repeated_flag(
2911 &mut args,
2912 "--allowed-mcp-server-names",
2913 &options.capability.allowed_mcp_servers,
2914 );
2915 push_repeated_flag(&mut args, "--policy", &options.capability.policy);
2916 push_repeated_flag(
2917 &mut args,
2918 "--admin-policy",
2919 &options.capability.admin_policy,
2920 );
2921 }
2922 args.extend([
2923 "-p".to_string(),
2924 options.prompt.clone(),
2925 "--skip-trust".to_string(),
2926 "--approval-mode".to_string(),
2927 "plan".to_string(),
2928 "--output-format".to_string(),
2929 "json".to_string(),
2930 ]);
2931 let mut envs = HashMap::new();
2932 let effort_settings = prepare_gemini_settings(options);
2933 if let Some(path) = effort_settings.as_ref() {
2934 envs.insert(
2935 "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2936 path.path.display().to_string(),
2937 );
2938 } else if options.capability.mode == CAPABILITY_OVERRIDE {
2939 if let Some(settings) = &options.capability.settings {
2940 envs.insert(
2941 "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2942 settings.clone(),
2943 );
2944 }
2945 }
2946 run_command(
2947 CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
2948 .envs(envs)
2949 .progress(command_progress(
2950 live_label("gemini", options).as_deref(),
2951 options.progress.clone(),
2952 ))
2953 .cancel(options.cancel.clone()),
2954 )
2955}
2956
2957fn live_label(name: &str, options: &EngineRunOptions) -> Option<String> {
2958 options.live.then(|| {
2959 format!(
2960 "{} {} iteration {}/{}",
2961 name, options.role, options.iteration, options.total_iterations
2962 )
2963 })
2964}
2965
2966fn provider_from_live_label(label: &str) -> Option<&str> {
2967 let provider = label.split_whitespace().next()?;
2968 ENGINES.contains(&provider).then_some(provider)
2969}
2970
2971fn role_from_live_label(label: &str) -> Option<&str> {
2972 if provider_from_live_label(label).is_some() {
2973 return label.split_whitespace().nth(1);
2974 }
2975 None
2976}
2977
2978fn iteration_from_live_label(label: &str) -> Option<usize> {
2979 iteration_pair_from_live_label(label).map(|(iteration, _)| iteration)
2980}
2981
2982fn total_iterations_from_live_label(label: &str) -> Option<usize> {
2983 iteration_pair_from_live_label(label).map(|(_, total)| total)
2984}
2985
2986fn iteration_pair_from_live_label(label: &str) -> Option<(usize, usize)> {
2987 let pair = label.split_whitespace().last()?;
2988 let (iteration, total) = pair.split_once('/')?;
2989 Some((iteration.parse().ok()?, total.parse().ok()?))
2990}
2991
2992fn prepare_gemini_settings(options: &EngineRunOptions) -> Option<TempSettings> {
2993 let effort = options.effort.as_deref()?;
2994 let budget = match effort {
2995 "low" => 1024,
2996 "medium" => 8192,
2997 "high" => 24576,
2998 _ => return None,
2999 };
3000 let dir = tempfile::tempdir().ok()?;
3001 let path = dir.path().join("settings.json");
3002 let mut settings = if options.capability.mode == CAPABILITY_OVERRIDE {
3003 options
3004 .capability
3005 .settings
3006 .as_ref()
3007 .and_then(|path| fs::read_to_string(path).ok())
3008 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
3009 .unwrap_or_else(|| Value::Object(Default::default()))
3010 } else {
3011 Value::Object(Default::default())
3012 };
3013 if let Value::Object(object) = &mut settings {
3014 object.insert("thinkingBudget".to_string(), Value::from(budget));
3015 }
3016 let _ = fs::write(&path, serde_json::to_vec(&settings).ok()?);
3017 Some(TempSettings { _dir: dir, path })
3018}
3019
3020fn should_use_claude_bare_mode(auth: &str) -> bool {
3021 if auth == "api-key" {
3022 return true;
3023 }
3024 if matches!(auth, "social-login" | "oauth" | "keychain") {
3025 return false;
3026 }
3027 if std::env::var("CLAUDE_CODE_OAUTH_TOKEN")
3028 .ok()
3029 .filter(|value| !value.trim().is_empty())
3030 .is_some()
3031 {
3032 return false;
3033 }
3034 std::env::var("ANTHROPIC_API_KEY")
3035 .ok()
3036 .filter(|value| !value.trim().is_empty())
3037 .is_some()
3038}
3039
3040fn configure_process_group(command: &mut Command) {
3041 #[cfg(unix)]
3042 {
3043 use std::os::unix::process::CommandExt;
3044 command.process_group(0);
3045 }
3046}
3047
3048fn terminate_child_tree(child: &mut Child) {
3049 #[cfg(unix)]
3050 {
3051 let pgid = -(child.id() as i32);
3052 unsafe {
3053 libc::kill(pgid, libc::SIGTERM);
3054 }
3055 for _ in 0..20 {
3056 if child.try_wait().ok().flatten().is_some() {
3057 return;
3058 }
3059 thread::sleep(Duration::from_millis(25));
3060 }
3061 unsafe {
3062 libc::kill(pgid, libc::SIGKILL);
3063 }
3064 }
3065 let _ = child.kill();
3066}
3067
3068fn run_command(request: CommandRequest<'_>) -> CommandResult {
3069 let CommandRequest {
3070 command,
3071 args,
3072 cwd,
3073 stdin_text,
3074 timeout_ms,
3075 envs,
3076 progress,
3077 cancel,
3078 } = request;
3079 let started = Instant::now();
3080 if let Some(progress) = &progress {
3081 emit_runtime_event(
3082 &progress.sink,
3083 progress.sink.is_none(),
3084 progress_event_with_context(
3085 provider_from_live_label(&progress.label),
3086 role_from_live_label(&progress.label),
3087 ProgressStage::Spawn,
3088 Some("running"),
3089 iteration_from_live_label(&progress.label),
3090 total_iterations_from_live_label(&progress.label),
3091 role_from_live_label(&progress.label)
3092 .is_some_and(|role| role.contains(":sub-agent-")),
3093 None,
3094 None,
3095 vec![],
3096 format!("[amon-hen] spawn {}", progress.label),
3097 ),
3098 );
3099 }
3100 let mut process = Command::new(command);
3101 process
3102 .args(args)
3103 .current_dir(cwd)
3104 .envs(envs)
3105 .stdin(Stdio::piped())
3106 .stdout(Stdio::piped())
3107 .stderr(Stdio::piped());
3108 configure_process_group(&mut process);
3109 let mut child = match process.spawn() {
3110 Ok(child) => child,
3111 Err(error) => {
3112 if let Some(progress) = &progress {
3113 emit_runtime_event(
3114 &progress.sink,
3115 progress.sink.is_none(),
3116 progress_event_with_context(
3117 provider_from_live_label(&progress.label),
3118 role_from_live_label(&progress.label),
3119 ProgressStage::Done,
3120 Some("missing"),
3121 iteration_from_live_label(&progress.label),
3122 total_iterations_from_live_label(&progress.label),
3123 role_from_live_label(&progress.label)
3124 .is_some_and(|role| role.contains(":sub-agent-")),
3125 Some(started.elapsed().as_millis()),
3126 None,
3127 vec![],
3128 format!("[amon-hen] spawn failed {}: {error}", progress.label),
3129 ),
3130 );
3131 }
3132 return CommandResult {
3133 command: command.to_string(),
3134 args: args.to_vec(),
3135 code: None,
3136 stdout: String::new(),
3137 stderr: String::new(),
3138 timed_out: false,
3139 cancelled: false,
3140 error: Some(error.to_string()),
3141 timeout_ms,
3142 duration_ms: started.elapsed().as_millis(),
3143 };
3144 }
3145 };
3146
3147 if let Some(mut stdin) = child.stdin.take() {
3148 if let Some(text) = stdin_text {
3149 let _ = stdin.write_all(text.as_bytes());
3150 }
3151 }
3152
3153 let stdout = child.stdout.take().map(read_pipe);
3154 let stderr = child.stderr.take().map(read_pipe);
3155 let timeout = Duration::from_millis(timeout_ms);
3156 let mut next_live_tick = Duration::from_secs(10);
3157 let mut timed_out = false;
3158 let mut cancelled = false;
3159 let code;
3160 loop {
3161 match child.try_wait() {
3162 Ok(Some(status)) => {
3163 code = status.code();
3164 break;
3165 }
3166 Ok(None) => {
3167 if cancel
3168 .as_ref()
3169 .is_some_and(|cancel| cancel.load(Ordering::Relaxed))
3170 {
3171 cancelled = true;
3172 terminate_child_tree(&mut child);
3173 let status = child.wait().ok();
3174 code = status.and_then(|status| status.code());
3175 break;
3176 }
3177 if timeout_ms > 0 && started.elapsed() >= timeout {
3178 timed_out = true;
3179 terminate_child_tree(&mut child);
3180 let status = child.wait().ok();
3181 code = status.and_then(|status| status.code());
3182 break;
3183 }
3184 if let Some(progress) = &progress {
3185 let elapsed = started.elapsed();
3186 if elapsed >= next_live_tick {
3187 let timeout_detail = if timeout_ms > 0 {
3188 format!("/{}s", timeout_ms / 1000)
3189 } else {
3190 String::new()
3191 };
3192 emit_runtime_event(
3193 &progress.sink,
3194 progress.sink.is_none(),
3195 progress_event_with_context(
3196 provider_from_live_label(&progress.label),
3197 role_from_live_label(&progress.label),
3198 ProgressStage::Heartbeat,
3199 Some("running"),
3200 iteration_from_live_label(&progress.label),
3201 total_iterations_from_live_label(&progress.label),
3202 role_from_live_label(&progress.label)
3203 .is_some_and(|role| role.contains(":sub-agent-")),
3204 Some(elapsed.as_millis()),
3205 None,
3206 vec![],
3207 format!(
3208 "[amon-hen] running {} for {}s{}",
3209 progress.label,
3210 elapsed.as_secs(),
3211 timeout_detail
3212 ),
3213 ),
3214 );
3215 next_live_tick += Duration::from_secs(10);
3216 }
3217 }
3218 thread::sleep(Duration::from_millis(25));
3219 }
3220 Err(error) => {
3221 return CommandResult {
3222 command: command.to_string(),
3223 args: args.to_vec(),
3224 code: None,
3225 stdout: String::new(),
3226 stderr: String::new(),
3227 timed_out,
3228 cancelled,
3229 error: Some(error.to_string()),
3230 timeout_ms,
3231 duration_ms: started.elapsed().as_millis(),
3232 }
3233 }
3234 }
3235 }
3236
3237 CommandResult {
3238 command: command.to_string(),
3239 args: args.to_vec(),
3240 code,
3241 stdout: stdout
3242 .and_then(|handle| handle.join().ok())
3243 .unwrap_or_default(),
3244 stderr: stderr
3245 .and_then(|handle| handle.join().ok())
3246 .unwrap_or_default(),
3247 timed_out,
3248 cancelled,
3249 error: cancelled.then(|| "cancelled".to_string()),
3250 timeout_ms,
3251 duration_ms: started.elapsed().as_millis(),
3252 }
3253}
3254
3255fn read_pipe<R>(mut pipe: R) -> thread::JoinHandle<String>
3256where
3257 R: Read + Send + 'static,
3258{
3259 thread::spawn(move || {
3260 let mut text = String::new();
3261 let _ = pipe.read_to_string(&mut text);
3262 text
3263 })
3264}
3265
3266fn finalize_engine(
3267 name: &str,
3268 bin: &str,
3269 duration_ms: u128,
3270 command_result: CommandResult,
3271 options: EngineRunOptions,
3272 sub_agents: Vec<EngineResult>,
3273) -> EngineResult {
3274 let output = match Engine::parse(name) {
3275 Some(Engine::Claude) => parse_claude_output(&command_result.stdout),
3276 Some(Engine::Gemini) => parse_gemini_output(&command_result.stdout),
3277 Some(Engine::Codex) => parse_codex_output(&command_result.stdout),
3278 None => command_result.stdout.trim().to_string(),
3279 };
3280 let status = if command_result.cancelled {
3281 "cancelled"
3282 } else if command_result.error.is_some() {
3283 "missing"
3284 } else if command_result.timed_out {
3285 "timeout"
3286 } else if command_result.code != Some(0) || output.trim().is_empty() {
3287 "error"
3288 } else {
3289 "ok"
3290 };
3291 let detail = if status == "ok" {
3292 String::new()
3293 } else if command_result.cancelled {
3294 "Cancelled by Studio/user request.".to_string()
3295 } else if command_result.timed_out {
3296 format!("Timed out after {}s.", command_result.timeout_ms / 1000)
3297 } else {
3298 compact_failure(&command_result)
3299 };
3300 let usage = aggregate_token_usage(
3301 extract_token_usage(&command_result.stdout, &options.prompt, &output)
3302 .unwrap_or_else(|| token_usage(&options.prompt, &output)),
3303 &sub_agents,
3304 );
3305 let tool_calls = extract_tool_usage(name, &command_result.stdout, &command_result.stderr);
3306 EngineResult {
3307 name: name.to_string(),
3308 bin: Some(bin.to_string()),
3309 status: status.to_string(),
3310 duration_ms,
3311 detail,
3312 exit_code: command_result.code,
3313 stdout: command_result.stdout,
3314 stderr: command_result.stderr,
3315 output,
3316 command: format_command(&command_result.command, &command_result.args),
3317 token_usage: usage,
3318 tool_calls,
3319 sub_agents,
3320 role: options.role,
3321 iteration: options.iteration,
3322 total_iterations: options.total_iterations,
3323 team_size: options.team_size,
3324 }
3325}
3326
3327fn compact_failure(result: &CommandResult) -> String {
3328 if let Some(error) = &result.error {
3329 return error.clone();
3330 }
3331 if !result.stderr.trim().is_empty() {
3332 return result.stderr.trim().to_string();
3333 }
3334 if !result.stdout.trim().is_empty() {
3335 return result.stdout.trim().to_string();
3336 }
3337 result
3338 .code
3339 .map(|code| format!("Exited with code {code}."))
3340 .unwrap_or_else(|| "Command failed.".to_string())
3341}
3342
3343fn command_telemetry(result: &CommandResult) -> CommandTelemetry {
3344 let status = if result.cancelled {
3345 "cancelled"
3346 } else if result.error.is_some() {
3347 "missing"
3348 } else if result.timed_out {
3349 "timeout"
3350 } else if result.code == Some(0) {
3351 "ok"
3352 } else {
3353 "error"
3354 };
3355 CommandTelemetry {
3356 command: format_command(&result.command, &result.args),
3357 status: status.to_string(),
3358 detail: truncate(&sanitize_status_detail(&compact_failure(result)), 600),
3359 exit_code: result.code,
3360 duration_ms: result.duration_ms,
3361 stdout_chars: result.stdout.len(),
3362 stderr_chars: result.stderr.len(),
3363 timed_out: result.timed_out,
3364 }
3365}
3366
3367fn parse_codex_output(stdout: &str) -> String {
3368 stdout.trim().to_string()
3369}
3370
3371fn parse_claude_output(stdout: &str) -> String {
3372 let trimmed = stdout.trim();
3373 if trimmed.is_empty() {
3374 return String::new();
3375 }
3376 if let Some(value) = extract_json(trimmed) {
3377 if let Some(result) = value.get("result").and_then(Value::as_str) {
3378 return result.trim().to_string();
3379 }
3380 }
3381 let mut latest = String::new();
3382 for line in trimmed.lines() {
3383 let Ok(value) = serde_json::from_str::<Value>(line) else {
3384 continue;
3385 };
3386 if value.get("type").and_then(Value::as_str) == Some("result") {
3387 if let Some(result) = value.get("result").and_then(Value::as_str) {
3388 latest = result.trim().to_string();
3389 }
3390 }
3391 if value.get("type").and_then(Value::as_str) == Some("assistant") {
3392 if let Some(content) = value.pointer("/message/content").and_then(Value::as_array) {
3393 let text = content
3394 .iter()
3395 .filter_map(|block| {
3396 (block.get("type").and_then(Value::as_str) == Some("text"))
3397 .then(|| block.get("text").and_then(Value::as_str))
3398 .flatten()
3399 })
3400 .collect::<String>();
3401 if !text.trim().is_empty() {
3402 latest = text.trim().to_string();
3403 }
3404 }
3405 }
3406 }
3407 if latest.is_empty() {
3408 trimmed.to_string()
3409 } else {
3410 latest
3411 }
3412}
3413
3414fn parse_gemini_output(stdout: &str) -> String {
3415 let trimmed = stdout.trim();
3416 if trimmed.is_empty() {
3417 return String::new();
3418 }
3419 if let Some(value) = extract_json(trimmed) {
3420 if let Some(response) = value.get("response").and_then(Value::as_str) {
3421 return response.trim().to_string();
3422 }
3423 }
3424 trimmed.to_string()
3425}
3426
3427fn extract_json(text: &str) -> Option<Value> {
3428 serde_json::from_str::<Value>(text).ok().or_else(|| {
3429 let start = text.find('{')?;
3430 let end = text.rfind('}')?;
3431 serde_json::from_str::<Value>(&text[start..=end]).ok()
3432 })
3433}
3434
3435fn build_member_prompt(input: MemberPromptInput<'_>) -> String {
3436 let mut sections = vec![
3437 "You are one member of a multi-model Amon Hen run.".to_string(),
3438 format!(
3439 "Amon Hen workflow: iteration {iteration} of {}.",
3440 input.workflow.iterations,
3441 iteration = input.iteration
3442 ),
3443 input
3444 .workflow
3445 .lead
3446 .as_ref()
3447 .map(|lead| format!("Lead model: {lead}."))
3448 .unwrap_or_else(|| "Lead model: auto.".to_string()),
3449 input
3450 .workflow
3451 .planner
3452 .as_ref()
3453 .map(|planner| format!("Planner model: {planner}."))
3454 .unwrap_or_else(|| "Planner model: none.".to_string()),
3455 format!("Your assigned role: {}.", input.role),
3456 role_instruction(input.role).to_string(),
3457 "Answer the user query directly.".to_string(),
3458 "Do not introduce yourself.".to_string(),
3459 "Do not describe your tools, environment, or capabilities unless the user explicitly asks."
3460 .to_string(),
3461 "Be concise unless the user asks for depth.".to_string(),
3462 ];
3463 if input.team_size > 0 {
3464 sections.push(format!(
3465 "Team work: you may coordinate up to {} internal sub-agents or subtasks inside your own CLI if that helps.",
3466 input.team_size
3467 ));
3468 }
3469 if input.workflow.handoff {
3470 sections.push(
3471 "Handoff mode is enabled. Treat earlier Amon Hen outputs as working context."
3472 .to_string(),
3473 );
3474 }
3475 if !input.plan_output.trim().is_empty() {
3476 sections.push(format!("Planner handoff:\n{}", input.plan_output.trim()));
3477 }
3478 let context = input
3479 .previous_iteration
3480 .iter()
3481 .chain(input.handoff_results.iter())
3482 .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
3483 .map(|result| format!("### {}\n{}", result.name, result.output.trim()))
3484 .collect::<Vec<_>>()
3485 .join("\n\n");
3486 if !context.is_empty() {
3487 sections.push(format!("Earlier Amon Hen handoffs:\n{context}"));
3488 }
3489 sections.push(format!("Current user query:\n{}", input.query.trim()));
3490 sections.join("\n\n")
3491}
3492
3493fn build_summary_prompt(
3494 query: &str,
3495 responses: &[EngineResult],
3496 workflow: &Workflow,
3497 max_member_chars: usize,
3498) -> String {
3499 let blocks = responses
3500 .iter()
3501 .map(|response| {
3502 let output = truncate(&response.output, max_member_chars);
3503 format!("### {}\n{}", response.name, output.trim())
3504 })
3505 .collect::<Vec<_>>()
3506 .join("\n\n");
3507 format!(
3508 "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{}",
3509 workflow.iterations,
3510 if workflow.iterations == 1 { "" } else { "s" },
3511 if workflow.handoff { "handoff enabled" } else { "parallel consultation" },
3512 workflow.lead.as_deref().unwrap_or("auto"),
3513 workflow.planner.as_deref().unwrap_or("none"),
3514 query.trim(),
3515 blocks
3516 )
3517}
3518
3519fn role_instruction(role: &str) -> &'static str {
3520 match role {
3521 "planner" => "Plan the work: identify the approach, risks, checkpoints, and useful handoffs for the executors.",
3522 "lead" => "Lead the work: make the strongest direct attempt while watching for conflicts you may need to resolve in synthesis.",
3523 "lead+planner" => "Plan and lead the work: produce a practical plan, then make the strongest direct attempt from that plan.",
3524 _ => "Execute the work: use any plan or handoff context, then produce your independent best answer.",
3525 }
3526}
3527
3528fn execution_order(members: &[String], planner: Option<&str>, lead: Option<&str>) -> Vec<String> {
3529 let mut ordered = Vec::new();
3530 if let Some(planner) = planner {
3531 if members.iter().any(|member| member == planner) {
3532 ordered.push(planner.to_string());
3533 }
3534 }
3535 ordered.extend(
3536 members
3537 .iter()
3538 .filter(|member| Some(member.as_str()) != planner && Some(member.as_str()) != lead)
3539 .cloned(),
3540 );
3541 if lead != planner {
3542 if let Some(lead) = lead {
3543 if members.iter().any(|member| member == lead) {
3544 ordered.push(lead.to_string());
3545 }
3546 }
3547 }
3548 ordered
3549}
3550
3551fn role_for(member: &str, workflow: &Workflow) -> String {
3552 let is_lead = workflow.lead.as_deref() == Some(member);
3553 let is_planner = workflow.planner.as_deref() == Some(member);
3554 match (is_lead, is_planner) {
3555 (true, true) => "lead+planner",
3556 (true, false) => "lead",
3557 (false, true) => "planner",
3558 (false, false) => "executor",
3559 }
3560 .to_string()
3561}
3562
3563fn pick_summarizer(resolved: &ResolvedArgs, successes: &[EngineResult]) -> String {
3564 if resolved.raw.summarizer != "auto" {
3565 return resolved.raw.summarizer.clone();
3566 }
3567 if let Some(lead) = &resolved.raw.lead {
3568 if successes.iter().any(|result| &result.name == lead) {
3569 return lead.clone();
3570 }
3571 }
3572 DEFAULT_SUMMARIZER_ORDER
3573 .iter()
3574 .find(|name| successes.iter().any(|result| result.name == **name))
3575 .unwrap_or(&successes[0].name.as_str())
3576 .to_string()
3577}
3578
3579fn resolve_binary(name: &str) -> String {
3580 let Some(engine) = Engine::parse(name) else {
3581 return name.to_string();
3582 };
3583 std::env::var(engine.binary_env_var())
3584 .ok()
3585 .filter(|value| !value.trim().is_empty())
3586 .unwrap_or_else(|| engine.as_str().to_string())
3587}
3588
3589fn token_usage(prompt: &str, output: &str) -> TokenUsage {
3590 let input = estimate_tokens(prompt);
3591 let output = estimate_tokens(output);
3592 TokenUsage {
3593 input,
3594 output,
3595 total: input + output,
3596 estimated: true,
3597 source: "estimate".to_string(),
3598 }
3599}
3600
3601fn aggregate_token_usage(mut usage: TokenUsage, sub_agents: &[EngineResult]) -> TokenUsage {
3602 if sub_agents.is_empty() {
3603 return usage;
3604 }
3605 for agent in sub_agents {
3606 usage.input += agent.token_usage.input;
3607 usage.output += agent.token_usage.output;
3608 usage.total += agent.token_usage.total;
3609 usage.estimated |= agent.token_usage.estimated;
3610 }
3611 usage.source = format!("{}+sub-agents", usage.source);
3612 usage
3613}
3614
3615fn aggregate_results_token_usage<'a>(
3616 results: impl IntoIterator<Item = &'a EngineResult>,
3617) -> TokenUsage {
3618 let mut usage = TokenUsage {
3619 input: 0,
3620 output: 0,
3621 total: 0,
3622 estimated: false,
3623 source: "aggregate".to_string(),
3624 };
3625 for result in results {
3626 usage.input += result.token_usage.input;
3627 usage.output += result.token_usage.output;
3628 usage.total += result.token_usage.total;
3629 usage.estimated |= result.token_usage.estimated;
3630 }
3631 usage
3632}
3633
3634fn extract_token_usage(stdout: &str, prompt: &str, output: &str) -> Option<TokenUsage> {
3635 let mut usage = TokenAccumulator::default();
3636 for value in json_values(stdout) {
3637 collect_token_counts(&value, &mut usage);
3638 }
3639 if usage.input.is_none() && usage.output.is_none() && usage.total.is_none() {
3640 return None;
3641 }
3642 let input = usage.input.unwrap_or_else(|| estimate_tokens(prompt));
3643 let output = usage.output.unwrap_or_else(|| estimate_tokens(output));
3644 let total = usage.total.unwrap_or(input + output);
3645 Some(TokenUsage {
3646 input,
3647 output,
3648 total,
3649 estimated: usage.input.is_none() || usage.output.is_none(),
3650 source: "provider".to_string(),
3651 })
3652}
3653
3654#[derive(Default)]
3655struct TokenAccumulator {
3656 input: Option<usize>,
3657 output: Option<usize>,
3658 total: Option<usize>,
3659}
3660
3661fn collect_token_counts(value: &Value, usage: &mut TokenAccumulator) {
3662 match value {
3663 Value::Object(map) => {
3664 for (key, value) in map {
3665 let normalized = key.replace(['_', '-'], "").to_ascii_lowercase();
3666 if let Some(count) = value.as_u64().map(|value| value as usize) {
3667 match normalized.as_str() {
3668 "inputtokens" | "inputtokencount" | "prompttokens" | "prompttokencount" => {
3669 usage.input = Some(count)
3670 }
3671 "outputtokens"
3672 | "outputtokencount"
3673 | "completiontokens"
3674 | "candidatestokencount" => usage.output = Some(count),
3675 "totaltokens" | "totaltokencount" => usage.total = Some(count),
3676 _ => {}
3677 }
3678 }
3679 collect_token_counts(value, usage);
3680 }
3681 }
3682 Value::Array(values) => {
3683 for value in values {
3684 collect_token_counts(value, usage);
3685 }
3686 }
3687 _ => {}
3688 }
3689}
3690
3691fn extract_tool_usage(provider: &str, stdout: &str, stderr: &str) -> Vec<ToolUsage> {
3692 let mut tools = Vec::new();
3693 for value in json_values(stdout) {
3694 collect_tool_usage(&value, &mut tools);
3695 }
3696 for line in stdout.lines().chain(stderr.lines()) {
3697 if let Some(tool) = line_tool_usage(provider, line) {
3698 tools.push(tool);
3699 }
3700 }
3701 dedupe_tools(tools)
3702}
3703
3704fn collect_tool_usage(value: &Value, tools: &mut Vec<ToolUsage>) {
3705 match value {
3706 Value::Object(map) => {
3707 let type_name = map.get("type").and_then(Value::as_str).unwrap_or_default();
3708 let name = map
3709 .get("name")
3710 .or_else(|| map.get("tool"))
3711 .or_else(|| map.get("tool_name"))
3712 .or_else(|| map.get("toolName"))
3713 .and_then(Value::as_str);
3714 if let Some(name) = name.filter(|_| {
3715 type_name.contains("tool")
3716 || map.contains_key("input")
3717 || map.contains_key("arguments")
3718 || map.contains_key("toolCall")
3719 }) {
3720 tools.push(ToolUsage {
3721 name: name.to_string(),
3722 kind: if type_name.is_empty() {
3723 "tool"
3724 } else {
3725 type_name
3726 }
3727 .to_string(),
3728 status: "observed".to_string(),
3729 detail: short_json_detail(value),
3730 });
3731 }
3732 for value in map.values() {
3733 collect_tool_usage(value, tools);
3734 }
3735 }
3736 Value::Array(values) => {
3737 for value in values {
3738 collect_tool_usage(value, tools);
3739 }
3740 }
3741 _ => {}
3742 }
3743}
3744
3745fn line_tool_usage(provider: &str, line: &str) -> Option<ToolUsage> {
3746 let trimmed = line.trim();
3747 let lowered = trimmed.to_ascii_lowercase();
3748 let marker = ["running shell:", "tool:", "tool_use", "mcp:", "command:"]
3749 .iter()
3750 .find(|marker| lowered.contains(**marker))?;
3751 Some(ToolUsage {
3752 name: marker.trim_end_matches(':').to_string(),
3753 kind: provider.to_string(),
3754 status: "observed".to_string(),
3755 detail: truncate(trimmed, 240),
3756 })
3757}
3758
3759fn short_json_detail(value: &Value) -> String {
3760 serde_json::to_string(value)
3761 .map(|text| truncate(&text, 240))
3762 .unwrap_or_default()
3763}
3764
3765fn dedupe_tools(tools: Vec<ToolUsage>) -> Vec<ToolUsage> {
3766 let mut seen = HashSet::new();
3767 tools
3768 .into_iter()
3769 .filter(|tool| seen.insert(format!("{}:{}:{}", tool.kind, tool.name, tool.detail)))
3770 .collect()
3771}
3772
3773fn json_values(text: &str) -> Vec<Value> {
3774 let trimmed = text.trim();
3775 if trimmed.is_empty() {
3776 return vec![];
3777 }
3778 let mut values = Vec::new();
3779 if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
3780 values.push(value);
3781 return values;
3782 }
3783 for line in trimmed.lines() {
3784 if let Ok(value) = serde_json::from_str::<Value>(line.trim()) {
3785 values.push(value);
3786 }
3787 }
3788 values
3789}
3790
3791fn estimate_tokens(text: &str) -> usize {
3792 if text.trim().is_empty() {
3793 0
3794 } else {
3795 text.len().div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN)
3796 }
3797}
3798
3799fn format_command(command: &str, args: &[String]) -> String {
3800 std::iter::once(command.to_string())
3801 .chain(args.iter().map(|arg| shell_quote(arg)))
3802 .collect::<Vec<_>>()
3803 .join(" ")
3804}
3805
3806fn shell_quote(value: &str) -> String {
3807 if value
3808 .chars()
3809 .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
3810 {
3811 value.to_string()
3812 } else {
3813 serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
3814 }
3815}
3816
3817fn truncate(text: &str, max_chars: usize) -> String {
3818 if text.chars().count() <= max_chars {
3819 text.to_string()
3820 } else {
3821 let mut truncated = text.chars().take(max_chars).collect::<String>();
3822 truncated.push_str("\n...[truncated]");
3823 truncated
3824 }
3825}
3826
3827fn should_show_banner(raw: &CliArgs) -> bool {
3828 !raw.no_banner && !raw.headless && !raw.json && !raw.json_stream && !raw.plain
3829}
3830
3831fn linear_delivery_requested(raw: &CliArgs) -> bool {
3832 raw.deliver_linear
3833 || raw.linear_until_complete
3834 || raw.linear_watch
3835 || !raw.linear_issue.is_empty()
3836 || raw
3837 .linear_query
3838 .as_ref()
3839 .is_some_and(|value| !value.trim().is_empty())
3840 || !raw.linear_project.is_empty()
3841 || !raw.linear_epic.is_empty()
3842}
3843
3844fn render_banner() -> &'static str {
3845 " _ _ _ \n / \\ _ __ ___ ___ _ __ | | | | ___ _ __ \n / _ \\ | '_ ` _ \\ / _ \\| '_ \\| |_| |/ _ \\ '_ \\ \n / ___ \\| | | | | | (_) | | | | _ | __/ | | |\n/_/ \\_\\_| |_| |_|\\___/|_| |_|_| |_|\\___|_| |_|"
3846}
3847
3848fn render_human_result(result: &AmonHenResult, verbose: bool) -> String {
3849 let mut lines = Vec::new();
3850 if verbose {
3851 lines.push(format!(
3852 "Amon Hen consulted: {}",
3853 result.members_requested.join(", ")
3854 ));
3855 for command in &result.prompt_commands {
3856 lines.push(format!(
3857 "cmd [{}] {} ({:.1}s)",
3858 command.status,
3859 command.command,
3860 command.duration_ms as f64 / 1000.0
3861 ));
3862 }
3863 for (index, member) in result.members.iter().enumerate() {
3864 lines.push(format!(
3865 "{}. [{}] {} ({:.1}s, tokens:{}, tools:{}, sub-agents:{}){}",
3866 index + 1,
3867 member.status,
3868 member.name,
3869 member.duration_ms as f64 / 1000.0,
3870 member.token_usage.total,
3871 member.tool_calls.len(),
3872 member.sub_agents.len(),
3873 if member.detail.is_empty() { "" } else { ": " }
3874 ));
3875 if !member.detail.is_empty() {
3876 lines.push(format!(" {}", member.detail));
3877 }
3878 for sub_agent in &member.sub_agents {
3879 lines.push(format!(
3880 " - {} [{}] tokens:{} tools:{}",
3881 sub_agent.role,
3882 sub_agent.status,
3883 sub_agent.token_usage.total,
3884 sub_agent.tool_calls.len()
3885 ));
3886 }
3887 if member.status == "ok" {
3888 lines.push(indent(&member.output, " "));
3889 }
3890 }
3891 lines.push("----------- synthesis -----------".to_string());
3892 }
3893 if result.summary.status == "ok" {
3894 lines.push(result.summary.output.trim().to_string());
3895 } else {
3896 lines.push(format!("Synthesis failed: {}", result.summary.detail));
3897 }
3898 lines.join("\n")
3899}
3900
3901fn indent(text: &str, prefix: &str) -> String {
3902 text.lines()
3903 .map(|line| format!("{prefix}{line}"))
3904 .collect::<Vec<_>>()
3905 .join("\n")
3906}
3907
3908fn is_success(result: &AmonHenResult) -> bool {
3909 result.members.iter().any(|member| member.status == "ok") && result.summary.status == "ok"
3910}
3911
3912#[cfg(test)]
3913mod tests {
3914 use super::*;
3915 use std::sync::Mutex as TestMutex;
3916
3917 static ENV_LOCK: TestMutex<()> = TestMutex::new(());
3918
3919 struct ProviderEnvGuard {
3920 saved: Vec<(&'static str, Option<OsString>)>,
3921 }
3922
3923 impl ProviderEnvGuard {
3924 fn install_echo_bins() -> Option<Self> {
3925 let echo = Path::new("/bin/echo");
3926 if !echo.is_file() {
3927 return None;
3928 }
3929 let vars = [
3930 "AMON_HEN_CODEX_BIN",
3931 "AMON_HEN_CLAUDE_BIN",
3932 "AMON_HEN_GEMINI_BIN",
3933 ];
3934 let saved = vars
3935 .into_iter()
3936 .map(|name| (name, std::env::var_os(name)))
3937 .collect::<Vec<_>>();
3938 for (name, _) in &saved {
3939 unsafe {
3940 std::env::set_var(name, echo);
3941 }
3942 }
3943 Some(Self { saved })
3944 }
3945 }
3946
3947 impl Drop for ProviderEnvGuard {
3948 fn drop(&mut self) {
3949 for (name, value) in &self.saved {
3950 unsafe {
3951 if let Some(value) = value {
3952 std::env::set_var(name, value);
3953 } else {
3954 std::env::remove_var(name);
3955 }
3956 }
3957 }
3958 }
3959 }
3960
3961 #[test]
3962 fn help_flags_use_success_exit_code() {
3963 let long_help = CliArgs::try_parse_from(["amon-hen", "--help"]).unwrap_err();
3964 let short_help = CliArgs::try_parse_from(["amon-hen", "-h"]).unwrap_err();
3965
3966 assert_eq!(long_help.kind(), ErrorKind::DisplayHelp);
3967 assert_eq!(short_help.kind(), ErrorKind::DisplayHelp);
3968 assert_eq!(parse_error_exit_code(long_help.kind()), 0);
3969 assert_eq!(parse_error_exit_code(short_help.kind()), 0);
3970 }
3971
3972 #[test]
3973 fn rendered_help_is_grouped_and_compact() {
3974 let help = render_cli_help();
3975
3976 assert!(help.contains("Core run:"));
3977 assert!(help.contains("Models, effort, and permissions:"));
3978 assert!(help.contains("Auth and provider capabilities:"));
3979 assert!(help.contains("Linear delivery:"));
3980 assert!(help.contains("--members codex,claude,gemini"));
3981 assert!(help.contains("--claude-permission-mode MODE"));
3982 assert!(!help.contains("\n\n\n"));
3983 }
3984
3985 #[test]
3986 fn parses_members_and_provider_flags() {
3987 let args = CliArgs::try_parse_from([
3988 "amon-hen",
3989 "--members",
3990 "codex,claude",
3991 "--planner",
3992 "codex",
3993 "--lead",
3994 "claude",
3995 "--codex-effort",
3996 "xhigh",
3997 "--claude-capabilities",
3998 "override",
3999 "--claude-allowed-tools",
4000 "Read,Bash(git:*)",
4001 "--claude-tools",
4002 "Read,Edit",
4003 "--claude-agent",
4004 "reviewer",
4005 "--gemini-allowed-mcp-servers",
4006 "linear,github",
4007 "ship it",
4008 ])
4009 .unwrap();
4010 let resolved = resolve_args(args).unwrap();
4011 assert_eq!(resolved.members, vec!["codex", "claude"]);
4012 assert_eq!(resolved.raw.codex_effort.as_deref(), Some("xhigh"));
4013 assert_eq!(
4014 resolved.raw.claude_allowed_tools,
4015 vec!["Read", "Bash(git:*)"]
4016 );
4017 assert_eq!(resolved.raw.claude_tools, vec!["Read", "Edit"]);
4018 assert_eq!(resolved.raw.claude_agent.as_deref(), Some("reviewer"));
4019 assert_eq!(
4020 resolved.raw.gemini_allowed_mcp_servers,
4021 vec!["linear", "github"]
4022 );
4023 }
4024
4025 #[test]
4026 fn parses_historical_linear_flags_in_rust() {
4027 let args = CliArgs::try_parse_from([
4028 "amon-hen",
4029 "--banner",
4030 "--auth-open-browser",
4031 "--members",
4032 "codex,claude,gemini",
4033 "--linear-project",
4034 "ENG",
4035 "--linear-endpoint",
4036 "https://linear.example/graphql",
4037 "--linear-auth",
4038 "oauth",
4039 "--linear-max-polls",
4040 "2",
4041 "--linear-max-concurrency",
4042 "3",
4043 "--linear-workflow-file",
4044 "docs/linear-workflow.md",
4045 "--linear-workspace-strategy",
4046 "copy",
4047 "--linear-completion-gate",
4048 "review-or-ci",
4049 "--delivery-phases",
4050 "plan,implement,verify,ship",
4051 "deliver it",
4052 ])
4053 .unwrap();
4054 let resolved = resolve_args(args).unwrap();
4055
4056 assert!(linear_delivery_requested(&resolved.raw));
4057 assert_eq!(
4058 resolved.raw.linear_endpoint.as_deref(),
4059 Some("https://linear.example/graphql")
4060 );
4061 assert_eq!(resolved.raw.linear_auth, "oauth");
4062 assert_eq!(resolved.raw.linear_max_polls, Some(2));
4063 assert_eq!(resolved.raw.linear_max_concurrency, 3);
4064 assert_eq!(
4065 resolved.raw.linear_workflow_file.as_deref(),
4066 Some(Path::new("docs/linear-workflow.md"))
4067 );
4068 assert_eq!(resolved.raw.linear_workspace_strategy, "copy");
4069 assert_eq!(resolved.raw.linear_completion_gate, "review-or-ci");
4070 assert_eq!(
4071 resolved.raw.delivery_phases,
4072 vec!["plan", "implement", "verify", "ship"]
4073 );
4074 }
4075
4076 #[test]
4077 fn cli_role_flag_matrix_reaches_provider_options_and_prompts() {
4078 let args = CliArgs::try_parse_from([
4079 "amon-hen",
4080 "--members",
4081 "codex,claude,gemini",
4082 "--planner",
4083 "codex",
4084 "--lead",
4085 "claude",
4086 "--handoff",
4087 "--iterations",
4088 "3",
4089 "--team-work",
4090 "2",
4091 "--codex-sub-agents",
4092 "4",
4093 "--claude-sub-agents",
4094 "1",
4095 "--gemini-sub-agents",
4096 "0",
4097 "--codex-model",
4098 "gpt-5.2",
4099 "--codex-effort",
4100 "xhigh",
4101 "--codex-sandbox",
4102 "danger-full-access",
4103 "--codex-config",
4104 "sandbox_workspace_write.network_access=true",
4105 "--codex-mcp-profile",
4106 "repo",
4107 "--claude-model",
4108 "sonnet",
4109 "--claude-effort",
4110 "max",
4111 "--claude-permission-mode",
4112 "bypassPermissions",
4113 "--claude-allowed-tools",
4114 "Read,Edit",
4115 "--claude-tools",
4116 "Bash",
4117 "--claude-agent",
4118 "lead-reviewer",
4119 "--gemini-model",
4120 "gemini-2.5-pro",
4121 "--gemini-effort",
4122 "high",
4123 "--gemini-settings",
4124 "gemini-settings.json",
4125 "--gemini-tools-profile",
4126 "repo,ci",
4127 "--gemini-allowed-mcp-servers",
4128 "linear",
4129 "Fix the regression",
4130 ])
4131 .unwrap();
4132 let resolved = resolve_args(args).unwrap();
4133 let workflow = build_workflow(&resolved);
4134 let previous = vec![EngineResult {
4135 name: "gemini".to_string(),
4136 bin: Some("gemini".to_string()),
4137 status: "ok".to_string(),
4138 duration_ms: 1,
4139 detail: String::new(),
4140 exit_code: Some(0),
4141 stdout: String::new(),
4142 stderr: String::new(),
4143 output: "previous iteration context".to_string(),
4144 command: "gemini".to_string(),
4145 token_usage: token_usage("a", "b"),
4146 tool_calls: vec![],
4147 sub_agents: vec![],
4148 role: "executor".to_string(),
4149 iteration: 1,
4150 total_iterations: 3,
4151 team_size: 0,
4152 }];
4153 let handoff = vec![EngineResult {
4154 name: "codex".to_string(),
4155 bin: Some("codex".to_string()),
4156 status: "ok".to_string(),
4157 duration_ms: 1,
4158 detail: String::new(),
4159 exit_code: Some(0),
4160 stdout: String::new(),
4161 stderr: String::new(),
4162 output: "planner handoff context".to_string(),
4163 command: "codex".to_string(),
4164 token_usage: token_usage("a", "b"),
4165 tool_calls: vec![],
4166 sub_agents: vec![],
4167 role: "planner".to_string(),
4168 iteration: 2,
4169 total_iterations: 3,
4170 team_size: 4,
4171 }];
4172
4173 assert_eq!(workflow.iterations, 3);
4174 assert!(workflow.handoff);
4175 assert_eq!(workflow.team_work, 2);
4176 assert_eq!(workflow.teams.get("codex"), Some(&4));
4177 assert_eq!(workflow.teams.get("claude"), Some(&1));
4178 assert_eq!(workflow.teams.get("gemini"), Some(&0));
4179 assert_eq!(role_for("codex", &workflow), "planner");
4180 assert_eq!(role_for("claude", &workflow), "lead");
4181 assert_eq!(role_for("gemini", &workflow), "executor");
4182
4183 let codex_prompt = build_member_prompt(MemberPromptInput {
4184 query: "Fix the regression",
4185 role: "planner",
4186 workflow: &workflow,
4187 iteration: 2,
4188 team_size: effective_team_size(&workflow, "codex"),
4189 previous_iteration: &previous,
4190 handoff_results: &handoff,
4191 plan_output: "plan output",
4192 });
4193 assert!(codex_prompt.contains("Amon Hen workflow: iteration 2 of 3."));
4194 assert!(codex_prompt.contains("Lead model: claude."));
4195 assert!(codex_prompt.contains("Planner model: codex."));
4196 assert!(codex_prompt.contains("Your assigned role: planner."));
4197 assert!(codex_prompt.contains("Handoff mode is enabled."));
4198 assert!(codex_prompt.contains("Team work: you may coordinate up to 4 internal sub-agents"));
4199 assert!(codex_prompt.contains("Planner handoff:\nplan output"));
4200 assert!(codex_prompt.contains("previous iteration context"));
4201 assert!(codex_prompt.contains("planner handoff context"));
4202
4203 let codex_options = engine_options(
4204 &resolved,
4205 EngineOptionsInput {
4206 member: "codex",
4207 prompt: codex_prompt,
4208 role: "planner",
4209 iteration: 2,
4210 workflow: &workflow,
4211 progress: None,
4212 cancel: None,
4213 },
4214 );
4215 assert_eq!(codex_options.model.as_deref(), Some("gpt-5.2"));
4216 assert_eq!(codex_options.effort.as_deref(), Some("xhigh"));
4217 assert_eq!(
4218 codex_options.permission.as_deref(),
4219 Some("danger-full-access")
4220 );
4221 assert_eq!(codex_options.role, "planner");
4222 assert_eq!(codex_options.iteration, 2);
4223 assert_eq!(codex_options.total_iterations, 3);
4224 assert_eq!(codex_options.team_size, 4);
4225 assert_eq!(codex_options.capability.mode, CAPABILITY_OVERRIDE);
4226 assert_eq!(
4227 codex_options.capability.config,
4228 vec!["sandbox_workspace_write.network_access=true"]
4229 );
4230 assert_eq!(
4231 codex_options.capability.mcp_profile.as_deref(),
4232 Some("repo")
4233 );
4234
4235 let claude_options = engine_options(
4236 &resolved,
4237 EngineOptionsInput {
4238 member: "claude",
4239 prompt: build_member_prompt(MemberPromptInput {
4240 query: "Fix the regression",
4241 role: "lead",
4242 workflow: &workflow,
4243 iteration: 2,
4244 team_size: effective_team_size(&workflow, "claude"),
4245 previous_iteration: &[],
4246 handoff_results: &[],
4247 plan_output: "",
4248 }),
4249 role: "lead",
4250 iteration: 2,
4251 workflow: &workflow,
4252 progress: None,
4253 cancel: None,
4254 },
4255 );
4256 assert_eq!(claude_options.model.as_deref(), Some("sonnet"));
4257 assert_eq!(claude_options.effort.as_deref(), Some("max"));
4258 assert_eq!(
4259 claude_options.permission.as_deref(),
4260 Some("bypassPermissions")
4261 );
4262 assert_eq!(claude_options.role, "lead");
4263 assert_eq!(claude_options.team_size, 1);
4264 assert_eq!(claude_options.capability.mode, CAPABILITY_OVERRIDE);
4265 assert_eq!(
4266 claude_options.capability.allowed_tools,
4267 vec!["Read", "Edit"]
4268 );
4269 assert_eq!(claude_options.capability.tools, vec!["Bash"]);
4270 assert_eq!(
4271 claude_options.capability.agent.as_deref(),
4272 Some("lead-reviewer")
4273 );
4274
4275 let gemini_options = engine_options(
4276 &resolved,
4277 EngineOptionsInput {
4278 member: "gemini",
4279 prompt: build_member_prompt(MemberPromptInput {
4280 query: "Fix the regression",
4281 role: "executor",
4282 workflow: &workflow,
4283 iteration: 2,
4284 team_size: effective_team_size(&workflow, "gemini"),
4285 previous_iteration: &[],
4286 handoff_results: &[],
4287 plan_output: "",
4288 }),
4289 role: "executor",
4290 iteration: 2,
4291 workflow: &workflow,
4292 progress: None,
4293 cancel: None,
4294 },
4295 );
4296 assert_eq!(gemini_options.model.as_deref(), Some("gemini-2.5-pro"));
4297 assert_eq!(gemini_options.effort.as_deref(), Some("high"));
4298 assert_eq!(gemini_options.permission, None);
4299 assert_eq!(gemini_options.role, "executor");
4300 assert_eq!(gemini_options.team_size, 0);
4301 assert_eq!(gemini_options.capability.mode, CAPABILITY_OVERRIDE);
4302 assert_eq!(
4303 gemini_options.capability.settings.as_deref(),
4304 Some("gemini-settings.json")
4305 );
4306 assert_eq!(gemini_options.capability.tools_profile, vec!["repo", "ci"]);
4307 assert_eq!(
4308 gemini_options.capability.allowed_mcp_servers,
4309 vec!["linear"]
4310 );
4311 }
4312
4313 #[test]
4314 fn provider_command_args_include_role_matrix_settings() {
4315 let cwd = std::env::current_dir().unwrap();
4316 let codex_options = EngineRunOptions {
4317 prompt: "codex prompt".to_string(),
4318 cwd: cwd.clone(),
4319 timeout_ms: 1,
4320 effort: Some("high".to_string()),
4321 model: Some("gpt-5.2".to_string()),
4322 permission: Some("workspace-write".to_string()),
4323 auth: DEFAULT_AUTH_MODE.to_string(),
4324 capability: ProviderCapability {
4325 mode: CAPABILITY_OVERRIDE.to_string(),
4326 config: vec!["tools.web_search=true".to_string()],
4327 mcp_profile: Some("repo".to_string()),
4328 mcp_config: vec![],
4329 allowed_tools: vec![],
4330 disallowed_tools: vec![],
4331 tools: vec![],
4332 agent: None,
4333 agents_json: None,
4334 plugin_dirs: vec![],
4335 strict_mcp_config: false,
4336 disable_slash_commands: false,
4337 settings: None,
4338 tools_profile: vec![],
4339 allowed_mcp_servers: vec![],
4340 policy: vec![],
4341 admin_policy: vec![],
4342 },
4343 role: "planner".to_string(),
4344 iteration: 2,
4345 total_iterations: 3,
4346 team_size: 4,
4347 is_sub_agent: false,
4348 live: false,
4349 progress: None,
4350 cancel: None,
4351 };
4352 let codex = run_codex("definitely-missing-codex-test-bin", &codex_options);
4353 assert_eq!(codex.args[0], "exec");
4354 assert!(codex
4355 .args
4356 .windows(2)
4357 .any(|pair| pair == ["--model", "gpt-5.2"]));
4358 assert!(codex
4359 .args
4360 .windows(2)
4361 .any(|pair| pair == ["-c", "model_reasoning_effort=high"]));
4362 assert!(codex
4363 .args
4364 .windows(2)
4365 .any(|pair| pair == ["-c", "tools.web_search=true"]));
4366 assert!(codex
4367 .args
4368 .windows(2)
4369 .any(|pair| pair == ["--profile", "repo"]));
4370 assert!(codex
4371 .args
4372 .windows(2)
4373 .any(|pair| pair == ["--sandbox", "workspace-write"]));
4374
4375 let claude_options = EngineRunOptions {
4376 prompt: "claude prompt".to_string(),
4377 cwd: cwd.clone(),
4378 timeout_ms: 1,
4379 effort: Some("max".to_string()),
4380 model: Some("sonnet".to_string()),
4381 permission: Some("bypassPermissions".to_string()),
4382 auth: "social-login".to_string(),
4383 capability: ProviderCapability {
4384 mode: CAPABILITY_OVERRIDE.to_string(),
4385 config: vec![],
4386 mcp_profile: None,
4387 mcp_config: vec!["mcp.json".to_string()],
4388 allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
4389 disallowed_tools: vec!["WebFetch".to_string()],
4390 tools: vec!["Bash".to_string()],
4391 agent: Some("lead-reviewer".to_string()),
4392 agents_json: Some("agents.json".to_string()),
4393 plugin_dirs: vec!["plugins/a".to_string(), "plugins/b".to_string()],
4394 strict_mcp_config: true,
4395 disable_slash_commands: true,
4396 settings: None,
4397 tools_profile: vec![],
4398 allowed_mcp_servers: vec![],
4399 policy: vec![],
4400 admin_policy: vec![],
4401 },
4402 role: "lead".to_string(),
4403 iteration: 2,
4404 total_iterations: 3,
4405 team_size: 1,
4406 is_sub_agent: false,
4407 live: false,
4408 progress: None,
4409 cancel: None,
4410 };
4411 let claude = run_claude("definitely-missing-claude-test-bin", &claude_options);
4412 assert!(!claude.args.iter().any(|arg| arg == "--bare"));
4413 assert!(claude
4414 .args
4415 .windows(2)
4416 .any(|pair| pair == ["--permission-mode", "bypassPermissions"]));
4417 assert!(claude
4418 .args
4419 .windows(2)
4420 .any(|pair| pair == ["--model", "sonnet"]));
4421 assert!(claude
4422 .args
4423 .windows(2)
4424 .any(|pair| pair == ["--effort", "max"]));
4425 assert!(claude
4426 .args
4427 .windows(3)
4428 .any(|triple| triple == ["--allowedTools", "Read", "Edit"]));
4429 assert!(claude
4430 .args
4431 .windows(2)
4432 .any(|pair| pair == ["--agent", "lead-reviewer"]));
4433 assert!(claude.args.iter().any(|arg| arg == "--strict-mcp-config"));
4434 assert!(claude
4435 .args
4436 .iter()
4437 .any(|arg| arg == "--disable-slash-commands"));
4438
4439 let gemini_options = EngineRunOptions {
4440 prompt: "gemini prompt".to_string(),
4441 cwd,
4442 timeout_ms: 1,
4443 effort: Some("high".to_string()),
4444 model: Some("gemini-2.5-pro".to_string()),
4445 permission: None,
4446 auth: DEFAULT_AUTH_MODE.to_string(),
4447 capability: ProviderCapability {
4448 mode: CAPABILITY_OVERRIDE.to_string(),
4449 config: vec![],
4450 mcp_profile: None,
4451 mcp_config: vec![],
4452 allowed_tools: vec![],
4453 disallowed_tools: vec![],
4454 tools: vec![],
4455 agent: None,
4456 agents_json: None,
4457 plugin_dirs: vec![],
4458 strict_mcp_config: false,
4459 disable_slash_commands: false,
4460 settings: None,
4461 tools_profile: vec!["repo".to_string(), "ci".to_string()],
4462 allowed_mcp_servers: vec!["linear".to_string()],
4463 policy: vec!["default".to_string()],
4464 admin_policy: vec!["locked".to_string()],
4465 },
4466 role: "executor".to_string(),
4467 iteration: 2,
4468 total_iterations: 3,
4469 team_size: 0,
4470 is_sub_agent: false,
4471 live: false,
4472 progress: None,
4473 cancel: None,
4474 };
4475 let gemini = run_gemini("definitely-missing-gemini-test-bin", &gemini_options);
4476 assert!(gemini
4477 .args
4478 .windows(2)
4479 .any(|pair| pair == ["--model", "gemini-2.5-pro"]));
4480 assert!(gemini
4481 .args
4482 .windows(3)
4483 .any(|triple| triple == ["--extensions", "repo", "ci"]));
4484 assert!(gemini
4485 .args
4486 .windows(2)
4487 .any(|pair| pair == ["--allowed-mcp-server-names", "linear"]));
4488 assert!(gemini
4489 .args
4490 .windows(2)
4491 .any(|pair| pair == ["--policy", "default"]));
4492 assert!(gemini
4493 .args
4494 .windows(2)
4495 .any(|pair| pair == ["--admin-policy", "locked"]));
4496 assert!(gemini
4497 .args
4498 .windows(2)
4499 .any(|pair| pair == ["-p", "gemini prompt"]));
4500 }
4501
4502 #[test]
4503 fn builds_member_prompt_with_roles_and_handoff() {
4504 let workflow = Workflow {
4505 handoff: true,
4506 lead: Some("claude".to_string()),
4507 planner: Some("codex".to_string()),
4508 iterations: 2,
4509 team_work: 1,
4510 teams: HashMap::new(),
4511 };
4512 let prompt = build_member_prompt(MemberPromptInput {
4513 query: "Fix the bug",
4514 role: "planner",
4515 workflow: &workflow,
4516 iteration: 1,
4517 team_size: 1,
4518 previous_iteration: &[],
4519 handoff_results: &[],
4520 plan_output: "",
4521 });
4522 assert!(prompt.contains("Amon Hen workflow: iteration 1 of 2."));
4523 assert!(prompt.contains("Lead model: claude."));
4524 assert!(prompt.contains("Planner model: codex."));
4525 assert!(prompt.contains("Your assigned role: planner."));
4526 assert!(prompt.contains("Current user query:"));
4527 }
4528
4529 #[test]
4530 fn parses_claude_stream_json_result() {
4531 let text = r#"{"type":"system","subtype":"status","status":"requesting"}
4532{"type":"result","result":"done"}"#;
4533 assert_eq!(parse_claude_output(text), "done");
4534 }
4535
4536 #[test]
4537 fn parses_gemini_json_response() {
4538 assert_eq!(parse_gemini_output(r#"{"response":"hello"}"#), "hello");
4539 }
4540
4541 #[test]
4542 fn estimates_tokens_with_ceiling_chunks() {
4543 assert_eq!(estimate_tokens(""), 0);
4544 assert_eq!(estimate_tokens("abc"), 1);
4545 assert_eq!(estimate_tokens("abcd"), 1);
4546 assert_eq!(estimate_tokens("abcde"), 2);
4547 }
4548
4549 #[test]
4550 fn extracts_social_login_urls() {
4551 assert_eq!(
4552 extract_auth_urls("Open https://example.com/callback?code=123, then continue"),
4553 vec!["https://example.com/callback?code=123"]
4554 );
4555 assert_eq!(
4556 extract_auth_urls("deeplink: claude://login/complete."),
4557 vec!["claude://login/complete"]
4558 );
4559 }
4560
4561 #[test]
4562 fn parses_provider_token_usage() {
4563 let stdout = r#"{"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}"#;
4564 let usage = extract_token_usage(stdout, "hello", "world").unwrap();
4565 assert_eq!(usage.input, 12);
4566 assert_eq!(usage.output, 8);
4567 assert_eq!(usage.total, 20);
4568 assert!(!usage.estimated);
4569 }
4570
4571 #[test]
4572 fn extracts_tool_usage_from_provider_streams() {
4573 let stdout = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}"#;
4574 let tools = extract_tool_usage("claude", stdout, "");
4575 assert!(tools.iter().any(|tool| tool.name == "Bash"));
4576 }
4577
4578 #[test]
4579 fn builds_real_sub_agent_handoff_prompt() {
4580 let agent = EngineResult {
4581 name: "codex".to_string(),
4582 bin: Some("codex".to_string()),
4583 status: "ok".to_string(),
4584 duration_ms: 1,
4585 detail: String::new(),
4586 exit_code: Some(0),
4587 stdout: String::new(),
4588 stderr: String::new(),
4589 output: "inspect parser".to_string(),
4590 command: "codex exec".to_string(),
4591 token_usage: token_usage("a", "b"),
4592 tool_calls: vec![],
4593 sub_agents: vec![],
4594 role: "executor:sub-agent-1".to_string(),
4595 iteration: 1,
4596 total_iterations: 1,
4597 team_size: 0,
4598 };
4599 let prompt = build_team_lead_prompt("ship it", &[agent]);
4600 assert!(prompt.contains("Sub-agent handoffs"));
4601 assert!(prompt.contains("inspect parser"));
4602 assert!(prompt.contains("Original provider prompt"));
4603 }
4604
4605 #[test]
4606 fn json_stream_events_emit_progress_before_final_result() {
4607 let bus = RuntimeEventBus::new();
4608 let events = Arc::new(Mutex::new(Vec::new()));
4609 let captured = Arc::clone(&events);
4610 bus.add_sink(Arc::new(move |event| {
4611 captured.lock().unwrap().push(event);
4612 }));
4613 let progress = bus.progress_sink();
4614
4615 progress(progress_event_with_context(
4616 None,
4617 None,
4618 ProgressStage::Start,
4619 None,
4620 Some(1),
4621 Some(1),
4622 false,
4623 None,
4624 None,
4625 vec![],
4626 "[amon-hen] iteration 1/1 started",
4627 ));
4628 progress(progress_event_with_context(
4629 Some("codex"),
4630 Some("planner"),
4631 ProgressStage::Done,
4632 Some("ok"),
4633 Some(1),
4634 Some(1),
4635 false,
4636 Some(25),
4637 Some(token_usage("prompt", "answer")),
4638 vec![],
4639 "[amon-hen] done codex role=planner status=ok",
4640 ));
4641 let result = test_amon_hen_result(vec![test_engine_result("codex", "planner", 1)], 1);
4642 bus.emit_result(&result);
4643
4644 let events = events.lock().unwrap();
4645 assert!(events.len() >= 3);
4646 assert!(events[..events.len() - 1]
4647 .iter()
4648 .any(|event| event.progress.stage != ProgressStage::Done));
4649 assert!(events
4650 .iter()
4651 .any(|event| event.progress.kind == RuntimeEventKind::TokenUsage));
4652 assert_eq!(
4653 events.last().unwrap().progress.kind,
4654 RuntimeEventKind::Result
4655 );
4656 assert!(events.last().unwrap().result.is_some());
4657 for event in events.iter() {
4658 serde_json::to_string(event).expect("json-stream event should serialize");
4659 }
4660 }
4661
4662 #[test]
4663 fn runtime_event_bus_preserves_stream_sequence_order_under_fanout() {
4664 let bus = RuntimeEventBus::new();
4665 let events = Arc::new(Mutex::new(Vec::new()));
4666 let captured = Arc::clone(&events);
4667 bus.add_sink(Arc::new(move |event| {
4668 captured.lock().unwrap().push(event.sequence);
4669 }));
4670 let barrier = Arc::new(std::sync::Barrier::new(16));
4671 let mut handles = Vec::new();
4672
4673 for index in 0..16 {
4674 let progress = bus.progress_sink();
4675 let barrier = Arc::clone(&barrier);
4676 handles.push(thread::spawn(move || {
4677 barrier.wait();
4678 progress(progress_event(
4679 Some("codex"),
4680 Some("planner"),
4681 ProgressStage::Heartbeat,
4682 Some("running"),
4683 format!("[amon-hen] fanout event {index}"),
4684 ));
4685 }));
4686 }
4687 for handle in handles {
4688 handle.join().unwrap();
4689 }
4690
4691 let sequences = events.lock().unwrap().clone();
4692 assert_eq!(sequences, (1..=16).collect::<Vec<_>>());
4693 }
4694
4695 #[test]
4696 fn multiple_iterations_preserve_timeline_entries() {
4697 let first = vec![test_engine_result("codex", "planner", 1)];
4698 let second = vec![test_engine_result("claude", "lead", 2)];
4699 let timeline = vec![
4700 iteration_record(1, 2, first.clone(), 10, None, None),
4701 iteration_record(
4702 2,
4703 2,
4704 second.clone(),
4705 15,
4706 iteration_handoff_context(&first, DEFAULT_MAX_MEMBER_CHARS),
4707 Some("summary context".to_string()),
4708 ),
4709 ];
4710 let result = test_amon_hen_result_with_iterations(second, timeline);
4711
4712 assert_eq!(result.iterations.len(), 2);
4713 assert_eq!(result.iterations[0].iteration, 1);
4714 assert_eq!(result.iterations[1].iteration, 2);
4715 assert!(result.iterations[1].handoff_context.is_some());
4716 assert_eq!(result.members[0].name, "claude");
4717 }
4718
4719 #[test]
4720 fn sub_agent_roles_remain_correct_in_timeline() {
4721 let mut member = test_engine_result("gemini", "lead", 1);
4722 member.sub_agents = vec![
4723 test_engine_result("gemini", "lead:sub-agent-1", 1),
4724 test_engine_result("gemini", "lead:sub-agent-2", 1),
4725 ];
4726 let timeline = vec![iteration_record(1, 1, vec![member], 20, None, None)];
4727
4728 assert_eq!(
4729 timeline[0]
4730 .sub_agents
4731 .iter()
4732 .map(|agent| agent.role.as_str())
4733 .collect::<Vec<_>>(),
4734 vec!["lead:sub-agent-1", "lead:sub-agent-2"]
4735 );
4736 assert_eq!(timeline[0].members[0].role, "lead");
4737 }
4738
4739 #[test]
4740 fn runtime_role_matrix_reaches_json_result_and_stream_events() {
4741 let _lock = ENV_LOCK.lock().unwrap();
4742 let Some(_env) = ProviderEnvGuard::install_echo_bins() else {
4743 return;
4744 };
4745 let args = CliArgs::try_parse_from([
4746 "amon-hen",
4747 "--members",
4748 "codex,claude,gemini",
4749 "--planner",
4750 "codex",
4751 "--lead",
4752 "claude",
4753 "--handoff",
4754 "--iterations",
4755 "2",
4756 "--team-work",
4757 "1",
4758 "--codex-sub-agents",
4759 "2",
4760 "--claude-sub-agents",
4761 "1",
4762 "--gemini-sub-agents",
4763 "0",
4764 "--timeout",
4765 "5",
4766 "role matrix smoke",
4767 ])
4768 .unwrap();
4769 let resolved = resolve_args(args).unwrap();
4770 let bus = RuntimeEventBus::new();
4771 let events = Arc::new(Mutex::new(Vec::new()));
4772 let captured = Arc::clone(&events);
4773 bus.add_sink(Arc::new(move |event| {
4774 captured.lock().unwrap().push(event);
4775 }));
4776 let prompt_context =
4777 build_prompt_context_with_progress(&resolved, Some(bus.progress_sink())).unwrap();
4778 let result = run_amon_hen_with_progress_and_cancel(
4779 &resolved,
4780 prompt_context.prompt,
4781 prompt_context.commands,
4782 Some(bus.progress_sink()),
4783 None,
4784 );
4785 bus.emit_result(&result);
4786
4787 assert_eq!(result.workflow.iterations, 2);
4788 assert_eq!(result.workflow.teams.get("codex"), Some(&2));
4789 assert_eq!(result.workflow.teams.get("claude"), Some(&1));
4790 assert_eq!(result.workflow.teams.get("gemini"), Some(&0));
4791 assert_eq!(result.iterations.len(), 2);
4792 for iteration in &result.iterations {
4793 assert_eq!(
4794 iteration
4795 .members
4796 .iter()
4797 .map(|member| (member.name.as_str(), member.role.as_str()))
4798 .collect::<Vec<_>>(),
4799 vec![
4800 ("codex", "planner"),
4801 ("gemini", "executor"),
4802 ("claude", "lead")
4803 ]
4804 );
4805 assert_eq!(
4806 iteration
4807 .sub_agents
4808 .iter()
4809 .map(|agent| (agent.name.as_str(), agent.role.as_str()))
4810 .collect::<Vec<_>>(),
4811 vec![
4812 ("codex", "planner:sub-agent-1"),
4813 ("codex", "planner:sub-agent-2"),
4814 ("claude", "lead:sub-agent-1")
4815 ]
4816 );
4817 }
4818
4819 let events = events.lock().unwrap();
4820 assert!(events.iter().any(|event| {
4821 event.progress.provider.as_deref() == Some("codex")
4822 && event.progress.role.as_deref() == Some("planner")
4823 && !event.progress.is_sub_agent
4824 }));
4825 assert!(events.iter().any(|event| {
4826 event.progress.provider.as_deref() == Some("claude")
4827 && event.progress.role.as_deref() == Some("lead:sub-agent-1")
4828 && event.progress.is_sub_agent
4829 }));
4830 let final_event = events.last().expect("final result event should exist");
4831 assert_eq!(final_event.progress.kind, RuntimeEventKind::Result);
4832 assert!(final_event.result.is_some());
4833 }
4834
4835 #[test]
4836 fn per_provider_sub_agent_overrides_are_reflected_in_prompts() {
4837 let args = CliArgs::try_parse_from([
4838 "amon-hen",
4839 "--members",
4840 "codex,gemini",
4841 "--team-work",
4842 "2",
4843 "--codex-sub-agents",
4844 "0",
4845 "--gemini-sub-agents",
4846 "3",
4847 "ship",
4848 ])
4849 .unwrap();
4850 let resolved = resolve_args(args).unwrap();
4851 let workflow = build_workflow(&resolved);
4852 let codex_prompt = build_member_prompt(MemberPromptInput {
4853 query: "ship",
4854 role: "executor",
4855 workflow: &workflow,
4856 iteration: 1,
4857 team_size: effective_team_size(&workflow, "codex"),
4858 previous_iteration: &[],
4859 handoff_results: &[],
4860 plan_output: "",
4861 });
4862 let gemini_prompt = build_member_prompt(MemberPromptInput {
4863 query: "ship",
4864 role: "executor",
4865 workflow: &workflow,
4866 iteration: 1,
4867 team_size: effective_team_size(&workflow, "gemini"),
4868 previous_iteration: &[],
4869 handoff_results: &[],
4870 plan_output: "",
4871 });
4872
4873 assert!(!codex_prompt.contains("Team work:"));
4874 assert!(gemini_prompt.contains("up to 3 internal sub-agents"));
4875 }
4876
4877 #[test]
4878 fn duplicate_members_are_deduped_in_input_order() {
4879 let args = CliArgs::try_parse_from([
4880 "amon-hen",
4881 "--members",
4882 "codex,claude,codex,gemini,claude",
4883 "hello",
4884 ])
4885 .unwrap();
4886 let resolved = resolve_args(args).unwrap();
4887 assert_eq!(resolved.members, vec!["codex", "claude", "gemini"]);
4888 }
4889
4890 #[test]
4891 fn command_cancel_stops_child_process_promptly() {
4892 if !command_available("sh") {
4893 return;
4894 }
4895 let cwd = std::env::current_dir().unwrap();
4896 let cancel = Arc::new(AtomicBool::new(true));
4897 let started = Instant::now();
4898 let args = ["-c".to_string(), "sleep 30".to_string()];
4899 let result =
4900 run_command(CommandRequest::new("sh", &args, &cwd, 30_000).cancel(Some(cancel)));
4901
4902 assert!(result.cancelled);
4903 assert_eq!(result.error.as_deref(), Some("cancelled"));
4904 assert!(
4905 started.elapsed() < Duration::from_secs(3),
4906 "cancelled command took {:?}",
4907 started.elapsed()
4908 );
4909 }
4910
4911 #[test]
4912 fn missing_provider_preflight_avoids_team_fanout() {
4913 let args = CliArgs::try_parse_from([
4914 "amon-hen",
4915 "--members",
4916 "codex",
4917 "--team-work",
4918 "5",
4919 "hello",
4920 ])
4921 .unwrap();
4922 let resolved = resolve_args(args).unwrap();
4923 let options = EngineRunOptions {
4924 prompt: "hello".to_string(),
4925 cwd: resolved.cwd.clone(),
4926 timeout_ms: 1_000,
4927 effort: None,
4928 model: None,
4929 permission: None,
4930 auth: DEFAULT_AUTH_MODE.to_string(),
4931 capability: provider_capability(&resolved, "codex"),
4932 role: "planner".to_string(),
4933 iteration: 1,
4934 total_iterations: 1,
4935 team_size: 5,
4936 is_sub_agent: false,
4937 live: false,
4938 progress: None,
4939 cancel: None,
4940 };
4941
4942 let result = run_engine("definitely-missing-amon-hen-test-bin", options);
4943
4944 assert_eq!(result.status, "missing");
4945 assert!(result.sub_agents.is_empty());
4946 assert!(result.detail.contains("not found in PATH"));
4947 }
4948
4949 #[test]
4950 fn system_time_is_available_for_test_environment() {
4951 assert!(std::time::SystemTime::now()
4952 .duration_since(std::time::UNIX_EPOCH)
4953 .is_ok());
4954 }
4955
4956 #[test]
4957 fn cli_hygiene_has_no_legacy_npm_cli_or_council_binary_references() {
4958 let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
4959 let repo_root = crate_dir
4960 .parent()
4961 .and_then(Path::parent)
4962 .expect("crate lives under crates/amon-hen");
4963 assert!(!repo_root.join("package.json").exists());
4964 assert!(!repo_root.join("package-lock.json").exists());
4965 assert!(!repo_root.join("npm").exists());
4966 assert!(!repo_root.join("bin").join("council").exists());
4967
4968 let docs = [
4969 repo_root.join("README.md"),
4970 crate_dir.join("README.md"),
4971 crate_dir.join("CHANGELOG.md"),
4972 ];
4973 for path in docs {
4974 let text = fs::read_to_string(&path).unwrap();
4975 assert!(
4976 !text.contains("`council`") && !text.contains(" council "),
4977 "legacy binary reference remains in {}",
4978 path.display()
4979 );
4980 }
4981
4982 let help = render_cli_help();
4983 assert!(!help.contains("council"));
4984 assert!(help.contains("amon-hen"));
4985 }
4986
4987 fn test_amon_hen_result(members: Vec<EngineResult>, total_iterations: usize) -> AmonHenResult {
4988 let timeline = vec![iteration_record(
4989 total_iterations,
4990 total_iterations,
4991 members.clone(),
4992 25,
4993 None,
4994 Some("summary context".to_string()),
4995 )];
4996 test_amon_hen_result_with_iterations(members, timeline)
4997 }
4998
4999 fn test_amon_hen_result_with_iterations(
5000 members: Vec<EngineResult>,
5001 iterations: Vec<IterationRecord>,
5002 ) -> AmonHenResult {
5003 let mut teams = HashMap::new();
5004 teams.insert("codex".to_string(), 0);
5005 teams.insert("claude".to_string(), 0);
5006 teams.insert("gemini".to_string(), 0);
5007 let workflow = Workflow {
5008 handoff: true,
5009 lead: Some("claude".to_string()),
5010 planner: Some("codex".to_string()),
5011 iterations: iterations.len().max(1),
5012 team_work: 0,
5013 teams,
5014 };
5015 AmonHenResult {
5016 query: "ship it".to_string(),
5017 cwd: ".".to_string(),
5018 members_requested: members.iter().map(|member| member.name.clone()).collect(),
5019 summarizer_requested: "auto".to_string(),
5020 workflow,
5021 prompt_commands: vec![],
5022 iterations,
5023 members,
5024 summary: test_engine_result("codex", "summary", 1),
5025 }
5026 }
5027
5028 fn test_engine_result(name: &str, role: &str, iteration: usize) -> EngineResult {
5029 EngineResult {
5030 name: name.to_string(),
5031 bin: Some(name.to_string()),
5032 status: "ok".to_string(),
5033 duration_ms: 10,
5034 detail: String::new(),
5035 exit_code: Some(0),
5036 stdout: String::new(),
5037 stderr: String::new(),
5038 output: format!("{name} output"),
5039 command: format!("{name} exec"),
5040 token_usage: token_usage("prompt", "output"),
5041 tool_calls: vec![ToolUsage {
5042 name: "tool".to_string(),
5043 kind: name.to_string(),
5044 status: "observed".to_string(),
5045 detail: "detail".to_string(),
5046 }],
5047 sub_agents: vec![],
5048 role: role.to_string(),
5049 iteration,
5050 total_iterations: iteration.max(1),
5051 team_size: 0,
5052 }
5053 }
5054}