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