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