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