Skip to main content

amon_hen/
lib.rs

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