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