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::{Child, Command, Stdio};
10use std::sync::{
11    atomic::{AtomicBool, AtomicU64, Ordering},
12    mpsc, Arc, Mutex,
13};
14use std::thread;
15use std::time::{Duration, Instant};
16
17mod linear_delivery;
18mod studio;
19
20const VERSION: &str = env!("CARGO_PKG_VERSION");
21const DEFAULT_TIMEOUT_MS: u64 = 600_000;
22const DEFAULT_MAX_MEMBER_CHARS: usize = 12_000;
23const DEFAULT_ITERATIONS: usize = 1;
24const DEFAULT_TEAM_SIZE: usize = 0;
25const TOKEN_ESTIMATE_CHARS_PER_TOKEN: usize = 4;
26
27const ENGINES: [&str; 3] = ["codex", "claude", "gemini"];
28const DEFAULT_SUMMARIZER_ORDER: [&str; 3] = ["codex", "claude", "gemini"];
29const DEFAULT_AUTH_MODE: &str = "auto";
30const CAPABILITY_INHERIT: &str = "inherit";
31const CAPABILITY_OVERRIDE: &str = "override";
32
33#[derive(Debug, Copy, Clone, Eq, PartialEq)]
34enum Engine {
35    Codex,
36    Claude,
37    Gemini,
38}
39
40impl Engine {
41    fn parse(name: &str) -> Option<Self> {
42        match name {
43            "codex" => Some(Self::Codex),
44            "claude" => Some(Self::Claude),
45            "gemini" => Some(Self::Gemini),
46            _ => None,
47        }
48    }
49
50    fn as_str(self) -> &'static str {
51        match self {
52            Self::Codex => "codex",
53            Self::Claude => "claude",
54            Self::Gemini => "gemini",
55        }
56    }
57
58    fn binary_env_var(self) -> &'static str {
59        match self {
60            Self::Codex => "AMON_HEN_CODEX_BIN",
61            Self::Claude => "AMON_HEN_CLAUDE_BIN",
62            Self::Gemini => "AMON_HEN_GEMINI_BIN",
63        }
64    }
65
66    fn allowed_efforts(self) -> &'static [&'static str] {
67        match self {
68            Self::Codex => &["low", "medium", "high", "xhigh", ""],
69            Self::Claude => &["low", "medium", "high", "xhigh", "max"],
70            Self::Gemini => &["low", "medium", "high", ""],
71        }
72    }
73}
74
75#[derive(Parser, Debug, Clone)]
76#[command(
77    name = "amon-hen",
78    version,
79    about = "Ask multiple AI CLIs the same question, then synthesize their answers.",
80    trailing_var_arg = true,
81    disable_version_flag = true
82)]
83pub struct CliArgs {
84    #[arg(short = 'v', long = "version", action = ArgAction::SetTrue)]
85    version: bool,
86
87    #[arg(long, action = ArgAction::SetTrue)]
88    json: bool,
89
90    #[arg(long = "json-stream", alias = "ndjson", action = ArgAction::SetTrue)]
91    json_stream: bool,
92
93    #[arg(long, action = ArgAction::SetTrue)]
94    headless: bool,
95
96    #[arg(long, action = ArgAction::SetTrue)]
97    studio: bool,
98
99    #[arg(long, action = ArgAction::SetTrue)]
100    plain: bool,
101
102    #[arg(long = "no-banner", action = ArgAction::SetTrue)]
103    no_banner: bool,
104    #[arg(long = "banner", hide = true, action = ArgAction::SetTrue)]
105    banner: bool,
106
107    #[arg(short = 'q', long = "quiet", alias = "summary-only", action = ArgAction::SetTrue)]
108    summary_only: bool,
109
110    #[arg(short = 'd', long, action = ArgAction::SetTrue)]
111    verbose: bool,
112
113    #[arg(long, action = ArgAction::SetTrue)]
114    all: bool,
115
116    #[arg(long, action = ArgAction::SetTrue)]
117    codex: bool,
118    #[arg(long = "no-codex", action = ArgAction::SetTrue)]
119    no_codex: bool,
120    #[arg(long, action = ArgAction::SetTrue)]
121    claude: bool,
122    #[arg(long = "no-claude", action = ArgAction::SetTrue)]
123    no_claude: bool,
124    #[arg(long, action = ArgAction::SetTrue)]
125    gemini: bool,
126    #[arg(long = "no-gemini", action = ArgAction::SetTrue)]
127    no_gemini: bool,
128
129    #[arg(long, value_delimiter = ',')]
130    members: Vec<String>,
131
132    #[arg(long, default_value = "auto")]
133    summarizer: String,
134
135    #[arg(long, value_enum)]
136    effort: Option<Effort>,
137
138    #[arg(long = "codex-model")]
139    codex_model: Option<String>,
140    #[arg(long = "claude-model")]
141    claude_model: Option<String>,
142    #[arg(long = "gemini-model")]
143    gemini_model: Option<String>,
144
145    #[arg(long = "codex-effort")]
146    codex_effort: Option<String>,
147    #[arg(long = "claude-effort")]
148    claude_effort: Option<String>,
149    #[arg(long = "gemini-effort")]
150    gemini_effort: Option<String>,
151
152    #[arg(long = "codex-sandbox", default_value = "read-only")]
153    codex_sandbox: String,
154    #[arg(long = "claude-permission-mode", default_value = "plan")]
155    claude_permission_mode: String,
156
157    #[arg(long = "codex-auth", default_value = "auto")]
158    codex_auth: String,
159    #[arg(long = "claude-auth", default_value = "auto")]
160    claude_auth: String,
161    #[arg(long = "gemini-auth", default_value = "auto")]
162    gemini_auth: String,
163
164    #[arg(long = "codex-capabilities", default_value = "inherit")]
165    codex_capabilities: String,
166    #[arg(long = "claude-capabilities", default_value = "inherit")]
167    claude_capabilities: String,
168    #[arg(long = "gemini-capabilities", default_value = "inherit")]
169    gemini_capabilities: String,
170
171    #[arg(long = "codex-config", action = ArgAction::Append)]
172    codex_config: Vec<String>,
173    #[arg(long = "codex-mcp-profile")]
174    codex_mcp_profile: Option<String>,
175    #[arg(long = "claude-mcp-config", action = ArgAction::Append)]
176    claude_mcp_config: Vec<String>,
177    #[arg(long = "claude-allowed-tools", value_delimiter = ',', action = ArgAction::Append)]
178    claude_allowed_tools: Vec<String>,
179    #[arg(long = "claude-disallowed-tools", value_delimiter = ',', action = ArgAction::Append)]
180    claude_disallowed_tools: Vec<String>,
181    #[arg(long = "claude-tools", value_delimiter = ',', action = ArgAction::Append)]
182    claude_tools: Vec<String>,
183    #[arg(long = "claude-agent")]
184    claude_agent: Option<String>,
185    #[arg(long = "claude-agents-json")]
186    claude_agents_json: Option<String>,
187    #[arg(long = "claude-plugin-dir", action = ArgAction::Append)]
188    claude_plugin_dir: Vec<String>,
189    #[arg(long = "claude-strict-mcp-config", action = ArgAction::SetTrue)]
190    claude_strict_mcp_config: bool,
191    #[arg(long = "claude-disable-slash-commands", action = ArgAction::SetTrue)]
192    claude_disable_slash_commands: bool,
193    #[arg(long = "gemini-settings")]
194    gemini_settings: Option<String>,
195    #[arg(long = "gemini-tools-profile", value_delimiter = ',', action = ArgAction::Append)]
196    gemini_tools_profile: Vec<String>,
197    #[arg(long = "gemini-allowed-mcp-servers", value_delimiter = ',', action = ArgAction::Append)]
198    gemini_allowed_mcp_servers: Vec<String>,
199    #[arg(long = "gemini-policy", value_delimiter = ',', action = ArgAction::Append)]
200    gemini_policy: Vec<String>,
201    #[arg(long = "gemini-admin-policy", value_delimiter = ',', action = ArgAction::Append)]
202    gemini_admin_policy: Vec<String>,
203    #[arg(long = "capabilities-status", action = ArgAction::SetTrue)]
204    capabilities_status: bool,
205
206    #[arg(long = "auth-login", action = ArgAction::SetTrue)]
207    auth_login: bool,
208    #[arg(long = "auth-status", action = ArgAction::SetTrue)]
209    auth_status: bool,
210    #[arg(long = "auth-login-providers", value_delimiter = ',')]
211    auth_login_providers: Vec<String>,
212    #[arg(long = "auth-device-code", action = ArgAction::SetTrue)]
213    auth_device_code: bool,
214    #[arg(long = "no-auth-open-browser", action = ArgAction::SetTrue)]
215    no_auth_open_browser: bool,
216    #[arg(long = "auth-open-browser", hide = true, action = ArgAction::SetTrue)]
217    auth_open_browser: bool,
218    #[arg(long = "auth-timeout", default_value_t = 300)]
219    auth_timeout: u64,
220    #[arg(long = "claude-login-mode", default_value = "claudeai")]
221    claude_login_mode: String,
222    #[arg(long = "claude-login-email")]
223    claude_login_email: Option<String>,
224
225    #[arg(long = "file", alias = "tag-file", action = ArgAction::Append)]
226    files: Vec<PathBuf>,
227    #[arg(long = "cmd", alias = "prompt-command", action = ArgAction::Append)]
228    commands: Vec<String>,
229
230    #[arg(long, action = ArgAction::SetTrue)]
231    handoff: bool,
232    #[arg(long)]
233    lead: Option<String>,
234    #[arg(long)]
235    planner: Option<String>,
236    #[arg(long, default_value_t = DEFAULT_ITERATIONS)]
237    iterations: usize,
238    #[arg(long = "team-work", alias = "teamwork", alias = "sub-agents", default_value_t = DEFAULT_TEAM_SIZE)]
239    team_work: usize,
240    #[arg(long = "codex-sub-agents")]
241    codex_sub_agents: Option<usize>,
242    #[arg(long = "claude-sub-agents")]
243    claude_sub_agents: Option<usize>,
244    #[arg(long = "gemini-sub-agents")]
245    gemini_sub_agents: Option<usize>,
246
247    #[arg(long = "deliver-linear", alias = "linear", action = ArgAction::SetTrue)]
248    deliver_linear: bool,
249    #[arg(long = "linear-setup", action = ArgAction::SetTrue)]
250    linear_setup: bool,
251    #[arg(long = "linear-status", action = ArgAction::SetTrue)]
252    linear_status: bool,
253    #[arg(long = "linear-watch", action = ArgAction::SetTrue)]
254    linear_watch: bool,
255    #[arg(long = "linear-until-complete", action = ArgAction::SetTrue)]
256    linear_until_complete: bool,
257    #[arg(long = "linear-issue", value_delimiter = ',', action = ArgAction::Append)]
258    linear_issue: Vec<String>,
259    #[arg(long = "linear-query")]
260    linear_query: Option<String>,
261    #[arg(long = "linear-project", value_delimiter = ',', action = ArgAction::Append)]
262    linear_project: Vec<String>,
263    #[arg(long = "linear-epic", value_delimiter = ',', action = ArgAction::Append)]
264    linear_epic: Vec<String>,
265    #[arg(long = "linear-team")]
266    linear_team: Option<String>,
267    #[arg(long = "linear-state")]
268    linear_state: Option<String>,
269    #[arg(long = "linear-assignee")]
270    linear_assignee: Option<String>,
271    #[arg(long = "linear-limit", default_value_t = 3)]
272    linear_limit: usize,
273    #[arg(long = "linear-endpoint")]
274    linear_endpoint: Option<String>,
275    #[arg(long = "linear-auth", default_value = "api-key")]
276    linear_auth: String,
277    #[arg(long = "linear-api-key-env", default_value = "LINEAR_API_KEY")]
278    linear_api_key_env: String,
279    #[arg(long = "linear-oauth-token-env", default_value = "LINEAR_OAUTH_TOKEN")]
280    linear_oauth_token_env: String,
281    #[arg(long = "linear-completion-gate", default_value = "delivered")]
282    linear_completion_gate: String,
283    #[arg(long = "linear-review-state")]
284    linear_review_state: Option<String>,
285    #[arg(long = "linear-ci-timeout", default_value_t = 900)]
286    linear_ci_timeout: u64,
287    #[arg(long = "linear-ci-poll-interval", default_value_t = 30)]
288    linear_ci_poll_interval: u64,
289    #[arg(long = "linear-poll-interval", default_value_t = 60)]
290    linear_poll_interval: u64,
291    #[arg(long = "linear-max-polls")]
292    linear_max_polls: Option<usize>,
293    #[arg(long = "linear-max-concurrency", default_value_t = 1)]
294    linear_max_concurrency: usize,
295    #[arg(long = "linear-max-attempts", default_value_t = 3)]
296    linear_max_attempts: usize,
297    #[arg(long = "linear-retry-base", default_value_t = 60)]
298    linear_retry_base: u64,
299    #[arg(long = "linear-workspace-strategy", default_value = "worktree")]
300    linear_workspace_strategy: String,
301    #[arg(long = "linear-state-file")]
302    linear_state_file: Option<PathBuf>,
303    #[arg(long = "linear-workspace-root")]
304    linear_workspace_root: Option<PathBuf>,
305    #[arg(long = "linear-observability-dir")]
306    linear_observability_dir: Option<PathBuf>,
307    #[arg(long = "linear-workflow-file")]
308    linear_workflow_file: Option<PathBuf>,
309    #[arg(long = "linear-attach-media", value_delimiter = ',', action = ArgAction::Append)]
310    linear_attach_media: Vec<String>,
311    #[arg(long = "linear-attachment-title")]
312    linear_attachment_title: Option<String>,
313    #[arg(long = "no-linear-comments", action = ArgAction::SetTrue)]
314    no_linear_comments: bool,
315    #[arg(long = "linear-update-review-state", action = ArgAction::SetTrue)]
316    linear_update_review_state: bool,
317    #[arg(long = "delivery-phases", value_delimiter = ',')]
318    delivery_phases: Vec<String>,
319
320    #[arg(long, default_value_t = DEFAULT_TIMEOUT_MS / 1000)]
321    timeout: u64,
322    #[arg(long = "max-member-chars", default_value_t = DEFAULT_MAX_MEMBER_CHARS)]
323    max_member_chars: usize,
324    #[arg(long, default_value = ".")]
325    cwd: PathBuf,
326    #[arg(long, default_value = "auto")]
327    color: String,
328    #[arg(long = "no-color", action = ArgAction::SetTrue)]
329    no_color: bool,
330
331    #[arg(value_name = "QUERY")]
332    prompt: Vec<String>,
333}
334
335#[derive(Copy, Clone, Debug, ValueEnum)]
336enum Effort {
337    Low,
338    Medium,
339    High,
340}
341
342impl Effort {
343    fn as_str(self) -> &'static str {
344        match self {
345            Effort::Low => "low",
346            Effort::Medium => "medium",
347            Effort::High => "high",
348        }
349    }
350}
351
352#[derive(Debug, Clone)]
353struct ResolvedArgs {
354    raw: CliArgs,
355    members: Vec<String>,
356    prompt: String,
357    cwd: PathBuf,
358}
359
360#[derive(Debug, Clone, Serialize)]
361pub struct AmonHenResult {
362    query: String,
363    cwd: String,
364    members_requested: Vec<String>,
365    summarizer_requested: String,
366    workflow: Workflow,
367    prompt_commands: Vec<CommandTelemetry>,
368    iterations: Vec<IterationRecord>,
369    members: Vec<EngineResult>,
370    summary: EngineResult,
371}
372
373#[derive(Debug, Clone, Serialize)]
374struct Workflow {
375    handoff: bool,
376    lead: Option<String>,
377    planner: Option<String>,
378    iterations: usize,
379    team_work: usize,
380    teams: HashMap<String, usize>,
381}
382
383#[derive(Debug, Clone, Serialize)]
384struct IterationRecord {
385    iteration: usize,
386    total_iterations: usize,
387    status: String,
388    duration_ms: u128,
389    members: Vec<EngineResult>,
390    sub_agents: Vec<EngineResult>,
391    handoff_context: Option<String>,
392    summary_context: Option<String>,
393    token_usage: TokenUsage,
394    tool_calls: Vec<ToolUsage>,
395}
396
397#[derive(Debug, Clone, Serialize)]
398pub struct EngineResult {
399    name: String,
400    bin: Option<String>,
401    status: String,
402    duration_ms: u128,
403    detail: String,
404    exit_code: Option<i32>,
405    stdout: String,
406    stderr: String,
407    output: String,
408    command: String,
409    token_usage: TokenUsage,
410    tool_calls: Vec<ToolUsage>,
411    sub_agents: Vec<EngineResult>,
412    role: String,
413    iteration: usize,
414    total_iterations: usize,
415    team_size: usize,
416}
417
418#[derive(Debug, Clone, Serialize)]
419struct TokenUsage {
420    input: usize,
421    output: usize,
422    total: usize,
423    estimated: bool,
424    source: String,
425}
426
427#[derive(Debug, Clone, Serialize)]
428struct ToolUsage {
429    name: String,
430    kind: String,
431    status: String,
432    detail: String,
433}
434
435#[derive(Debug, Clone, Serialize)]
436struct CommandTelemetry {
437    command: String,
438    status: String,
439    detail: String,
440    exit_code: Option<i32>,
441    duration_ms: u128,
442    stdout_chars: usize,
443    stderr_chars: usize,
444    timed_out: bool,
445}
446
447#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)]
448#[serde(rename_all = "snake_case")]
449enum ProgressStage {
450    Context,
451    Start,
452    Spawn,
453    Heartbeat,
454    Done,
455}
456
457#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)]
458#[serde(rename_all = "snake_case")]
459enum RuntimeEventKind {
460    Context,
461    Iteration,
462    Provider,
463    TokenUsage,
464    ToolUsage,
465    Result,
466}
467
468#[derive(Debug, Clone, Serialize)]
469struct ProgressEvent {
470    kind: RuntimeEventKind,
471    provider: Option<String>,
472    role: Option<String>,
473    stage: ProgressStage,
474    status: Option<String>,
475    iteration: Option<usize>,
476    total_iterations: Option<usize>,
477    is_sub_agent: bool,
478    duration_ms: Option<u128>,
479    token_usage: Option<TokenUsage>,
480    tool_calls: Vec<ToolUsage>,
481    message: String,
482}
483
484type ProgressSink = Arc<dyn Fn(ProgressEvent) + Send + Sync + 'static>;
485type RuntimeEventSink = Arc<dyn Fn(RuntimeEvent) + Send + Sync + 'static>;
486
487#[derive(Debug, Clone, Serialize)]
488struct RuntimeEvent {
489    sequence: u64,
490    elapsed_ms: u128,
491    #[serde(flatten)]
492    progress: ProgressEvent,
493    #[serde(skip_serializing_if = "Option::is_none")]
494    result: Option<Box<AmonHenResult>>,
495    #[serde(skip_serializing_if = "Option::is_none", rename = "linearResult")]
496    linear_result: Option<Box<linear_delivery::LinearDeliveryResult>>,
497}
498
499#[derive(Clone)]
500struct RuntimeEventBus {
501    started: Instant,
502    sequence: Arc<AtomicU64>,
503    emit_lock: Arc<Mutex<()>>,
504    sinks: Arc<Mutex<Vec<RuntimeEventSink>>>,
505}
506
507#[derive(Debug)]
508struct PromptContext {
509    prompt: String,
510    commands: Vec<CommandTelemetry>,
511}
512
513#[derive(Debug)]
514struct CommandResult {
515    command: String,
516    args: Vec<String>,
517    code: Option<i32>,
518    stdout: String,
519    stderr: String,
520    timed_out: bool,
521    cancelled: bool,
522    error: Option<String>,
523    timeout_ms: u64,
524    duration_ms: u128,
525}
526
527#[derive(Clone)]
528struct CommandProgress {
529    label: String,
530    sink: Option<ProgressSink>,
531}
532
533struct CommandRequest<'a> {
534    command: &'a str,
535    args: &'a [String],
536    cwd: &'a Path,
537    stdin_text: Option<&'a str>,
538    timeout_ms: u64,
539    envs: HashMap<String, String>,
540    progress: Option<CommandProgress>,
541    cancel: Option<Arc<AtomicBool>>,
542}
543
544impl<'a> CommandRequest<'a> {
545    fn new(command: &'a str, args: &'a [String], cwd: &'a Path, timeout_ms: u64) -> Self {
546        Self {
547            command,
548            args,
549            cwd,
550            stdin_text: None,
551            timeout_ms,
552            envs: HashMap::new(),
553            progress: None,
554            cancel: None,
555        }
556    }
557
558    fn stdin_text(mut self, stdin_text: Option<&'a str>) -> Self {
559        self.stdin_text = stdin_text;
560        self
561    }
562
563    fn envs(mut self, envs: HashMap<String, String>) -> Self {
564        self.envs = envs;
565        self
566    }
567
568    fn progress(mut self, progress: Option<CommandProgress>) -> Self {
569        self.progress = progress;
570        self
571    }
572
573    fn cancel(mut self, cancel: Option<Arc<AtomicBool>>) -> Self {
574        self.cancel = cancel;
575        self
576    }
577}
578
579#[derive(Clone)]
580struct EngineRunOptions {
581    prompt: String,
582    cwd: PathBuf,
583    timeout_ms: u64,
584    effort: Option<String>,
585    model: Option<String>,
586    permission: Option<String>,
587    auth: String,
588    capability: ProviderCapability,
589    role: String,
590    iteration: usize,
591    total_iterations: usize,
592    team_size: usize,
593    is_sub_agent: bool,
594    live: bool,
595    progress: Option<ProgressSink>,
596    cancel: Option<Arc<AtomicBool>>,
597}
598
599struct EngineOptionsInput<'a> {
600    member: &'a str,
601    prompt: String,
602    role: &'a str,
603    iteration: usize,
604    workflow: &'a Workflow,
605    progress: Option<ProgressSink>,
606    cancel: Option<Arc<AtomicBool>>,
607}
608
609struct MemberPromptInput<'a> {
610    query: &'a str,
611    role: &'a str,
612    workflow: &'a Workflow,
613    iteration: usize,
614    team_size: usize,
615    previous_iteration: &'a [EngineResult],
616    handoff_results: &'a [EngineResult],
617    plan_output: &'a str,
618}
619
620#[derive(Debug, Clone)]
621struct ProviderCapability {
622    mode: String,
623    config: Vec<String>,
624    mcp_profile: Option<String>,
625    mcp_config: Vec<String>,
626    allowed_tools: Vec<String>,
627    disallowed_tools: Vec<String>,
628    tools: Vec<String>,
629    agent: Option<String>,
630    agents_json: Option<String>,
631    plugin_dirs: Vec<String>,
632    strict_mcp_config: bool,
633    disable_slash_commands: bool,
634    settings: Option<String>,
635    tools_profile: Vec<String>,
636    allowed_mcp_servers: Vec<String>,
637    policy: Vec<String>,
638    admin_policy: Vec<String>,
639}
640
641#[derive(Debug, Clone, Serialize)]
642struct ProviderAuthStatus {
643    provider: String,
644    configured: bool,
645    status: String,
646    source: String,
647    detail: String,
648}
649
650#[derive(Debug, Clone, Serialize)]
651struct ProviderCapabilityStatus {
652    provider: String,
653    mcp: CommandTelemetry,
654    skills: Option<CommandTelemetry>,
655    tools: Option<CommandTelemetry>,
656    detail: String,
657}
658
659struct TempSettings {
660    _dir: tempfile::TempDir,
661    path: PathBuf,
662}
663
664pub fn run_from_env() -> i32 {
665    let args: Vec<OsString> = std::env::args_os().skip(1).collect();
666    run_with_args(args)
667}
668
669pub fn run_with_args<I, T>(args: I) -> i32
670where
671    I: IntoIterator<Item = T>,
672    T: Into<OsString>,
673{
674    let raw_args: Vec<OsString> = args.into_iter().map(Into::into).collect();
675    if raw_args.first().and_then(|value| value.to_str()) == Some("help") {
676        println!("{}", render_cli_help());
677        return 0;
678    }
679
680    let mut parse_args = vec![OsString::from("amon-hen")];
681    parse_args.extend(raw_args);
682    let parsed = match CliArgs::try_parse_from(parse_args) {
683        Ok(parsed) => parsed,
684        Err(error) => {
685            if error.kind() == ErrorKind::DisplayHelp {
686                println!("{}", render_cli_help());
687                return 0;
688            }
689            if error.kind() == ErrorKind::DisplayVersion {
690                println!("{VERSION}");
691                return 0;
692            }
693            let exit_code = parse_error_exit_code(error.kind());
694            let _ = error.print();
695            return exit_code;
696        }
697    };
698
699    if parsed.version {
700        println!("{VERSION}");
701        return 0;
702    }
703
704    let resolved = match resolve_args(parsed) {
705        Ok(resolved) => resolved,
706        Err(error) => {
707            eprintln!("{error}");
708            return 64;
709        }
710    };
711
712    if resolved.raw.studio {
713        return studio::run_studio(&resolved);
714    }
715
716    let event_bus = resolved.raw.json_stream.then(RuntimeEventBus::new);
717    if let Some(bus) = &event_bus {
718        let stdout_lock = Arc::new(Mutex::new(()));
719        bus.add_sink(Arc::new(move |event| {
720            let Ok(line) = serde_json::to_string(&event) else {
721                return;
722            };
723            let _guard = stdout_lock.lock().ok();
724            println!("{line}");
725            let _ = io::stdout().flush();
726        }));
727    }
728    let progress = event_bus.as_ref().map(RuntimeEventBus::progress_sink);
729
730    if resolved.raw.auth_status {
731        println!(
732            "{}",
733            render_auth_statuses(&collect_auth_statuses(&resolved))
734        );
735        if resolved.prompt.trim().is_empty()
736            && !resolved.raw.auth_login
737            && !resolved.raw.deliver_linear
738            && !resolved.raw.capabilities_status
739        {
740            return 0;
741        }
742    }
743
744    if resolved.raw.capabilities_status {
745        println!(
746            "{}",
747            render_provider_capability_statuses(&collect_provider_capability_statuses(&resolved))
748        );
749        if resolved.prompt.trim().is_empty()
750            && !resolved.raw.auth_login
751            && !resolved.raw.deliver_linear
752        {
753            return 0;
754        }
755    }
756
757    if resolved.raw.auth_login {
758        if let Err(error) = run_social_login(&resolved) {
759            eprintln!("{error}");
760            return 1;
761        }
762        if resolved.prompt.trim().is_empty() && !resolved.raw.deliver_linear {
763            return 0;
764        }
765    }
766
767    if resolved.raw.linear_setup || resolved.raw.linear_status {
768        match linear_delivery::get_linear_status(&resolved) {
769            Ok(status) => {
770                println!("{}", linear_delivery::render_linear_status(&status));
771                return 0;
772            }
773            Err(error) => {
774                eprintln!("{error}");
775                return 1;
776            }
777        }
778    }
779
780    if linear_delivery_requested(&resolved.raw) {
781        match linear_delivery::run_linear_delivery_with_progress(&resolved, progress.clone(), None)
782        {
783            Ok(result) => {
784                if let Some(bus) = &event_bus {
785                    bus.emit_linear_result(&result);
786                } else if resolved.raw.json {
787                    let serialized = serde_json::to_string_pretty(&result);
788                    println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
789                } else {
790                    println!(
791                        "{}",
792                        linear_delivery::render_linear_delivery_result(&result)
793                    );
794                }
795                return if result.success { 0 } else { 1 };
796            }
797            Err(error) => {
798                eprintln!("{error}");
799                return 1;
800            }
801        }
802    }
803
804    if resolved.prompt.trim().is_empty() {
805        eprintln!("No query provided.\n\n{}", render_cli_help());
806        return 64;
807    }
808
809    let prompt_context = match build_prompt_context_with_progress(&resolved, progress.clone()) {
810        Ok(context) => context,
811        Err(error) => {
812            eprintln!("{error}");
813            return 1;
814        }
815    };
816
817    if should_show_banner(&resolved.raw) {
818        eprintln!("{}", render_banner());
819    }
820
821    let result = run_amon_hen_with_progress(
822        &resolved,
823        prompt_context.prompt,
824        prompt_context.commands,
825        progress,
826    );
827    if let Some(bus) = &event_bus {
828        bus.emit_result(&result);
829    } else if resolved.raw.json {
830        let serialized = serde_json::to_string_pretty(&result);
831        println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
832    } else {
833        println!("{}", render_human_result(&result, resolved.raw.verbose));
834    }
835
836    if is_success(&result) {
837        0
838    } else {
839        1
840    }
841}
842
843fn parse_error_exit_code(kind: ErrorKind) -> i32 {
844    match kind {
845        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
846        _ => 64,
847    }
848}
849
850fn render_cli_help() -> &'static str {
851    r#"Amon Hen
852Rust-native orchestration for Codex, Claude, Gemini, and Linear delivery.
853
854Usage:
855  amon-hen [OPTIONS] "your task"
856  amon-hen --studio --members codex,claude,gemini "your task"
857  amon-hen --deliver-linear --linear-project "Project" --linear-until-complete
858
859Core run:
860  --members codex,claude,gemini       Providers to consult. Also supports --codex, --claude, --gemini, --all.
861  --summarizer auto|codex|claude|gemini
862                                      Provider used for synthesis. auto tries the configured order.
863  --studio                            Open the interactive native Studio.
864  --plain                             Disable rich terminal output for script-friendly runs.
865  --json, --json-stream               Emit JSON or newline-delimited JSON.
866  -q, --quiet                         Print the synthesis only.
867  -d, --verbose                       Include provider details, telemetry, and tool usage.
868
869Models, effort, and permissions:
870  --codex-model NAME                  Model passed to Codex.
871  --claude-model NAME                 Model passed to Claude.
872  --gemini-model NAME                 Model passed to Gemini.
873  --effort low|medium|high            Shared default effort.
874  --codex-effort low|medium|high|xhigh
875  --claude-effort low|medium|high|xhigh|max
876  --gemini-effort low|medium|high
877  --codex-sandbox read-only|workspace-write|danger-full-access
878  --claude-permission-mode MODE       Claude permission mode such as plan, acceptEdits, bypassPermissions.
879
880Auth and provider capabilities:
881  --auth-login                        Start provider social-login flows.
882  --auth-status                       Show configured auth sources for each provider.
883  --codex-auth auto|api-key|social-login|oauth|keychain
884  --claude-auth auto|api-key|social-login|oauth|keychain
885  --gemini-auth auto|api-key|social-login|oauth|keychain
886  --capabilities-status               Probe provider Skills / MCP / Tools support.
887  --codex-capabilities inherit|override
888  --claude-capabilities inherit|override
889  --gemini-capabilities inherit|override
890  --codex-config PATH                 Extra Codex config file, repeatable.
891  --codex-mcp-profile NAME            Codex MCP profile.
892  --claude-mcp-config PATH            Claude MCP config file, repeatable.
893  --claude-allowed-tools LIST         Allowed Claude tools.
894  --claude-disallowed-tools LIST      Disallowed Claude tools.
895  --claude-tools LIST                 Claude tool list override.
896  --claude-agent NAME                 Claude agent profile.
897  --claude-agents-json JSON           Claude agents JSON override.
898  --claude-plugin-dir PATH            Claude plugin directory, repeatable.
899  --gemini-settings PATH              Gemini settings file.
900  --gemini-tools-profile LIST         Gemini tool profile list.
901  --gemini-allowed-mcp-servers LIST   Gemini MCP allow-list.
902  --gemini-policy LIST                Gemini policy values.
903  --gemini-admin-policy LIST          Gemini admin policy values.
904
905Team workflow:
906  --handoff                           Feed planner/lead context between providers.
907  --planner codex|claude|gemini       Assign the planning role.
908  --lead codex|claude|gemini          Assign the lead reviewer/synthesizer role.
909  --iterations N                      Run multiple provider rounds per prompt.
910  --team-work N                       Spawn N same-provider sub-agents per provider.
911  --codex-sub-agents N                Override Codex sub-agent count.
912  --claude-sub-agents N               Override Claude sub-agent count.
913  --gemini-sub-agents N               Override Gemini sub-agent count.
914  --file PATH                         Tag a local file into the prompt context, repeatable.
915  --cmd COMMAND                       Run a local command and include its telemetry, repeatable.
916
917Linear delivery:
918  --deliver-linear                    Deliver Linear work instead of a one-shot prompt.
919  --linear-watch                      Poll Linear for matching work.
920  --linear-until-complete             Continue until review/delivery gate is met.
921  --linear-issue ID,ID                Target specific Linear issues.
922  --linear-project NAME,NAME          Target Linear projects.
923  --linear-epic NAME,NAME             Target Linear epics.
924  --linear-team KEY                   Restrict to a Linear team.
925  --linear-state NAME                 Restrict by Linear workflow state.
926  --linear-completion-gate delivered|review|ci
927  --linear-max-concurrency N          Isolated workspace concurrency.
928  --linear-max-attempts N             Retry attempts per issue.
929  --linear-workspace-strategy worktree|local
930  --linear-workspace-root PATH        Root for per-issue workspaces.
931  --linear-observability-dir PATH     Logs, reconciliation, and run telemetry.
932  --linear-attach-media PATH,PATH     Attach generated media back to Linear.
933
934Runtime:
935  --cwd PATH                          Working directory for provider CLIs.
936  --timeout SECONDS                   Per-provider timeout.
937  --max-member-chars N                Max provider output included in synthesis.
938  --color auto|always|never           Color mode.
939  --no-color                          Disable color.
940  -v, --version                       Print version.
941  -h, --help                          Show this help.
942
943Examples:
944  amon-hen --studio --members codex,claude,gemini "Inspect this repo"
945  amon-hen --members codex,claude,gemini --planner codex --lead claude --handoff --iterations 2 "Suggest the cleanest next patch"
946  amon-hen --deliver-linear --linear-project "Developer Platform" --linear-until-complete --team-work 2
947  amon-hen --auth-login --auth-login-providers codex,claude,gemini"#
948}
949
950fn resolve_args(raw: CliArgs) -> Result<ResolvedArgs, String> {
951    let members = resolve_members(&raw)?;
952    validate_engine_name(&raw.summarizer, true, "--summarizer")?;
953    if let Some(lead) = &raw.lead {
954        validate_engine_name(lead, false, "--lead")?;
955        if !members.contains(lead) {
956            return Err(format!(
957                "--lead must be one of the enabled members: {}",
958                members.join(", ")
959            ));
960        }
961    }
962    if let Some(planner) = &raw.planner {
963        validate_engine_name(planner, false, "--planner")?;
964        if !members.contains(planner) {
965            return Err(format!(
966                "--planner must be one of the enabled members: {}",
967                members.join(", ")
968            ));
969        }
970    }
971    validate_provider_effort("codex", raw.codex_effort.as_deref())?;
972    validate_provider_effort("claude", raw.claude_effort.as_deref())?;
973    validate_provider_effort("gemini", raw.gemini_effort.as_deref())?;
974    validate_choice(
975        "--claude-login-mode",
976        &raw.claude_login_mode,
977        &["claudeai", "console", "sso"],
978    )?;
979    validate_choice(
980        "--codex-capabilities",
981        &raw.codex_capabilities,
982        &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
983    )?;
984    validate_choice(
985        "--claude-capabilities",
986        &raw.claude_capabilities,
987        &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
988    )?;
989    validate_choice(
990        "--gemini-capabilities",
991        &raw.gemini_capabilities,
992        &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
993    )?;
994    validate_choice("--linear-auth", &raw.linear_auth, &["api-key", "oauth"])?;
995    validate_choice(
996        "--linear-completion-gate",
997        &raw.linear_completion_gate,
998        &["delivered", "human-review", "ci-success", "review-or-ci"],
999    )?;
1000    validate_choice(
1001        "--linear-workspace-strategy",
1002        &raw.linear_workspace_strategy,
1003        &["worktree", "copy", "none"],
1004    )?;
1005    if raw.linear_limit == 0 {
1006        return Err("--linear-limit requires a positive integer.".to_string());
1007    }
1008    if raw.linear_max_concurrency == 0 {
1009        return Err("--linear-max-concurrency requires a positive integer.".to_string());
1010    }
1011    if raw.linear_max_polls == Some(0) {
1012        return Err("--linear-max-polls requires a positive integer.".to_string());
1013    }
1014    if raw
1015        .linear_endpoint
1016        .as_ref()
1017        .is_some_and(|value| value.trim().is_empty())
1018    {
1019        return Err("--linear-endpoint requires a non-empty value.".to_string());
1020    }
1021    for phase in raw
1022        .delivery_phases
1023        .iter()
1024        .map(|phase| phase.trim())
1025        .filter(|phase| !phase.is_empty())
1026    {
1027        validate_choice(
1028            "--delivery-phases",
1029            phase,
1030            &["plan", "implement", "verify", "ship"],
1031        )?;
1032    }
1033
1034    let mut prompt = raw.prompt.join(" ");
1035    if prompt.trim().is_empty() && !io::stdin().is_terminal() {
1036        let mut stdin = String::new();
1037        io::stdin()
1038            .read_to_string(&mut stdin)
1039            .map_err(|error| format!("Failed to read stdin: {error}"))?;
1040        prompt = stdin;
1041    }
1042    let cwd = raw.cwd.canonicalize().unwrap_or_else(|_| raw.cwd.clone());
1043
1044    Ok(ResolvedArgs {
1045        raw,
1046        members,
1047        prompt,
1048        cwd,
1049    })
1050}
1051
1052fn resolve_members(raw: &CliArgs) -> Result<Vec<String>, String> {
1053    let mut members = if raw.members.is_empty() {
1054        ENGINES
1055            .iter()
1056            .map(|name| (*name).to_string())
1057            .collect::<Vec<_>>()
1058    } else {
1059        raw.members
1060            .iter()
1061            .map(|name| name.trim().to_string())
1062            .filter(|name| !name.is_empty())
1063            .collect::<Vec<_>>()
1064    };
1065
1066    if raw.all {
1067        members = ENGINES.iter().map(|name| (*name).to_string()).collect();
1068    }
1069    if raw.no_codex {
1070        members.retain(|name| name != "codex");
1071    }
1072    if raw.no_claude {
1073        members.retain(|name| name != "claude");
1074    }
1075    if raw.no_gemini {
1076        members.retain(|name| name != "gemini");
1077    }
1078    for enabled in [
1079        (raw.codex, "codex"),
1080        (raw.claude, "claude"),
1081        (raw.gemini, "gemini"),
1082    ] {
1083        if enabled.0 && !members.iter().any(|name| name == enabled.1) {
1084            members.push(enabled.1.to_string());
1085        }
1086    }
1087
1088    let mut seen = HashSet::new();
1089    members.retain(|member| seen.insert(member.clone()));
1090    for member in &members {
1091        validate_engine_name(member, false, "--members")?;
1092    }
1093    if members.is_empty() {
1094        return Err("At least one member must be enabled.".to_string());
1095    }
1096    Ok(members)
1097}
1098
1099fn validate_engine_name(name: &str, allow_auto: bool, flag: &str) -> Result<(), String> {
1100    if allow_auto && name == "auto" {
1101        return Ok(());
1102    }
1103    if Engine::parse(name).is_some() {
1104        Ok(())
1105    } else {
1106        Err(format!(
1107            "{flag} must be one of: {}",
1108            if allow_auto {
1109                "auto, codex, claude, gemini"
1110            } else {
1111                "codex, claude, gemini"
1112            }
1113        ))
1114    }
1115}
1116
1117fn validate_provider_effort(provider: &str, value: Option<&str>) -> Result<(), String> {
1118    let Some(value) = value else {
1119        return Ok(());
1120    };
1121    let engine = Engine::parse(provider).expect("provider effort validation uses known engines");
1122    if engine.allowed_efforts().contains(&value) {
1123        Ok(())
1124    } else {
1125        Err(format!("Unsupported --{provider}-effort value: {value}"))
1126    }
1127}
1128
1129fn validate_choice(flag: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
1130    if allowed.contains(&value) {
1131        Ok(())
1132    } else {
1133        Err(format!("{flag} must be one of: {}", allowed.join(", ")))
1134    }
1135}
1136
1137fn collect_auth_statuses(resolved: &ResolvedArgs) -> Vec<ProviderAuthStatus> {
1138    collect_auth_statuses_with_cancel(resolved, None)
1139}
1140
1141fn collect_auth_statuses_with_cancel(
1142    resolved: &ResolvedArgs,
1143    cancel: Option<Arc<AtomicBool>>,
1144) -> Vec<ProviderAuthStatus> {
1145    resolved
1146        .members
1147        .iter()
1148        .map(|provider| provider_auth_status_with_cancel(provider, resolved, cancel.clone()))
1149        .collect()
1150}
1151
1152fn provider_auth_status(provider: &str, resolved: &ResolvedArgs) -> ProviderAuthStatus {
1153    provider_auth_status_with_cancel(provider, resolved, None)
1154}
1155
1156fn provider_auth_status_with_cancel(
1157    provider: &str,
1158    resolved: &ResolvedArgs,
1159    cancel: Option<Arc<AtomicBool>>,
1160) -> ProviderAuthStatus {
1161    let auth = provider_auth(resolved, provider);
1162    if cancel
1163        .as_ref()
1164        .is_some_and(|cancel| cancel.load(Ordering::Relaxed))
1165    {
1166        return ProviderAuthStatus {
1167            provider: provider.to_string(),
1168            configured: false,
1169            status: "cancelled".to_string(),
1170            source: auth,
1171            detail: "Auth status refresh cancelled.".to_string(),
1172        };
1173    }
1174    match provider {
1175        "codex" => {
1176            let bin = resolve_binary("codex");
1177            let args = vec!["login".to_string(), "status".to_string()];
1178            status_from_command(provider, &auth, &bin, &args, &resolved.cwd, cancel)
1179        }
1180        "claude" => {
1181            let bin = resolve_binary("claude");
1182            let args = vec![
1183                "auth".to_string(),
1184                "status".to_string(),
1185                "--text".to_string(),
1186            ];
1187            status_from_command(provider, &auth, &bin, &args, &resolved.cwd, cancel)
1188        }
1189        "gemini" => {
1190            let has_api_key = std::env::var("GEMINI_API_KEY")
1191                .ok()
1192                .is_some_and(|value| !value.trim().is_empty());
1193            let home = std::env::var("HOME").unwrap_or_default();
1194            let has_oauth_file = !home.is_empty()
1195                && [
1196                    ".gemini/oauth_creds.json",
1197                    ".gemini/oauth_tokens.json",
1198                    ".gemini/settings.json",
1199                ]
1200                .iter()
1201                .any(|path| Path::new(&home).join(path).exists());
1202            let configured = has_api_key || has_oauth_file;
1203            ProviderAuthStatus {
1204                provider: provider.to_string(),
1205                configured,
1206                status: if configured { "configured" } else { "unknown" }.to_string(),
1207                source: auth,
1208                detail: if has_api_key {
1209                    "GEMINI_API_KEY is present.".to_string()
1210                } else if has_oauth_file {
1211                    "Gemini local auth/config files are present.".to_string()
1212                } else {
1213                    "Gemini CLI does not currently expose a stable headless auth status command; use Social login from Studio or run gemini interactively.".to_string()
1214                },
1215            }
1216        }
1217        _ => ProviderAuthStatus {
1218            provider: provider.to_string(),
1219            configured: false,
1220            status: "unknown".to_string(),
1221            source: auth,
1222            detail: "Unknown provider.".to_string(),
1223        },
1224    }
1225}
1226
1227fn status_from_command(
1228    provider: &str,
1229    auth: &str,
1230    bin: &str,
1231    args: &[String],
1232    cwd: &Path,
1233    cancel: Option<Arc<AtomicBool>>,
1234) -> ProviderAuthStatus {
1235    let result = run_command(CommandRequest::new(bin, args, cwd, 15_000).cancel(cancel));
1236    let configured = result.code == Some(0);
1237    ProviderAuthStatus {
1238        provider: provider.to_string(),
1239        configured,
1240        status: if configured {
1241            "configured"
1242        } else if result.cancelled {
1243            "cancelled"
1244        } else if result.error.is_some() {
1245            "missing-cli"
1246        } else {
1247            "not-configured"
1248        }
1249        .to_string(),
1250        source: auth.to_string(),
1251        detail: sanitize_status_detail(&compact_failure(&result)),
1252    }
1253}
1254
1255fn sanitize_status_detail(detail: &str) -> String {
1256    let detail = detail.trim();
1257    if detail.is_empty() {
1258        return "No status detail returned.".to_string();
1259    }
1260    let detail = detail
1261        .lines()
1262        .take(6)
1263        .map(str::trim)
1264        .filter(|line| !line.is_empty())
1265        .map(redact_possible_secret)
1266        .collect::<Vec<_>>()
1267        .join(" ");
1268    redact_local_paths(&detail)
1269}
1270
1271fn redact_possible_secret(value: &str) -> String {
1272    value
1273        .split_whitespace()
1274        .map(|token| {
1275            if looks_sensitive_token(token) {
1276                "[redacted]"
1277            } else if looks_like_email(token) {
1278                "[email]"
1279            } else {
1280                token
1281            }
1282        })
1283        .collect::<Vec<_>>()
1284        .join(" ")
1285}
1286
1287fn looks_sensitive_token(token: &str) -> bool {
1288    let trimmed =
1289        token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_');
1290    trimmed.len() >= 28
1291        && (trimmed.starts_with("sk-")
1292            || trimmed.starts_with("sk_")
1293            || trimmed.starts_with("gho_")
1294            || trimmed.starts_with("ghp_")
1295            || trimmed.starts_with("ghs_")
1296            || trimmed.starts_with("ghu_")
1297            || trimmed.starts_with("AIza")
1298            || trimmed.starts_with("AQ."))
1299}
1300
1301fn looks_like_email(token: &str) -> bool {
1302    let trimmed = token.trim_matches(|ch: char| {
1303        !ch.is_ascii_alphanumeric() && !matches!(ch, '@' | '.' | '_' | '-' | '+')
1304    });
1305    trimmed.contains('@') && trimmed.rsplit_once('.').is_some()
1306}
1307
1308fn redact_local_paths(value: &str) -> String {
1309    let mut redacted = value.to_string();
1310    if let Ok(home) = std::env::var("HOME") {
1311        if !home.trim().is_empty() {
1312            redacted = redacted.replace(&home, "~");
1313        }
1314    }
1315    redacted
1316}
1317
1318fn render_auth_statuses(statuses: &[ProviderAuthStatus]) -> String {
1319    let mut lines = vec!["Provider auth status".to_string()];
1320    for status in statuses {
1321        lines.push(format!(
1322            "- {}: {} via {} ({})",
1323            status.provider, status.status, status.source, status.detail
1324        ));
1325    }
1326    lines.join("\n")
1327}
1328
1329fn collect_provider_capability_statuses(resolved: &ResolvedArgs) -> Vec<ProviderCapabilityStatus> {
1330    collect_provider_capability_statuses_with_cancel(resolved, None)
1331}
1332
1333fn collect_provider_capability_statuses_with_cancel(
1334    resolved: &ResolvedArgs,
1335    cancel: Option<Arc<AtomicBool>>,
1336) -> Vec<ProviderCapabilityStatus> {
1337    resolved
1338        .members
1339        .iter()
1340        .map(|provider| provider_capability_status(provider, resolved, cancel.clone()))
1341        .collect()
1342}
1343
1344fn provider_capability_status(
1345    provider: &str,
1346    resolved: &ResolvedArgs,
1347    cancel: Option<Arc<AtomicBool>>,
1348) -> ProviderCapabilityStatus {
1349    match provider {
1350        "codex" => {
1351            let bin = resolve_binary("codex");
1352            let mcp = run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone());
1353            ProviderCapabilityStatus {
1354                provider: provider.to_string(),
1355                mcp,
1356                skills: None,
1357                tools: Some(run_capability_probe(
1358                    &bin,
1359                    &["plugin", "marketplace", "--help"],
1360                    &resolved.cwd,
1361                    cancel,
1362                )),
1363                detail:
1364                    "Codex inherits ~/.codex config unless --codex-capabilities override is set."
1365                        .to_string(),
1366            }
1367        }
1368        "claude" => {
1369            let bin = resolve_binary("claude");
1370            ProviderCapabilityStatus {
1371                provider: provider.to_string(),
1372                mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
1373                skills: Some(run_capability_probe(
1374                    &bin,
1375                    &["plugin", "list"],
1376                    &resolved.cwd,
1377                    cancel.clone(),
1378                )),
1379                tools: Some(run_capability_probe(
1380                    &bin,
1381                    &["agents"],
1382                    &resolved.cwd,
1383                    cancel,
1384                )),
1385                detail: "Claude override can manage MCP config, tools, agents, plugin dirs, and slash-command skills.".to_string(),
1386            }
1387        }
1388        "gemini" => {
1389            let bin = resolve_binary("gemini");
1390            ProviderCapabilityStatus {
1391                provider: provider.to_string(),
1392                mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
1393                skills: Some(run_capability_probe(
1394                    &bin,
1395                    &["skills", "list"],
1396                    &resolved.cwd,
1397                    cancel.clone(),
1398                )),
1399                tools: Some(run_capability_probe(
1400                    &bin,
1401                    &["extensions", "list"],
1402                    &resolved.cwd,
1403                    cancel,
1404                )),
1405                detail: "Gemini override can manage settings, extensions, MCP server allowlists, and policy files.".to_string(),
1406            }
1407        }
1408        _ => ProviderCapabilityStatus {
1409            provider: provider.to_string(),
1410            mcp: CommandTelemetry {
1411                command: provider.to_string(),
1412                status: "unknown".to_string(),
1413                detail: "Unknown provider.".to_string(),
1414                exit_code: None,
1415                duration_ms: 0,
1416                stdout_chars: 0,
1417                stderr_chars: 0,
1418                timed_out: false,
1419            },
1420            skills: None,
1421            tools: None,
1422            detail: "Unknown provider.".to_string(),
1423        },
1424    }
1425}
1426
1427fn run_capability_probe(
1428    bin: &str,
1429    args: &[&str],
1430    cwd: &Path,
1431    cancel: Option<Arc<AtomicBool>>,
1432) -> CommandTelemetry {
1433    let args = args
1434        .iter()
1435        .map(|arg| (*arg).to_string())
1436        .collect::<Vec<_>>();
1437    command_telemetry(&run_command(
1438        CommandRequest::new(bin, &args, cwd, 20_000).cancel(cancel),
1439    ))
1440}
1441
1442fn emit_runtime_event(
1443    progress: &Option<ProgressSink>,
1444    fallback_to_stderr: bool,
1445    event: ProgressEvent,
1446) {
1447    if let Some(progress) = progress {
1448        progress(event);
1449    } else if fallback_to_stderr {
1450        eprintln!("{}", event.message);
1451    }
1452}
1453
1454impl RuntimeEventBus {
1455    fn new() -> Self {
1456        Self {
1457            started: Instant::now(),
1458            sequence: Arc::new(AtomicU64::new(1)),
1459            emit_lock: Arc::new(Mutex::new(())),
1460            sinks: Arc::new(Mutex::new(Vec::new())),
1461        }
1462    }
1463
1464    fn add_sink(&self, sink: RuntimeEventSink) {
1465        if let Ok(mut sinks) = self.sinks.lock() {
1466            sinks.push(sink);
1467        }
1468    }
1469
1470    fn progress_sink(&self) -> ProgressSink {
1471        let bus = self.clone();
1472        Arc::new(move |event| bus.emit(event, None, None))
1473    }
1474
1475    fn emit_result(&self, result: &AmonHenResult) {
1476        let status = if is_success(result) { "ok" } else { "error" };
1477        self.emit(
1478            ProgressEvent {
1479                kind: RuntimeEventKind::Result,
1480                provider: Some(result.summary.name.clone()),
1481                role: Some(result.summary.role.clone()),
1482                stage: ProgressStage::Done,
1483                status: Some(status.to_string()),
1484                iteration: Some(result.workflow.iterations),
1485                total_iterations: Some(result.workflow.iterations),
1486                is_sub_agent: false,
1487                duration_ms: Some(result.summary.duration_ms),
1488                token_usage: Some(result.summary.token_usage.clone()),
1489                tool_calls: result.summary.tool_calls.clone(),
1490                message: format!("[amon-hen] result {status}"),
1491            },
1492            Some(Box::new(result.clone())),
1493            None,
1494        );
1495    }
1496
1497    fn emit_linear_result(&self, result: &linear_delivery::LinearDeliveryResult) {
1498        let status = if result.success { "ok" } else { "error" };
1499        self.emit(
1500            progress_event_with_context(
1501                Some("linear"),
1502                Some("delivery"),
1503                ProgressStage::Done,
1504                Some(status),
1505                None,
1506                None,
1507                false,
1508                Some(result.duration_ms),
1509                None,
1510                vec![],
1511                format!("[amon-hen] linear delivery {status}"),
1512            ),
1513            None,
1514            Some(Box::new(result.clone())),
1515        );
1516    }
1517
1518    fn emit(
1519        &self,
1520        progress: ProgressEvent,
1521        result: Option<Box<AmonHenResult>>,
1522        linear_result: Option<Box<linear_delivery::LinearDeliveryResult>>,
1523    ) {
1524        let _emit_guard = self.emit_lock.lock().ok();
1525        let event = RuntimeEvent {
1526            sequence: self.sequence.fetch_add(1, Ordering::SeqCst),
1527            elapsed_ms: self.started.elapsed().as_millis(),
1528            progress,
1529            result,
1530            linear_result,
1531        };
1532        let sinks = self
1533            .sinks
1534            .lock()
1535            .map(|sinks| sinks.clone())
1536            .unwrap_or_default();
1537        for sink in sinks {
1538            sink(event.clone());
1539        }
1540    }
1541}
1542
1543fn progress_event(
1544    provider: Option<&str>,
1545    role: Option<&str>,
1546    stage: ProgressStage,
1547    status: Option<&str>,
1548    message: impl Into<String>,
1549) -> ProgressEvent {
1550    progress_event_with_context(
1551        provider,
1552        role,
1553        stage,
1554        status,
1555        None,
1556        None,
1557        false,
1558        None,
1559        None,
1560        vec![],
1561        message,
1562    )
1563}
1564
1565#[allow(clippy::too_many_arguments)]
1566fn progress_event_with_context(
1567    provider: Option<&str>,
1568    role: Option<&str>,
1569    stage: ProgressStage,
1570    status: Option<&str>,
1571    iteration: Option<usize>,
1572    total_iterations: Option<usize>,
1573    is_sub_agent: bool,
1574    duration_ms: Option<u128>,
1575    token_usage: Option<TokenUsage>,
1576    tool_calls: Vec<ToolUsage>,
1577    message: impl Into<String>,
1578) -> ProgressEvent {
1579    ProgressEvent {
1580        kind: runtime_event_kind(provider, stage, token_usage.as_ref(), &tool_calls),
1581        provider: provider.map(ToString::to_string),
1582        role: role.map(ToString::to_string),
1583        stage,
1584        status: status.map(ToString::to_string),
1585        iteration,
1586        total_iterations,
1587        is_sub_agent,
1588        duration_ms,
1589        token_usage,
1590        tool_calls,
1591        message: message.into(),
1592    }
1593}
1594
1595fn runtime_event_kind(
1596    provider: Option<&str>,
1597    stage: ProgressStage,
1598    token_usage: Option<&TokenUsage>,
1599    tool_calls: &[ToolUsage],
1600) -> RuntimeEventKind {
1601    if !tool_calls.is_empty() {
1602        RuntimeEventKind::ToolUsage
1603    } else if token_usage.is_some() {
1604        RuntimeEventKind::TokenUsage
1605    } else if provider.is_some() {
1606        RuntimeEventKind::Provider
1607    } else if matches!(stage, ProgressStage::Context) {
1608        RuntimeEventKind::Context
1609    } else {
1610        RuntimeEventKind::Iteration
1611    }
1612}
1613
1614fn command_progress(label: Option<&str>, sink: Option<ProgressSink>) -> Option<CommandProgress> {
1615    label.map(|label| CommandProgress {
1616        label: label.to_string(),
1617        sink,
1618    })
1619}
1620
1621fn render_provider_capability_statuses(statuses: &[ProviderCapabilityStatus]) -> String {
1622    let mut lines = vec!["Provider capabilities".to_string()];
1623    for status in statuses {
1624        lines.push(format!(
1625            "- {} MCP: {} ({})",
1626            status.provider, status.mcp.status, status.mcp.command
1627        ));
1628        if !status.mcp.detail.trim().is_empty() {
1629            lines.push(format!("  {}", status.mcp.detail));
1630        }
1631        if let Some(skills) = &status.skills {
1632            lines.push(format!(
1633                "  skills/plugins: {} ({})",
1634                skills.status, skills.command
1635            ));
1636            if !skills.detail.trim().is_empty() {
1637                lines.push(format!("  {}", skills.detail));
1638            }
1639        }
1640        if let Some(tools) = &status.tools {
1641            lines.push(format!(
1642                "  tools/extensions: {} ({})",
1643                tools.status, tools.command
1644            ));
1645            if !tools.detail.trim().is_empty() {
1646                lines.push(format!("  {}", tools.detail));
1647            }
1648        }
1649        lines.push(format!("  {}", status.detail));
1650    }
1651    lines.join("\n")
1652}
1653
1654fn run_social_login(resolved: &ResolvedArgs) -> Result<(), String> {
1655    let providers = if resolved.raw.auth_login_providers.is_empty() {
1656        resolved.members.clone()
1657    } else {
1658        resolved.raw.auth_login_providers.clone()
1659    };
1660
1661    for provider in providers {
1662        validate_engine_name(&provider, false, "--auth-login-providers")?;
1663        let (bin, args, instruction): (String, Vec<String>, &str) =
1664            match provider.as_str() {
1665                "codex" => {
1666                    let mut args = vec!["login".to_string()];
1667                    if resolved.raw.auth_device_code {
1668                        args.push("--device-auth".to_string());
1669                    }
1670                    (
1671                        resolve_binary("codex"),
1672                        args,
1673                        "Complete the Codex browser login. Deeplinks and pasted codes are supported by the provider CLI when prompted.",
1674                    )
1675                }
1676                "claude" => {
1677                    let mut args = vec!["auth".to_string(), "login".to_string()];
1678                    match resolved.raw.claude_login_mode.as_str() {
1679                        "console" => args.push("--console".to_string()),
1680                        "sso" => args.push("--sso".to_string()),
1681                        _ => args.push("--claudeai".to_string()),
1682                    }
1683                    if let Some(email) = &resolved.raw.claude_login_email {
1684                        push_arg(&mut args, "--email", email.clone());
1685                    }
1686                    (
1687                        resolve_binary("claude"),
1688                        args,
1689                        "Complete the Claude browser login. Paste any shown login code into this terminal when prompted; deeplinks are opened when the CLI emits them.",
1690                    )
1691                }
1692                "gemini" => (
1693                    resolve_binary("gemini"),
1694                    vec![],
1695                    "Use the Gemini CLI auth selector, choose browser/social login, and complete local callback or code paste when prompted.",
1696                ),
1697                _ => unreachable!(),
1698            };
1699        eprintln!(
1700            "[auth] launching {provider}: {}",
1701            format_command(&bin, &args)
1702        );
1703        eprintln!("[auth] {provider}: {instruction}");
1704        let result = run_interactive_auth_command(
1705            &bin,
1706            &args,
1707            &resolved.cwd,
1708            resolved.raw.auth_timeout * 1000,
1709            !resolved.raw.no_auth_open_browser,
1710            &provider,
1711        );
1712        if result.code.unwrap_or(1) != 0 {
1713            return Err(format!(
1714                "{provider} social login failed: {}",
1715                compact_failure(&result)
1716            ));
1717        }
1718        let status = provider_auth_status(&provider, resolved);
1719        eprintln!("[auth] {provider}: {} ({})", status.status, status.detail);
1720    }
1721    Ok(())
1722}
1723
1724fn run_interactive_auth_command(
1725    command: &str,
1726    args: &[String],
1727    cwd: &Path,
1728    timeout_ms: u64,
1729    open_browser: bool,
1730    provider: &str,
1731) -> CommandResult {
1732    let started = Instant::now();
1733    let mut process = Command::new(command);
1734    process
1735        .args(args)
1736        .current_dir(cwd)
1737        .stdin(Stdio::inherit())
1738        .stdout(Stdio::piped())
1739        .stderr(Stdio::piped());
1740    configure_process_group(&mut process);
1741    let mut child = match process.spawn() {
1742        Ok(child) => child,
1743        Err(error) => {
1744            return CommandResult {
1745                command: command.to_string(),
1746                args: args.to_vec(),
1747                code: None,
1748                stdout: String::new(),
1749                stderr: String::new(),
1750                timed_out: false,
1751                cancelled: false,
1752                error: Some(error.to_string()),
1753                timeout_ms,
1754                duration_ms: started.elapsed().as_millis(),
1755            }
1756        }
1757    };
1758
1759    let seen_urls = Arc::new(Mutex::new(HashSet::new()));
1760    let stdout = child.stdout.take().map(|pipe| {
1761        read_auth_pipe(
1762            pipe,
1763            true,
1764            provider.to_string(),
1765            open_browser,
1766            Arc::clone(&seen_urls),
1767        )
1768    });
1769    let stderr = child.stderr.take().map(|pipe| {
1770        read_auth_pipe(
1771            pipe,
1772            false,
1773            provider.to_string(),
1774            open_browser,
1775            Arc::clone(&seen_urls),
1776        )
1777    });
1778    let timeout = Duration::from_millis(timeout_ms);
1779    let mut timed_out = false;
1780    let code;
1781    loop {
1782        match child.try_wait() {
1783            Ok(Some(status)) => {
1784                code = status.code();
1785                break;
1786            }
1787            Ok(None) => {
1788                if timeout_ms > 0 && started.elapsed() >= timeout {
1789                    timed_out = true;
1790                    terminate_child_tree(&mut child);
1791                    let status = child.wait().ok();
1792                    code = status.and_then(|status| status.code());
1793                    break;
1794                }
1795                thread::sleep(Duration::from_millis(50));
1796            }
1797            Err(error) => {
1798                return CommandResult {
1799                    command: command.to_string(),
1800                    args: args.to_vec(),
1801                    code: None,
1802                    stdout: String::new(),
1803                    stderr: String::new(),
1804                    timed_out,
1805                    cancelled: false,
1806                    error: Some(error.to_string()),
1807                    timeout_ms,
1808                    duration_ms: started.elapsed().as_millis(),
1809                }
1810            }
1811        }
1812    }
1813
1814    CommandResult {
1815        command: command.to_string(),
1816        args: args.to_vec(),
1817        code,
1818        stdout: stdout
1819            .and_then(|handle| handle.join().ok())
1820            .unwrap_or_default(),
1821        stderr: stderr
1822            .and_then(|handle| handle.join().ok())
1823            .unwrap_or_default(),
1824        timed_out,
1825        cancelled: false,
1826        error: None,
1827        timeout_ms,
1828        duration_ms: started.elapsed().as_millis(),
1829    }
1830}
1831
1832fn read_auth_pipe<R>(
1833    mut pipe: R,
1834    stdout: bool,
1835    provider: String,
1836    open_browser: bool,
1837    seen_urls: Arc<Mutex<HashSet<String>>>,
1838) -> thread::JoinHandle<String>
1839where
1840    R: Read + Send + 'static,
1841{
1842    thread::spawn(move || {
1843        let mut text = String::new();
1844        let mut buffer = [0u8; 4096];
1845        loop {
1846            let read = match pipe.read(&mut buffer) {
1847                Ok(0) => break,
1848                Ok(read) => read,
1849                Err(_) => break,
1850            };
1851            let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
1852            text.push_str(&chunk);
1853            if stdout {
1854                print!("{chunk}");
1855                let _ = io::stdout().flush();
1856            } else {
1857                eprint!("{chunk}");
1858                let _ = io::stderr().flush();
1859            }
1860            if open_browser {
1861                for url in extract_auth_urls(&chunk) {
1862                    let mut seen = seen_urls.lock().ok();
1863                    if seen.as_mut().is_some_and(|seen| !seen.insert(url.clone())) {
1864                        continue;
1865                    }
1866                    eprintln!("[auth] {provider}: opening {url}");
1867                    if let Err(error) = open_browser_url(&url) {
1868                        eprintln!("[auth] {provider}: failed to open {url}: {error}");
1869                    }
1870                }
1871            }
1872        }
1873        text
1874    })
1875}
1876
1877fn extract_auth_urls(text: &str) -> Vec<String> {
1878    text.split_whitespace()
1879        .map(|token| {
1880            token.trim_matches(|ch: char| {
1881                matches!(ch, '<' | '>' | '"' | '\'' | ')' | '(' | ',' | ';')
1882            })
1883        })
1884        .map(|token| token.trim_end_matches(['.', ':', ',', ';']))
1885        .filter(|token| {
1886            token.starts_with("http://")
1887                || token.starts_with("https://")
1888                || token.starts_with("codex://")
1889                || token.starts_with("openai://")
1890                || token.starts_with("claude://")
1891                || token.starts_with("anthropic://")
1892                || token.starts_with("gemini://")
1893                || token.starts_with("google://")
1894        })
1895        .map(ToString::to_string)
1896        .collect()
1897}
1898
1899fn open_browser_url(url: &str) -> Result<(), String> {
1900    let (command, args): (&str, Vec<String>) = if cfg!(target_os = "macos") {
1901        ("open", vec![url.to_string()])
1902    } else if cfg!(target_os = "windows") {
1903        (
1904            "cmd",
1905            vec![
1906                "/c".to_string(),
1907                "start".to_string(),
1908                "".to_string(),
1909                url.to_string(),
1910            ],
1911        )
1912    } else {
1913        ("xdg-open", vec![url.to_string()])
1914    };
1915    Command::new(command)
1916        .args(args)
1917        .stdin(Stdio::null())
1918        .stdout(Stdio::null())
1919        .stderr(Stdio::null())
1920        .spawn()
1921        .map(|mut child| {
1922            let _ = child.try_wait();
1923        })
1924        .map_err(|error| error.to_string())
1925}
1926
1927fn build_prompt_context_with_progress(
1928    resolved: &ResolvedArgs,
1929    progress: Option<ProgressSink>,
1930) -> Result<PromptContext, String> {
1931    let mut prompt = resolved.prompt.trim().to_string();
1932    let mut sections = Vec::new();
1933    let mut commands = Vec::new();
1934
1935    for file in &resolved.raw.files {
1936        let path = if file.is_absolute() {
1937            file.clone()
1938        } else {
1939            resolved.cwd.join(file)
1940        };
1941        let content = fs::read_to_string(&path)
1942            .map_err(|error| format!("Failed to read tagged file {}: {error}", path.display()))?;
1943        sections.push(format!("--- file: {} ---\n{}", file.display(), content));
1944    }
1945
1946    for command in &resolved.raw.commands {
1947        let shell = if cfg!(windows) { "cmd" } else { "sh" };
1948        let args = if cfg!(windows) {
1949            vec!["/C".to_string(), command.clone()]
1950        } else {
1951            vec!["-lc".to_string(), command.clone()]
1952        };
1953        let live_label = (resolved.raw.verbose || progress.is_some())
1954            .then(|| format!("context command `{}`", truncate(command, 80)));
1955        emit_runtime_event(
1956            &progress,
1957            false,
1958            progress_event(
1959                None,
1960                None,
1961                ProgressStage::Context,
1962                None,
1963                format!("[amon-hen] context command `{}`", truncate(command, 80)),
1964            ),
1965        );
1966        let result = run_command(
1967            CommandRequest::new(shell, &args, &resolved.cwd, resolved.raw.timeout * 1000)
1968                .progress(command_progress(live_label.as_deref(), progress.clone())),
1969        );
1970        let telemetry = command_telemetry(&result);
1971        let mut event = progress_event(
1972            None,
1973            None,
1974            ProgressStage::Done,
1975            Some(&telemetry.status),
1976            format!(
1977                "[amon-hen] context command `{}` {} in {:.1}s",
1978                truncate(command, 80),
1979                telemetry.status,
1980                result.duration_ms as f64 / 1000.0
1981            ),
1982        );
1983        event.kind = RuntimeEventKind::Context;
1984        emit_runtime_event(&progress, false, event);
1985        commands.push(telemetry);
1986        sections.push(format!(
1987            "--- command: {} (exit {}) ---\nstdout:\n{}\nstderr:\n{}",
1988            command,
1989            result
1990                .code
1991                .map(|code| code.to_string())
1992                .unwrap_or_else(|| "unknown".to_string()),
1993            result.stdout.trim(),
1994            result.stderr.trim()
1995        ));
1996    }
1997
1998    if !sections.is_empty() {
1999        prompt.push_str("\n\nPrompt context:\n");
2000        prompt.push_str(&sections.join("\n\n"));
2001    }
2002    Ok(PromptContext { prompt, commands })
2003}
2004
2005fn run_amon_hen_with_progress(
2006    resolved: &ResolvedArgs,
2007    query: String,
2008    prompt_commands: Vec<CommandTelemetry>,
2009    progress: Option<ProgressSink>,
2010) -> AmonHenResult {
2011    run_amon_hen_with_progress_and_cancel(resolved, query, prompt_commands, progress, None)
2012}
2013
2014fn run_amon_hen_with_progress_and_cancel(
2015    resolved: &ResolvedArgs,
2016    query: String,
2017    prompt_commands: Vec<CommandTelemetry>,
2018    progress: Option<ProgressSink>,
2019    cancel: Option<Arc<AtomicBool>>,
2020) -> AmonHenResult {
2021    let workflow = build_workflow(resolved);
2022    let mut previous_iteration = Vec::new();
2023    let mut final_members = Vec::new();
2024    let mut iterations = Vec::new();
2025
2026    for iteration in 1..=workflow.iterations {
2027        let iteration_started = Instant::now();
2028        let handoff_context =
2029            iteration_handoff_context(&previous_iteration, resolved.raw.max_member_chars);
2030        emit_runtime_event(
2031            &progress,
2032            resolved.raw.verbose,
2033            progress_event_with_context(
2034                None,
2035                None,
2036                ProgressStage::Start,
2037                None,
2038                Some(iteration),
2039                Some(workflow.iterations),
2040                false,
2041                None,
2042                None,
2043                vec![],
2044                format!(
2045                    "[amon-hen] iteration {iteration}/{} started",
2046                    workflow.iterations
2047                ),
2048            ),
2049        );
2050        final_members = run_iteration(
2051            resolved,
2052            &query,
2053            &workflow,
2054            iteration,
2055            &previous_iteration,
2056            progress.clone(),
2057            cancel.clone(),
2058        );
2059        iterations.push(iteration_record(
2060            iteration,
2061            workflow.iterations,
2062            final_members.clone(),
2063            iteration_started.elapsed().as_millis(),
2064            handoff_context,
2065            None,
2066        ));
2067        previous_iteration = final_members.clone();
2068    }
2069
2070    let successes = final_members
2071        .iter()
2072        .filter(|result| result.status == "ok")
2073        .cloned()
2074        .collect::<Vec<_>>();
2075    let summary = if successes.is_empty() {
2076        EngineResult {
2077            name: resolved.raw.summarizer.clone(),
2078            bin: None,
2079            status: "error".to_string(),
2080            duration_ms: 0,
2081            detail: "No Amon Hen member produced a response.".to_string(),
2082            exit_code: None,
2083            stdout: String::new(),
2084            stderr: String::new(),
2085            output: String::new(),
2086            command: String::new(),
2087            token_usage: token_usage("", ""),
2088            tool_calls: vec![],
2089            sub_agents: vec![],
2090            role: "summary".to_string(),
2091            iteration: workflow.iterations,
2092            total_iterations: workflow.iterations,
2093            team_size: 0,
2094        }
2095    } else {
2096        let summary_prompt =
2097            build_summary_prompt(&query, &successes, &workflow, resolved.raw.max_member_chars);
2098        if let Some(record) = iterations.last_mut() {
2099            record.summary_context = Some(truncate(&summary_prompt, resolved.raw.max_member_chars));
2100        }
2101        let summarizer = pick_summarizer(resolved, &successes);
2102        let mut options = engine_options(
2103            resolved,
2104            EngineOptionsInput {
2105                member: &summarizer,
2106                prompt: summary_prompt,
2107                role: "summary",
2108                iteration: workflow.iterations,
2109                workflow: &workflow,
2110                progress: progress.clone(),
2111                cancel: cancel.clone(),
2112            },
2113        );
2114        options.role = "summary".to_string();
2115        options.team_size = 0;
2116        run_engine(&summarizer, options)
2117    };
2118
2119    AmonHenResult {
2120        query,
2121        cwd: resolved.cwd.display().to_string(),
2122        members_requested: resolved.members.clone(),
2123        summarizer_requested: resolved.raw.summarizer.clone(),
2124        workflow,
2125        prompt_commands,
2126        iterations,
2127        members: final_members,
2128        summary,
2129    }
2130}
2131
2132fn build_workflow(resolved: &ResolvedArgs) -> Workflow {
2133    let mut teams = HashMap::new();
2134    for member in ENGINES {
2135        teams.insert(member.to_string(), resolved.raw.team_work);
2136    }
2137    if let Some(value) = resolved.raw.codex_sub_agents {
2138        teams.insert("codex".to_string(), value);
2139    }
2140    if let Some(value) = resolved.raw.claude_sub_agents {
2141        teams.insert("claude".to_string(), value);
2142    }
2143    if let Some(value) = resolved.raw.gemini_sub_agents {
2144        teams.insert("gemini".to_string(), value);
2145    }
2146    Workflow {
2147        handoff: resolved.raw.handoff,
2148        lead: resolved.raw.lead.clone(),
2149        planner: resolved.raw.planner.clone(),
2150        iterations: resolved.raw.iterations.max(1),
2151        team_work: resolved.raw.team_work,
2152        teams,
2153    }
2154}
2155
2156fn effective_team_size(workflow: &Workflow, member: &str) -> usize {
2157    *workflow.teams.get(member).unwrap_or(&workflow.team_work)
2158}
2159
2160fn iteration_record(
2161    iteration: usize,
2162    total_iterations: usize,
2163    members: Vec<EngineResult>,
2164    duration_ms: u128,
2165    handoff_context: Option<String>,
2166    summary_context: Option<String>,
2167) -> IterationRecord {
2168    let status = if members.iter().any(|member| member.status == "ok") {
2169        "ok"
2170    } else if members.iter().any(|member| member.status == "cancelled") {
2171        "cancelled"
2172    } else if members.iter().any(|member| member.status == "timeout") {
2173        "timeout"
2174    } else if members.iter().any(|member| member.status == "missing") {
2175        "missing"
2176    } else {
2177        "error"
2178    };
2179    let sub_agents = members
2180        .iter()
2181        .flat_map(|member| member.sub_agents.iter().cloned())
2182        .collect::<Vec<_>>();
2183    let token_usage = aggregate_results_token_usage(members.iter());
2184    let tool_calls = members
2185        .iter()
2186        .chain(sub_agents.iter())
2187        .flat_map(|member| member.tool_calls.iter().cloned())
2188        .collect::<Vec<_>>();
2189    IterationRecord {
2190        iteration,
2191        total_iterations,
2192        status: status.to_string(),
2193        duration_ms,
2194        members,
2195        sub_agents,
2196        handoff_context,
2197        summary_context,
2198        token_usage,
2199        tool_calls,
2200    }
2201}
2202
2203fn iteration_handoff_context(
2204    previous_iteration: &[EngineResult],
2205    max_member_chars: usize,
2206) -> Option<String> {
2207    let context = previous_iteration
2208        .iter()
2209        .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
2210        .map(|result| {
2211            format!(
2212                "### {} ({})\n{}",
2213                result.name,
2214                result.role,
2215                truncate(result.output.trim(), max_member_chars)
2216            )
2217        })
2218        .collect::<Vec<_>>()
2219        .join("\n\n");
2220    (!context.is_empty()).then_some(context)
2221}
2222
2223fn run_iteration(
2224    resolved: &ResolvedArgs,
2225    query: &str,
2226    workflow: &Workflow,
2227    iteration: usize,
2228    previous_iteration: &[EngineResult],
2229    progress: Option<ProgressSink>,
2230    cancel: Option<Arc<AtomicBool>>,
2231) -> Vec<EngineResult> {
2232    let ordered = execution_order(
2233        &resolved.members,
2234        workflow.planner.as_deref(),
2235        workflow.lead.as_deref(),
2236    );
2237    if workflow.handoff {
2238        let mut results = Vec::new();
2239        for member in ordered {
2240            let role = role_for(&member, workflow);
2241            let team_size = effective_team_size(workflow, &member);
2242            let prompt = build_member_prompt(MemberPromptInput {
2243                query,
2244                role: &role,
2245                workflow,
2246                iteration,
2247                team_size,
2248                previous_iteration,
2249                handoff_results: &results,
2250                plan_output: "",
2251            });
2252            let options = engine_options(
2253                resolved,
2254                EngineOptionsInput {
2255                    member: &member,
2256                    prompt,
2257                    role: &role,
2258                    iteration,
2259                    workflow,
2260                    progress: progress.clone(),
2261                    cancel: cancel.clone(),
2262                },
2263            );
2264            results.push(run_engine(&member, options));
2265        }
2266        return results;
2267    }
2268
2269    let planner = workflow
2270        .planner
2271        .as_ref()
2272        .filter(|planner| ordered.contains(planner))
2273        .cloned();
2274    let mut results = Vec::new();
2275    let mut plan_output = String::new();
2276    if let Some(planner_name) = planner.as_ref() {
2277        let role = role_for(planner_name, workflow);
2278        let team_size = effective_team_size(workflow, planner_name);
2279        let prompt = build_member_prompt(MemberPromptInput {
2280            query,
2281            role: &role,
2282            workflow,
2283            iteration,
2284            team_size,
2285            previous_iteration,
2286            handoff_results: &[],
2287            plan_output: "",
2288        });
2289        let options = engine_options(
2290            resolved,
2291            EngineOptionsInput {
2292                member: planner_name,
2293                prompt,
2294                role: &role,
2295                iteration,
2296                workflow,
2297                progress: progress.clone(),
2298                cancel: cancel.clone(),
2299            },
2300        );
2301        let result = run_engine(planner_name, options);
2302        if result.status == "ok" {
2303            plan_output = result.output.clone();
2304        }
2305        results.push(result);
2306    }
2307
2308    let (tx, rx) = mpsc::channel();
2309    let executors = ordered
2310        .into_iter()
2311        .filter(|member| Some(member) != planner.as_ref())
2312        .collect::<Vec<_>>();
2313    let resolved = Arc::new(resolved.clone());
2314    let workflow = Arc::new(workflow.clone());
2315    let previous_iteration = Arc::new(previous_iteration.to_vec());
2316    let plan_output = Arc::new(plan_output);
2317    let progress = Arc::new(progress);
2318    let cancel = Arc::new(cancel);
2319    for member in executors.clone() {
2320        let tx = tx.clone();
2321        let resolved = Arc::clone(&resolved);
2322        let workflow = Arc::clone(&workflow);
2323        let query = query.to_string();
2324        let previous_iteration = Arc::clone(&previous_iteration);
2325        let plan_output = Arc::clone(&plan_output);
2326        let progress = Arc::clone(&progress);
2327        let cancel = Arc::clone(&cancel);
2328        thread::spawn(move || {
2329            let role = role_for(&member, &workflow);
2330            let team_size = effective_team_size(&workflow, &member);
2331            let prompt = build_member_prompt(MemberPromptInput {
2332                query: &query,
2333                role: &role,
2334                workflow: &workflow,
2335                iteration,
2336                team_size,
2337                previous_iteration: previous_iteration.as_slice(),
2338                handoff_results: &[],
2339                plan_output: &plan_output,
2340            });
2341            let options = engine_options(
2342                &resolved,
2343                EngineOptionsInput {
2344                    member: &member,
2345                    prompt,
2346                    role: &role,
2347                    iteration,
2348                    workflow: &workflow,
2349                    progress: (*progress).clone(),
2350                    cancel: (*cancel).clone(),
2351                },
2352            );
2353            let _ = tx.send(run_engine(&member, options));
2354        });
2355    }
2356    drop(tx);
2357    let mut executor_results = rx.into_iter().collect::<Vec<_>>();
2358    executor_results.sort_by_key(|result| {
2359        executors
2360            .iter()
2361            .position(|member| member == &result.name)
2362            .unwrap_or(usize::MAX)
2363    });
2364    results.extend(executor_results);
2365    results
2366}
2367
2368fn engine_options(resolved: &ResolvedArgs, input: EngineOptionsInput<'_>) -> EngineRunOptions {
2369    EngineRunOptions {
2370        prompt: input.prompt,
2371        cwd: resolved.cwd.clone(),
2372        timeout_ms: resolved.raw.timeout * 1000,
2373        effort: provider_effort(resolved, input.member),
2374        model: provider_model(resolved, input.member),
2375        permission: provider_permission(resolved, input.member),
2376        auth: provider_auth(resolved, input.member),
2377        capability: provider_capability(resolved, input.member),
2378        role: input.role.to_string(),
2379        iteration: input.iteration,
2380        total_iterations: input.workflow.iterations,
2381        team_size: effective_team_size(input.workflow, input.member),
2382        is_sub_agent: false,
2383        live: resolved.raw.verbose || input.progress.is_some(),
2384        progress: input.progress,
2385        cancel: input.cancel,
2386    }
2387}
2388
2389fn provider_effort(resolved: &ResolvedArgs, member: &str) -> Option<String> {
2390    provider_option(
2391        member,
2392        &resolved.raw.codex_effort,
2393        &resolved.raw.claude_effort,
2394        &resolved.raw.gemini_effort,
2395    )
2396    .or_else(|| {
2397        resolved
2398            .raw
2399            .effort
2400            .map(|effort| effort.as_str().to_string())
2401    })
2402}
2403
2404fn provider_model(resolved: &ResolvedArgs, member: &str) -> Option<String> {
2405    provider_option(
2406        member,
2407        &resolved.raw.codex_model,
2408        &resolved.raw.claude_model,
2409        &resolved.raw.gemini_model,
2410    )
2411}
2412
2413fn provider_permission(resolved: &ResolvedArgs, member: &str) -> Option<String> {
2414    match Engine::parse(member) {
2415        Some(Engine::Codex) => Some(resolved.raw.codex_sandbox.clone()),
2416        Some(Engine::Claude) => Some(resolved.raw.claude_permission_mode.clone()),
2417        Some(Engine::Gemini) | None => None,
2418    }
2419}
2420
2421fn provider_auth(resolved: &ResolvedArgs, member: &str) -> String {
2422    match Engine::parse(member) {
2423        Some(Engine::Codex) => resolved.raw.codex_auth.clone(),
2424        Some(Engine::Claude) => resolved.raw.claude_auth.clone(),
2425        Some(Engine::Gemini) => resolved.raw.gemini_auth.clone(),
2426        None => DEFAULT_AUTH_MODE.to_string(),
2427    }
2428}
2429
2430fn provider_capability(resolved: &ResolvedArgs, member: &str) -> ProviderCapability {
2431    match Engine::parse(member).expect("provider capabilities use validated engines") {
2432        Engine::Codex => ProviderCapability {
2433            mode: inferred_capability_mode(
2434                &resolved.raw.codex_capabilities,
2435                !resolved.raw.codex_config.is_empty() || resolved.raw.codex_mcp_profile.is_some(),
2436            ),
2437            config: resolved.raw.codex_config.clone(),
2438            mcp_profile: resolved.raw.codex_mcp_profile.clone(),
2439            mcp_config: vec![],
2440            allowed_tools: vec![],
2441            disallowed_tools: vec![],
2442            tools: vec![],
2443            agent: None,
2444            agents_json: None,
2445            plugin_dirs: vec![],
2446            strict_mcp_config: false,
2447            disable_slash_commands: false,
2448            settings: None,
2449            tools_profile: vec![],
2450            allowed_mcp_servers: vec![],
2451            policy: vec![],
2452            admin_policy: vec![],
2453        },
2454        Engine::Claude => ProviderCapability {
2455            mode: inferred_capability_mode(
2456                &resolved.raw.claude_capabilities,
2457                !resolved.raw.claude_mcp_config.is_empty()
2458                    || !resolved.raw.claude_allowed_tools.is_empty()
2459                    || !resolved.raw.claude_disallowed_tools.is_empty()
2460                    || !resolved.raw.claude_tools.is_empty()
2461                    || resolved.raw.claude_agent.is_some()
2462                    || resolved.raw.claude_agents_json.is_some()
2463                    || !resolved.raw.claude_plugin_dir.is_empty()
2464                    || resolved.raw.claude_strict_mcp_config
2465                    || resolved.raw.claude_disable_slash_commands,
2466            ),
2467            config: vec![],
2468            mcp_profile: None,
2469            mcp_config: resolved.raw.claude_mcp_config.clone(),
2470            allowed_tools: resolved.raw.claude_allowed_tools.clone(),
2471            disallowed_tools: resolved.raw.claude_disallowed_tools.clone(),
2472            tools: resolved.raw.claude_tools.clone(),
2473            agent: resolved.raw.claude_agent.clone(),
2474            agents_json: resolved.raw.claude_agents_json.clone(),
2475            plugin_dirs: resolved.raw.claude_plugin_dir.clone(),
2476            strict_mcp_config: resolved.raw.claude_strict_mcp_config,
2477            disable_slash_commands: resolved.raw.claude_disable_slash_commands,
2478            settings: None,
2479            tools_profile: vec![],
2480            allowed_mcp_servers: vec![],
2481            policy: vec![],
2482            admin_policy: vec![],
2483        },
2484        Engine::Gemini => ProviderCapability {
2485            mode: inferred_capability_mode(
2486                &resolved.raw.gemini_capabilities,
2487                resolved.raw.gemini_settings.is_some()
2488                    || !resolved.raw.gemini_tools_profile.is_empty()
2489                    || !resolved.raw.gemini_allowed_mcp_servers.is_empty()
2490                    || !resolved.raw.gemini_policy.is_empty()
2491                    || !resolved.raw.gemini_admin_policy.is_empty(),
2492            ),
2493            config: vec![],
2494            mcp_profile: None,
2495            mcp_config: vec![],
2496            allowed_tools: vec![],
2497            disallowed_tools: vec![],
2498            tools: vec![],
2499            agent: None,
2500            agents_json: None,
2501            plugin_dirs: vec![],
2502            strict_mcp_config: false,
2503            disable_slash_commands: false,
2504            settings: resolved.raw.gemini_settings.clone(),
2505            tools_profile: resolved.raw.gemini_tools_profile.clone(),
2506            allowed_mcp_servers: resolved.raw.gemini_allowed_mcp_servers.clone(),
2507            policy: resolved.raw.gemini_policy.clone(),
2508            admin_policy: resolved.raw.gemini_admin_policy.clone(),
2509        },
2510    }
2511}
2512
2513fn inferred_capability_mode(configured: &str, has_override_flags: bool) -> String {
2514    if configured == CAPABILITY_INHERIT && has_override_flags {
2515        CAPABILITY_OVERRIDE.to_string()
2516    } else {
2517        configured.to_string()
2518    }
2519}
2520
2521fn provider_option(
2522    member: &str,
2523    codex: &Option<String>,
2524    claude: &Option<String>,
2525    gemini: &Option<String>,
2526) -> Option<String> {
2527    match Engine::parse(member) {
2528        Some(Engine::Codex) => codex.clone(),
2529        Some(Engine::Claude) => claude.clone(),
2530        Some(Engine::Gemini) => gemini.clone(),
2531        None => None,
2532    }
2533}
2534
2535fn build_sub_agent_prompt(original: &str, role: &str, index: usize, total: usize) -> String {
2536    format!(
2537        "You are sub-agent {index} of {total} for an Amon Hen provider assigned role `{role}`.\n\
2538         Work independently on a useful slice of the task. Inspect, reason, or verify as needed, \
2539         then return concise findings, risks, and concrete recommendations for the provider lead.\n\n\
2540         Original provider prompt:\n{original}"
2541    )
2542}
2543
2544fn build_team_lead_prompt(original: &str, sub_agents: &[EngineResult]) -> String {
2545    let handoff = sub_agents
2546        .iter()
2547        .map(|agent| {
2548            format!(
2549                "### {} [{}]\n{}",
2550                agent.role,
2551                agent.status,
2552                if agent.output.trim().is_empty() {
2553                    agent.detail.trim()
2554                } else {
2555                    agent.output.trim()
2556                }
2557            )
2558        })
2559        .collect::<Vec<_>>()
2560        .join("\n\n");
2561    format!(
2562        "You are the provider lead. Use the sub-agent handoffs below, resolve disagreements, \
2563         and produce the final provider response for the original Amon Hen role.\n\n\
2564         Sub-agent handoffs:\n{handoff}\n\nOriginal provider prompt:\n{original}"
2565    )
2566}
2567
2568fn run_engine(name: &str, options: EngineRunOptions) -> EngineResult {
2569    if let Some(result) = missing_engine_if_unavailable(name, &options) {
2570        return result;
2571    }
2572    if options.team_size > 0 && options.role != "summary" && !options.is_sub_agent {
2573        return run_engine_team(name, options);
2574    }
2575    run_engine_single(name, options, vec![])
2576}
2577
2578fn missing_engine_if_unavailable(name: &str, options: &EngineRunOptions) -> Option<EngineResult> {
2579    let bin = resolve_binary(name);
2580    if command_available(&bin) {
2581        return None;
2582    }
2583    let detail = format!(
2584        "Provider CLI `{bin}` was not found in PATH. Install the {name} CLI on this machine or set {} to the executable path.",
2585        Engine::parse(name)
2586            .map(Engine::binary_env_var)
2587            .unwrap_or("AMON_HEN_PROVIDER_BIN")
2588    );
2589    let message = format!("[amon-hen] {name} missing: {detail}");
2590    emit_runtime_event(
2591        &options.progress,
2592        options.live && options.progress.is_none(),
2593        progress_event_with_context(
2594            Some(name),
2595            Some(&options.role),
2596            ProgressStage::Done,
2597            Some("missing"),
2598            Some(options.iteration),
2599            Some(options.total_iterations),
2600            options.is_sub_agent,
2601            Some(0),
2602            Some(token_usage(&options.prompt, "")),
2603            vec![],
2604            message,
2605        ),
2606    );
2607    Some(EngineResult {
2608        name: name.to_string(),
2609        bin: Some(bin.clone()),
2610        status: "missing".to_string(),
2611        duration_ms: 0,
2612        detail: detail.clone(),
2613        exit_code: None,
2614        stdout: String::new(),
2615        stderr: detail,
2616        output: String::new(),
2617        command: bin,
2618        token_usage: token_usage(&options.prompt, ""),
2619        tool_calls: vec![],
2620        sub_agents: vec![],
2621        role: options.role.clone(),
2622        iteration: options.iteration,
2623        total_iterations: options.total_iterations,
2624        team_size: options.team_size,
2625    })
2626}
2627
2628fn command_available(command: &str) -> bool {
2629    let path = Path::new(command);
2630    if path.components().count() > 1 || path.is_absolute() {
2631        return path.is_file();
2632    }
2633    let Some(paths) = std::env::var_os("PATH") else {
2634        return false;
2635    };
2636    std::env::split_paths(&paths).any(|dir| dir.join(command).is_file())
2637}
2638
2639fn run_engine_team(name: &str, options: EngineRunOptions) -> EngineResult {
2640    let team_size = options.team_size;
2641    let (tx, rx) = mpsc::channel();
2642    for index in 1..=team_size {
2643        let tx = tx.clone();
2644        let name = name.to_string();
2645        let mut sub_options = options.clone();
2646        sub_options.team_size = 0;
2647        sub_options.is_sub_agent = true;
2648        sub_options.role = format!("{}:sub-agent-{index}", options.role);
2649        sub_options.prompt =
2650            build_sub_agent_prompt(&options.prompt, &options.role, index, team_size);
2651        thread::spawn(move || {
2652            let _ = tx.send((index, run_engine_single(&name, sub_options, vec![])));
2653        });
2654    }
2655    drop(tx);
2656
2657    let mut indexed = rx.into_iter().collect::<Vec<_>>();
2658    indexed.sort_by_key(|(index, _)| *index);
2659    let sub_agents = indexed
2660        .into_iter()
2661        .map(|(_, result)| result)
2662        .collect::<Vec<_>>();
2663    let mut lead_options = options.clone();
2664    lead_options.prompt = build_team_lead_prompt(&options.prompt, &sub_agents);
2665    lead_options.is_sub_agent = true;
2666    run_engine_single(name, lead_options, sub_agents)
2667}
2668
2669fn run_engine_single(
2670    name: &str,
2671    options: EngineRunOptions,
2672    sub_agents: Vec<EngineResult>,
2673) -> EngineResult {
2674    let started = Instant::now();
2675    let bin = resolve_binary(name);
2676    let live = options.live;
2677    let role_is_sub_agent = options.role.contains(":sub-agent-");
2678    let start_message = format!(
2679        "[amon-hen] start {} role={} iteration={}/{}{}",
2680        name,
2681        options.role,
2682        options.iteration,
2683        options.total_iterations,
2684        if role_is_sub_agent { " sub-agent" } else { "" }
2685    );
2686    emit_runtime_event(
2687        &options.progress,
2688        live && options.progress.is_none(),
2689        progress_event_with_context(
2690            Some(name),
2691            Some(&options.role),
2692            ProgressStage::Start,
2693            Some("running"),
2694            Some(options.iteration),
2695            Some(options.total_iterations),
2696            role_is_sub_agent,
2697            None,
2698            None,
2699            vec![],
2700            start_message,
2701        ),
2702    );
2703    let result = match Engine::parse(name) {
2704        Some(Engine::Codex) => run_codex(&bin, &options),
2705        Some(Engine::Claude) => run_claude(&bin, &options),
2706        Some(Engine::Gemini) => run_gemini(&bin, &options),
2707        None => CommandResult {
2708            command: name.to_string(),
2709            args: vec![],
2710            code: None,
2711            stdout: String::new(),
2712            stderr: String::new(),
2713            timed_out: false,
2714            cancelled: false,
2715            error: Some(format!("Unknown engine: {name}")),
2716            timeout_ms: options.timeout_ms,
2717            duration_ms: started.elapsed().as_millis(),
2718        },
2719    };
2720    let engine_progress = options.progress.clone();
2721    let engine = finalize_engine(
2722        name,
2723        &bin,
2724        started.elapsed().as_millis(),
2725        result,
2726        options,
2727        sub_agents,
2728    );
2729    if live {
2730        let done_message = format!(
2731            "[amon-hen] done {} role={} status={} elapsed={:.1}s tokens={} tools={} sub-agents={}",
2732            engine.name,
2733            engine.role,
2734            engine.status,
2735            engine.duration_ms as f64 / 1000.0,
2736            engine.token_usage.total,
2737            engine.tool_calls.len(),
2738            engine.sub_agents.len()
2739        );
2740        emit_runtime_event(
2741            &engine_progress,
2742            live && engine_progress.is_none(),
2743            progress_event_with_context(
2744                Some(&engine.name),
2745                Some(&engine.role),
2746                ProgressStage::Done,
2747                Some(&engine.status),
2748                Some(engine.iteration),
2749                Some(engine.total_iterations),
2750                engine.role.contains(":sub-agent-"),
2751                Some(engine.duration_ms),
2752                Some(engine.token_usage.clone()),
2753                engine.tool_calls.clone(),
2754                done_message,
2755            ),
2756        );
2757    }
2758    engine
2759}
2760
2761fn push_arg(args: &mut Vec<String>, flag: &str, value: impl Into<String>) {
2762    args.push(flag.to_string());
2763    args.push(value.into());
2764}
2765
2766fn push_optional_arg(args: &mut Vec<String>, flag: &str, value: &Option<String>) {
2767    if let Some(value) = value {
2768        push_arg(args, flag, value.clone());
2769    }
2770}
2771
2772fn push_repeated_flag(args: &mut Vec<String>, flag: &str, values: &[String]) {
2773    if !values.is_empty() {
2774        args.push(flag.to_string());
2775        args.extend(values.iter().cloned());
2776    }
2777}
2778
2779fn push_each_arg(args: &mut Vec<String>, flag: &str, values: &[String]) {
2780    for value in values {
2781        push_arg(args, flag, value.clone());
2782    }
2783}
2784
2785fn run_codex(bin: &str, options: &EngineRunOptions) -> CommandResult {
2786    let temp = match tempfile::tempdir() {
2787        Ok(temp) => temp,
2788        Err(error) => {
2789            return CommandResult {
2790                command: bin.to_string(),
2791                args: vec![],
2792                code: None,
2793                stdout: String::new(),
2794                stderr: String::new(),
2795                timed_out: false,
2796                cancelled: false,
2797                error: Some(error.to_string()),
2798                timeout_ms: options.timeout_ms,
2799                duration_ms: 0,
2800            }
2801        }
2802    };
2803    let output_path = temp.path().join("last-message.txt");
2804    let mut args = vec!["exec".to_string()];
2805    push_optional_arg(&mut args, "--model", &options.model);
2806    if let Some(effort) = &options.effort {
2807        push_arg(&mut args, "-c", format!("model_reasoning_effort={effort}"));
2808    }
2809    if options.capability.mode == CAPABILITY_OVERRIDE {
2810        for config in &options.capability.config {
2811            push_arg(&mut args, "-c", config.clone());
2812        }
2813        push_optional_arg(&mut args, "--profile", &options.capability.mcp_profile);
2814    }
2815    args.extend([
2816        "--skip-git-repo-check".to_string(),
2817        "--sandbox".to_string(),
2818        options
2819            .permission
2820            .clone()
2821            .unwrap_or_else(|| "read-only".to_string()),
2822        "--ephemeral".to_string(),
2823        "--json".to_string(),
2824        "-o".to_string(),
2825        output_path.display().to_string(),
2826        "-".to_string(),
2827    ]);
2828    let mut result = run_command(
2829        CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
2830            .stdin_text(Some(&options.prompt))
2831            .progress(command_progress(
2832                live_label("codex", options).as_deref(),
2833                options.progress.clone(),
2834            ))
2835            .cancel(options.cancel.clone()),
2836    );
2837    if let Ok(output) = fs::read_to_string(output_path) {
2838        if !output.trim().is_empty() {
2839            result.stdout = output;
2840        }
2841    }
2842    result
2843}
2844
2845fn run_claude(bin: &str, options: &EngineRunOptions) -> CommandResult {
2846    let mut args = Vec::new();
2847    if should_use_claude_bare_mode(&options.auth) {
2848        args.push("--bare".to_string());
2849    }
2850    args.extend([
2851        "-p".to_string(),
2852        "--permission-mode".to_string(),
2853        options
2854            .permission
2855            .clone()
2856            .unwrap_or_else(|| "plan".to_string()),
2857        "--verbose".to_string(),
2858        "--output-format".to_string(),
2859        "stream-json".to_string(),
2860        "--include-partial-messages".to_string(),
2861        "--no-session-persistence".to_string(),
2862    ]);
2863    push_optional_arg(&mut args, "--model", &options.model);
2864    if let Some(effort) = options
2865        .effort
2866        .clone()
2867        .or_else(|| std::env::var("CLAUDE_CODE_EFFORT_LEVEL").ok())
2868    {
2869        push_arg(&mut args, "--effort", effort);
2870    }
2871    if options.capability.mode == CAPABILITY_OVERRIDE {
2872        push_repeated_flag(&mut args, "--mcp-config", &options.capability.mcp_config);
2873        push_repeated_flag(
2874            &mut args,
2875            "--allowedTools",
2876            &options.capability.allowed_tools,
2877        );
2878        push_repeated_flag(
2879            &mut args,
2880            "--disallowedTools",
2881            &options.capability.disallowed_tools,
2882        );
2883        push_repeated_flag(&mut args, "--tools", &options.capability.tools);
2884        push_optional_arg(&mut args, "--agent", &options.capability.agent);
2885        push_optional_arg(&mut args, "--agents", &options.capability.agents_json);
2886        push_each_arg(&mut args, "--plugin-dir", &options.capability.plugin_dirs);
2887        if options.capability.strict_mcp_config {
2888            args.push("--strict-mcp-config".to_string());
2889        }
2890        if options.capability.disable_slash_commands {
2891            args.push("--disable-slash-commands".to_string());
2892        }
2893    }
2894    run_command(
2895        CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
2896            .stdin_text(Some(&options.prompt))
2897            .progress(command_progress(
2898                live_label("claude", options).as_deref(),
2899                options.progress.clone(),
2900            ))
2901            .cancel(options.cancel.clone()),
2902    )
2903}
2904
2905fn run_gemini(bin: &str, options: &EngineRunOptions) -> CommandResult {
2906    let mut args = Vec::new();
2907    push_optional_arg(&mut args, "--model", &options.model);
2908    if options.capability.mode == CAPABILITY_OVERRIDE {
2909        push_repeated_flag(&mut args, "--extensions", &options.capability.tools_profile);
2910        push_repeated_flag(
2911            &mut args,
2912            "--allowed-mcp-server-names",
2913            &options.capability.allowed_mcp_servers,
2914        );
2915        push_repeated_flag(&mut args, "--policy", &options.capability.policy);
2916        push_repeated_flag(
2917            &mut args,
2918            "--admin-policy",
2919            &options.capability.admin_policy,
2920        );
2921    }
2922    args.extend([
2923        "-p".to_string(),
2924        options.prompt.clone(),
2925        "--skip-trust".to_string(),
2926        "--approval-mode".to_string(),
2927        "plan".to_string(),
2928        "--output-format".to_string(),
2929        "json".to_string(),
2930    ]);
2931    let mut envs = HashMap::new();
2932    let effort_settings = prepare_gemini_settings(options);
2933    if let Some(path) = effort_settings.as_ref() {
2934        envs.insert(
2935            "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2936            path.path.display().to_string(),
2937        );
2938    } else if options.capability.mode == CAPABILITY_OVERRIDE {
2939        if let Some(settings) = &options.capability.settings {
2940            envs.insert(
2941                "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2942                settings.clone(),
2943            );
2944        }
2945    }
2946    run_command(
2947        CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
2948            .envs(envs)
2949            .progress(command_progress(
2950                live_label("gemini", options).as_deref(),
2951                options.progress.clone(),
2952            ))
2953            .cancel(options.cancel.clone()),
2954    )
2955}
2956
2957fn live_label(name: &str, options: &EngineRunOptions) -> Option<String> {
2958    options.live.then(|| {
2959        format!(
2960            "{} {} iteration {}/{}",
2961            name, options.role, options.iteration, options.total_iterations
2962        )
2963    })
2964}
2965
2966fn provider_from_live_label(label: &str) -> Option<&str> {
2967    let provider = label.split_whitespace().next()?;
2968    ENGINES.contains(&provider).then_some(provider)
2969}
2970
2971fn role_from_live_label(label: &str) -> Option<&str> {
2972    if provider_from_live_label(label).is_some() {
2973        return label.split_whitespace().nth(1);
2974    }
2975    None
2976}
2977
2978fn iteration_from_live_label(label: &str) -> Option<usize> {
2979    iteration_pair_from_live_label(label).map(|(iteration, _)| iteration)
2980}
2981
2982fn total_iterations_from_live_label(label: &str) -> Option<usize> {
2983    iteration_pair_from_live_label(label).map(|(_, total)| total)
2984}
2985
2986fn iteration_pair_from_live_label(label: &str) -> Option<(usize, usize)> {
2987    let pair = label.split_whitespace().last()?;
2988    let (iteration, total) = pair.split_once('/')?;
2989    Some((iteration.parse().ok()?, total.parse().ok()?))
2990}
2991
2992fn prepare_gemini_settings(options: &EngineRunOptions) -> Option<TempSettings> {
2993    let effort = options.effort.as_deref()?;
2994    let budget = match effort {
2995        "low" => 1024,
2996        "medium" => 8192,
2997        "high" => 24576,
2998        _ => return None,
2999    };
3000    let dir = tempfile::tempdir().ok()?;
3001    let path = dir.path().join("settings.json");
3002    let mut settings = if options.capability.mode == CAPABILITY_OVERRIDE {
3003        options
3004            .capability
3005            .settings
3006            .as_ref()
3007            .and_then(|path| fs::read_to_string(path).ok())
3008            .and_then(|text| serde_json::from_str::<Value>(&text).ok())
3009            .unwrap_or_else(|| Value::Object(Default::default()))
3010    } else {
3011        Value::Object(Default::default())
3012    };
3013    if let Value::Object(object) = &mut settings {
3014        object.insert("thinkingBudget".to_string(), Value::from(budget));
3015    }
3016    let _ = fs::write(&path, serde_json::to_vec(&settings).ok()?);
3017    Some(TempSettings { _dir: dir, path })
3018}
3019
3020fn should_use_claude_bare_mode(auth: &str) -> bool {
3021    if auth == "api-key" {
3022        return true;
3023    }
3024    if matches!(auth, "social-login" | "oauth" | "keychain") {
3025        return false;
3026    }
3027    if std::env::var("CLAUDE_CODE_OAUTH_TOKEN")
3028        .ok()
3029        .filter(|value| !value.trim().is_empty())
3030        .is_some()
3031    {
3032        return false;
3033    }
3034    std::env::var("ANTHROPIC_API_KEY")
3035        .ok()
3036        .filter(|value| !value.trim().is_empty())
3037        .is_some()
3038}
3039
3040fn configure_process_group(command: &mut Command) {
3041    #[cfg(unix)]
3042    {
3043        use std::os::unix::process::CommandExt;
3044        command.process_group(0);
3045    }
3046}
3047
3048fn terminate_child_tree(child: &mut Child) {
3049    #[cfg(unix)]
3050    {
3051        let pgid = -(child.id() as i32);
3052        unsafe {
3053            libc::kill(pgid, libc::SIGTERM);
3054        }
3055        for _ in 0..20 {
3056            if child.try_wait().ok().flatten().is_some() {
3057                return;
3058            }
3059            thread::sleep(Duration::from_millis(25));
3060        }
3061        unsafe {
3062            libc::kill(pgid, libc::SIGKILL);
3063        }
3064    }
3065    let _ = child.kill();
3066}
3067
3068fn run_command(request: CommandRequest<'_>) -> CommandResult {
3069    let CommandRequest {
3070        command,
3071        args,
3072        cwd,
3073        stdin_text,
3074        timeout_ms,
3075        envs,
3076        progress,
3077        cancel,
3078    } = request;
3079    let started = Instant::now();
3080    if let Some(progress) = &progress {
3081        emit_runtime_event(
3082            &progress.sink,
3083            progress.sink.is_none(),
3084            progress_event_with_context(
3085                provider_from_live_label(&progress.label),
3086                role_from_live_label(&progress.label),
3087                ProgressStage::Spawn,
3088                Some("running"),
3089                iteration_from_live_label(&progress.label),
3090                total_iterations_from_live_label(&progress.label),
3091                role_from_live_label(&progress.label)
3092                    .is_some_and(|role| role.contains(":sub-agent-")),
3093                None,
3094                None,
3095                vec![],
3096                format!("[amon-hen] spawn {}", progress.label),
3097            ),
3098        );
3099    }
3100    let mut process = Command::new(command);
3101    process
3102        .args(args)
3103        .current_dir(cwd)
3104        .envs(envs)
3105        .stdin(Stdio::piped())
3106        .stdout(Stdio::piped())
3107        .stderr(Stdio::piped());
3108    configure_process_group(&mut process);
3109    let mut child = match process.spawn() {
3110        Ok(child) => child,
3111        Err(error) => {
3112            if let Some(progress) = &progress {
3113                emit_runtime_event(
3114                    &progress.sink,
3115                    progress.sink.is_none(),
3116                    progress_event_with_context(
3117                        provider_from_live_label(&progress.label),
3118                        role_from_live_label(&progress.label),
3119                        ProgressStage::Done,
3120                        Some("missing"),
3121                        iteration_from_live_label(&progress.label),
3122                        total_iterations_from_live_label(&progress.label),
3123                        role_from_live_label(&progress.label)
3124                            .is_some_and(|role| role.contains(":sub-agent-")),
3125                        Some(started.elapsed().as_millis()),
3126                        None,
3127                        vec![],
3128                        format!("[amon-hen] spawn failed {}: {error}", progress.label),
3129                    ),
3130                );
3131            }
3132            return CommandResult {
3133                command: command.to_string(),
3134                args: args.to_vec(),
3135                code: None,
3136                stdout: String::new(),
3137                stderr: String::new(),
3138                timed_out: false,
3139                cancelled: false,
3140                error: Some(error.to_string()),
3141                timeout_ms,
3142                duration_ms: started.elapsed().as_millis(),
3143            };
3144        }
3145    };
3146
3147    if let Some(mut stdin) = child.stdin.take() {
3148        if let Some(text) = stdin_text {
3149            let _ = stdin.write_all(text.as_bytes());
3150        }
3151    }
3152
3153    let stdout = child.stdout.take().map(read_pipe);
3154    let stderr = child.stderr.take().map(read_pipe);
3155    let timeout = Duration::from_millis(timeout_ms);
3156    let mut next_live_tick = Duration::from_secs(10);
3157    let mut timed_out = false;
3158    let mut cancelled = false;
3159    let code;
3160    loop {
3161        match child.try_wait() {
3162            Ok(Some(status)) => {
3163                code = status.code();
3164                break;
3165            }
3166            Ok(None) => {
3167                if cancel
3168                    .as_ref()
3169                    .is_some_and(|cancel| cancel.load(Ordering::Relaxed))
3170                {
3171                    cancelled = true;
3172                    terminate_child_tree(&mut child);
3173                    let status = child.wait().ok();
3174                    code = status.and_then(|status| status.code());
3175                    break;
3176                }
3177                if timeout_ms > 0 && started.elapsed() >= timeout {
3178                    timed_out = true;
3179                    terminate_child_tree(&mut child);
3180                    let status = child.wait().ok();
3181                    code = status.and_then(|status| status.code());
3182                    break;
3183                }
3184                if let Some(progress) = &progress {
3185                    let elapsed = started.elapsed();
3186                    if elapsed >= next_live_tick {
3187                        let timeout_detail = if timeout_ms > 0 {
3188                            format!("/{}s", timeout_ms / 1000)
3189                        } else {
3190                            String::new()
3191                        };
3192                        emit_runtime_event(
3193                            &progress.sink,
3194                            progress.sink.is_none(),
3195                            progress_event_with_context(
3196                                provider_from_live_label(&progress.label),
3197                                role_from_live_label(&progress.label),
3198                                ProgressStage::Heartbeat,
3199                                Some("running"),
3200                                iteration_from_live_label(&progress.label),
3201                                total_iterations_from_live_label(&progress.label),
3202                                role_from_live_label(&progress.label)
3203                                    .is_some_and(|role| role.contains(":sub-agent-")),
3204                                Some(elapsed.as_millis()),
3205                                None,
3206                                vec![],
3207                                format!(
3208                                    "[amon-hen] running {} for {}s{}",
3209                                    progress.label,
3210                                    elapsed.as_secs(),
3211                                    timeout_detail
3212                                ),
3213                            ),
3214                        );
3215                        next_live_tick += Duration::from_secs(10);
3216                    }
3217                }
3218                thread::sleep(Duration::from_millis(25));
3219            }
3220            Err(error) => {
3221                return CommandResult {
3222                    command: command.to_string(),
3223                    args: args.to_vec(),
3224                    code: None,
3225                    stdout: String::new(),
3226                    stderr: String::new(),
3227                    timed_out,
3228                    cancelled,
3229                    error: Some(error.to_string()),
3230                    timeout_ms,
3231                    duration_ms: started.elapsed().as_millis(),
3232                }
3233            }
3234        }
3235    }
3236
3237    CommandResult {
3238        command: command.to_string(),
3239        args: args.to_vec(),
3240        code,
3241        stdout: stdout
3242            .and_then(|handle| handle.join().ok())
3243            .unwrap_or_default(),
3244        stderr: stderr
3245            .and_then(|handle| handle.join().ok())
3246            .unwrap_or_default(),
3247        timed_out,
3248        cancelled,
3249        error: cancelled.then(|| "cancelled".to_string()),
3250        timeout_ms,
3251        duration_ms: started.elapsed().as_millis(),
3252    }
3253}
3254
3255fn read_pipe<R>(mut pipe: R) -> thread::JoinHandle<String>
3256where
3257    R: Read + Send + 'static,
3258{
3259    thread::spawn(move || {
3260        let mut text = String::new();
3261        let _ = pipe.read_to_string(&mut text);
3262        text
3263    })
3264}
3265
3266fn finalize_engine(
3267    name: &str,
3268    bin: &str,
3269    duration_ms: u128,
3270    command_result: CommandResult,
3271    options: EngineRunOptions,
3272    sub_agents: Vec<EngineResult>,
3273) -> EngineResult {
3274    let output = match Engine::parse(name) {
3275        Some(Engine::Claude) => parse_claude_output(&command_result.stdout),
3276        Some(Engine::Gemini) => parse_gemini_output(&command_result.stdout),
3277        Some(Engine::Codex) => parse_codex_output(&command_result.stdout),
3278        None => command_result.stdout.trim().to_string(),
3279    };
3280    let status = if command_result.cancelled {
3281        "cancelled"
3282    } else if command_result.error.is_some() {
3283        "missing"
3284    } else if command_result.timed_out {
3285        "timeout"
3286    } else if command_result.code != Some(0) || output.trim().is_empty() {
3287        "error"
3288    } else {
3289        "ok"
3290    };
3291    let detail = if status == "ok" {
3292        String::new()
3293    } else if command_result.cancelled {
3294        "Cancelled by Studio/user request.".to_string()
3295    } else if command_result.timed_out {
3296        format!("Timed out after {}s.", command_result.timeout_ms / 1000)
3297    } else {
3298        compact_failure(&command_result)
3299    };
3300    let usage = aggregate_token_usage(
3301        extract_token_usage(&command_result.stdout, &options.prompt, &output)
3302            .unwrap_or_else(|| token_usage(&options.prompt, &output)),
3303        &sub_agents,
3304    );
3305    let tool_calls = extract_tool_usage(name, &command_result.stdout, &command_result.stderr);
3306    EngineResult {
3307        name: name.to_string(),
3308        bin: Some(bin.to_string()),
3309        status: status.to_string(),
3310        duration_ms,
3311        detail,
3312        exit_code: command_result.code,
3313        stdout: command_result.stdout,
3314        stderr: command_result.stderr,
3315        output,
3316        command: format_command(&command_result.command, &command_result.args),
3317        token_usage: usage,
3318        tool_calls,
3319        sub_agents,
3320        role: options.role,
3321        iteration: options.iteration,
3322        total_iterations: options.total_iterations,
3323        team_size: options.team_size,
3324    }
3325}
3326
3327fn compact_failure(result: &CommandResult) -> String {
3328    if let Some(error) = &result.error {
3329        return error.clone();
3330    }
3331    if !result.stderr.trim().is_empty() {
3332        return result.stderr.trim().to_string();
3333    }
3334    if !result.stdout.trim().is_empty() {
3335        return result.stdout.trim().to_string();
3336    }
3337    result
3338        .code
3339        .map(|code| format!("Exited with code {code}."))
3340        .unwrap_or_else(|| "Command failed.".to_string())
3341}
3342
3343fn command_telemetry(result: &CommandResult) -> CommandTelemetry {
3344    let status = if result.cancelled {
3345        "cancelled"
3346    } else if result.error.is_some() {
3347        "missing"
3348    } else if result.timed_out {
3349        "timeout"
3350    } else if result.code == Some(0) {
3351        "ok"
3352    } else {
3353        "error"
3354    };
3355    CommandTelemetry {
3356        command: format_command(&result.command, &result.args),
3357        status: status.to_string(),
3358        detail: truncate(&sanitize_status_detail(&compact_failure(result)), 600),
3359        exit_code: result.code,
3360        duration_ms: result.duration_ms,
3361        stdout_chars: result.stdout.len(),
3362        stderr_chars: result.stderr.len(),
3363        timed_out: result.timed_out,
3364    }
3365}
3366
3367fn parse_codex_output(stdout: &str) -> String {
3368    stdout.trim().to_string()
3369}
3370
3371fn parse_claude_output(stdout: &str) -> String {
3372    let trimmed = stdout.trim();
3373    if trimmed.is_empty() {
3374        return String::new();
3375    }
3376    if let Some(value) = extract_json(trimmed) {
3377        if let Some(result) = value.get("result").and_then(Value::as_str) {
3378            return result.trim().to_string();
3379        }
3380    }
3381    let mut latest = String::new();
3382    for line in trimmed.lines() {
3383        let Ok(value) = serde_json::from_str::<Value>(line) else {
3384            continue;
3385        };
3386        if value.get("type").and_then(Value::as_str) == Some("result") {
3387            if let Some(result) = value.get("result").and_then(Value::as_str) {
3388                latest = result.trim().to_string();
3389            }
3390        }
3391        if value.get("type").and_then(Value::as_str) == Some("assistant") {
3392            if let Some(content) = value.pointer("/message/content").and_then(Value::as_array) {
3393                let text = content
3394                    .iter()
3395                    .filter_map(|block| {
3396                        (block.get("type").and_then(Value::as_str) == Some("text"))
3397                            .then(|| block.get("text").and_then(Value::as_str))
3398                            .flatten()
3399                    })
3400                    .collect::<String>();
3401                if !text.trim().is_empty() {
3402                    latest = text.trim().to_string();
3403                }
3404            }
3405        }
3406    }
3407    if latest.is_empty() {
3408        trimmed.to_string()
3409    } else {
3410        latest
3411    }
3412}
3413
3414fn parse_gemini_output(stdout: &str) -> String {
3415    let trimmed = stdout.trim();
3416    if trimmed.is_empty() {
3417        return String::new();
3418    }
3419    if let Some(value) = extract_json(trimmed) {
3420        if let Some(response) = value.get("response").and_then(Value::as_str) {
3421            return response.trim().to_string();
3422        }
3423    }
3424    trimmed.to_string()
3425}
3426
3427fn extract_json(text: &str) -> Option<Value> {
3428    serde_json::from_str::<Value>(text).ok().or_else(|| {
3429        let start = text.find('{')?;
3430        let end = text.rfind('}')?;
3431        serde_json::from_str::<Value>(&text[start..=end]).ok()
3432    })
3433}
3434
3435fn build_member_prompt(input: MemberPromptInput<'_>) -> String {
3436    let mut sections = vec![
3437        "You are one member of a multi-model Amon Hen run.".to_string(),
3438        format!(
3439            "Amon Hen workflow: iteration {iteration} of {}.",
3440            input.workflow.iterations,
3441            iteration = input.iteration
3442        ),
3443        input
3444            .workflow
3445            .lead
3446            .as_ref()
3447            .map(|lead| format!("Lead model: {lead}."))
3448            .unwrap_or_else(|| "Lead model: auto.".to_string()),
3449        input
3450            .workflow
3451            .planner
3452            .as_ref()
3453            .map(|planner| format!("Planner model: {planner}."))
3454            .unwrap_or_else(|| "Planner model: none.".to_string()),
3455        format!("Your assigned role: {}.", input.role),
3456        role_instruction(input.role).to_string(),
3457        "Answer the user query directly.".to_string(),
3458        "Do not introduce yourself.".to_string(),
3459        "Do not describe your tools, environment, or capabilities unless the user explicitly asks."
3460            .to_string(),
3461        "Be concise unless the user asks for depth.".to_string(),
3462    ];
3463    if input.team_size > 0 {
3464        sections.push(format!(
3465            "Team work: you may coordinate up to {} internal sub-agents or subtasks inside your own CLI if that helps.",
3466            input.team_size
3467        ));
3468    }
3469    if input.workflow.handoff {
3470        sections.push(
3471            "Handoff mode is enabled. Treat earlier Amon Hen outputs as working context."
3472                .to_string(),
3473        );
3474    }
3475    if !input.plan_output.trim().is_empty() {
3476        sections.push(format!("Planner handoff:\n{}", input.plan_output.trim()));
3477    }
3478    let context = input
3479        .previous_iteration
3480        .iter()
3481        .chain(input.handoff_results.iter())
3482        .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
3483        .map(|result| format!("### {}\n{}", result.name, result.output.trim()))
3484        .collect::<Vec<_>>()
3485        .join("\n\n");
3486    if !context.is_empty() {
3487        sections.push(format!("Earlier Amon Hen handoffs:\n{context}"));
3488    }
3489    sections.push(format!("Current user query:\n{}", input.query.trim()));
3490    sections.join("\n\n")
3491}
3492
3493fn build_summary_prompt(
3494    query: &str,
3495    responses: &[EngineResult],
3496    workflow: &Workflow,
3497    max_member_chars: usize,
3498) -> String {
3499    let blocks = responses
3500        .iter()
3501        .map(|response| {
3502            let output = truncate(&response.output, max_member_chars);
3503            format!("### {}\n{}", response.name, output.trim())
3504        })
3505        .collect::<Vec<_>>()
3506        .join("\n\n");
3507    format!(
3508        "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{}",
3509        workflow.iterations,
3510        if workflow.iterations == 1 { "" } else { "s" },
3511        if workflow.handoff { "handoff enabled" } else { "parallel consultation" },
3512        workflow.lead.as_deref().unwrap_or("auto"),
3513        workflow.planner.as_deref().unwrap_or("none"),
3514        query.trim(),
3515        blocks
3516    )
3517}
3518
3519fn role_instruction(role: &str) -> &'static str {
3520    match role {
3521        "planner" => "Plan the work: identify the approach, risks, checkpoints, and useful handoffs for the executors.",
3522        "lead" => "Lead the work: make the strongest direct attempt while watching for conflicts you may need to resolve in synthesis.",
3523        "lead+planner" => "Plan and lead the work: produce a practical plan, then make the strongest direct attempt from that plan.",
3524        _ => "Execute the work: use any plan or handoff context, then produce your independent best answer.",
3525    }
3526}
3527
3528fn execution_order(members: &[String], planner: Option<&str>, lead: Option<&str>) -> Vec<String> {
3529    let mut ordered = Vec::new();
3530    if let Some(planner) = planner {
3531        if members.iter().any(|member| member == planner) {
3532            ordered.push(planner.to_string());
3533        }
3534    }
3535    ordered.extend(
3536        members
3537            .iter()
3538            .filter(|member| Some(member.as_str()) != planner && Some(member.as_str()) != lead)
3539            .cloned(),
3540    );
3541    if lead != planner {
3542        if let Some(lead) = lead {
3543            if members.iter().any(|member| member == lead) {
3544                ordered.push(lead.to_string());
3545            }
3546        }
3547    }
3548    ordered
3549}
3550
3551fn role_for(member: &str, workflow: &Workflow) -> String {
3552    let is_lead = workflow.lead.as_deref() == Some(member);
3553    let is_planner = workflow.planner.as_deref() == Some(member);
3554    match (is_lead, is_planner) {
3555        (true, true) => "lead+planner",
3556        (true, false) => "lead",
3557        (false, true) => "planner",
3558        (false, false) => "executor",
3559    }
3560    .to_string()
3561}
3562
3563fn pick_summarizer(resolved: &ResolvedArgs, successes: &[EngineResult]) -> String {
3564    if resolved.raw.summarizer != "auto" {
3565        return resolved.raw.summarizer.clone();
3566    }
3567    if let Some(lead) = &resolved.raw.lead {
3568        if successes.iter().any(|result| &result.name == lead) {
3569            return lead.clone();
3570        }
3571    }
3572    DEFAULT_SUMMARIZER_ORDER
3573        .iter()
3574        .find(|name| successes.iter().any(|result| result.name == **name))
3575        .unwrap_or(&successes[0].name.as_str())
3576        .to_string()
3577}
3578
3579fn resolve_binary(name: &str) -> String {
3580    let Some(engine) = Engine::parse(name) else {
3581        return name.to_string();
3582    };
3583    std::env::var(engine.binary_env_var())
3584        .ok()
3585        .filter(|value| !value.trim().is_empty())
3586        .unwrap_or_else(|| engine.as_str().to_string())
3587}
3588
3589fn token_usage(prompt: &str, output: &str) -> TokenUsage {
3590    let input = estimate_tokens(prompt);
3591    let output = estimate_tokens(output);
3592    TokenUsage {
3593        input,
3594        output,
3595        total: input + output,
3596        estimated: true,
3597        source: "estimate".to_string(),
3598    }
3599}
3600
3601fn aggregate_token_usage(mut usage: TokenUsage, sub_agents: &[EngineResult]) -> TokenUsage {
3602    if sub_agents.is_empty() {
3603        return usage;
3604    }
3605    for agent in sub_agents {
3606        usage.input += agent.token_usage.input;
3607        usage.output += agent.token_usage.output;
3608        usage.total += agent.token_usage.total;
3609        usage.estimated |= agent.token_usage.estimated;
3610    }
3611    usage.source = format!("{}+sub-agents", usage.source);
3612    usage
3613}
3614
3615fn aggregate_results_token_usage<'a>(
3616    results: impl IntoIterator<Item = &'a EngineResult>,
3617) -> TokenUsage {
3618    let mut usage = TokenUsage {
3619        input: 0,
3620        output: 0,
3621        total: 0,
3622        estimated: false,
3623        source: "aggregate".to_string(),
3624    };
3625    for result in results {
3626        usage.input += result.token_usage.input;
3627        usage.output += result.token_usage.output;
3628        usage.total += result.token_usage.total;
3629        usage.estimated |= result.token_usage.estimated;
3630    }
3631    usage
3632}
3633
3634fn extract_token_usage(stdout: &str, prompt: &str, output: &str) -> Option<TokenUsage> {
3635    let mut usage = TokenAccumulator::default();
3636    for value in json_values(stdout) {
3637        collect_token_counts(&value, &mut usage);
3638    }
3639    if usage.input.is_none() && usage.output.is_none() && usage.total.is_none() {
3640        return None;
3641    }
3642    let input = usage.input.unwrap_or_else(|| estimate_tokens(prompt));
3643    let output = usage.output.unwrap_or_else(|| estimate_tokens(output));
3644    let total = usage.total.unwrap_or(input + output);
3645    Some(TokenUsage {
3646        input,
3647        output,
3648        total,
3649        estimated: usage.input.is_none() || usage.output.is_none(),
3650        source: "provider".to_string(),
3651    })
3652}
3653
3654#[derive(Default)]
3655struct TokenAccumulator {
3656    input: Option<usize>,
3657    output: Option<usize>,
3658    total: Option<usize>,
3659}
3660
3661fn collect_token_counts(value: &Value, usage: &mut TokenAccumulator) {
3662    match value {
3663        Value::Object(map) => {
3664            for (key, value) in map {
3665                let normalized = key.replace(['_', '-'], "").to_ascii_lowercase();
3666                if let Some(count) = value.as_u64().map(|value| value as usize) {
3667                    match normalized.as_str() {
3668                        "inputtokens" | "inputtokencount" | "prompttokens" | "prompttokencount" => {
3669                            usage.input = Some(count)
3670                        }
3671                        "outputtokens"
3672                        | "outputtokencount"
3673                        | "completiontokens"
3674                        | "candidatestokencount" => usage.output = Some(count),
3675                        "totaltokens" | "totaltokencount" => usage.total = Some(count),
3676                        _ => {}
3677                    }
3678                }
3679                collect_token_counts(value, usage);
3680            }
3681        }
3682        Value::Array(values) => {
3683            for value in values {
3684                collect_token_counts(value, usage);
3685            }
3686        }
3687        _ => {}
3688    }
3689}
3690
3691fn extract_tool_usage(provider: &str, stdout: &str, stderr: &str) -> Vec<ToolUsage> {
3692    let mut tools = Vec::new();
3693    for value in json_values(stdout) {
3694        collect_tool_usage(&value, &mut tools);
3695    }
3696    for line in stdout.lines().chain(stderr.lines()) {
3697        if let Some(tool) = line_tool_usage(provider, line) {
3698            tools.push(tool);
3699        }
3700    }
3701    dedupe_tools(tools)
3702}
3703
3704fn collect_tool_usage(value: &Value, tools: &mut Vec<ToolUsage>) {
3705    match value {
3706        Value::Object(map) => {
3707            let type_name = map.get("type").and_then(Value::as_str).unwrap_or_default();
3708            let name = map
3709                .get("name")
3710                .or_else(|| map.get("tool"))
3711                .or_else(|| map.get("tool_name"))
3712                .or_else(|| map.get("toolName"))
3713                .and_then(Value::as_str);
3714            if let Some(name) = name.filter(|_| {
3715                type_name.contains("tool")
3716                    || map.contains_key("input")
3717                    || map.contains_key("arguments")
3718                    || map.contains_key("toolCall")
3719            }) {
3720                tools.push(ToolUsage {
3721                    name: name.to_string(),
3722                    kind: if type_name.is_empty() {
3723                        "tool"
3724                    } else {
3725                        type_name
3726                    }
3727                    .to_string(),
3728                    status: "observed".to_string(),
3729                    detail: short_json_detail(value),
3730                });
3731            }
3732            for value in map.values() {
3733                collect_tool_usage(value, tools);
3734            }
3735        }
3736        Value::Array(values) => {
3737            for value in values {
3738                collect_tool_usage(value, tools);
3739            }
3740        }
3741        _ => {}
3742    }
3743}
3744
3745fn line_tool_usage(provider: &str, line: &str) -> Option<ToolUsage> {
3746    let trimmed = line.trim();
3747    let lowered = trimmed.to_ascii_lowercase();
3748    let marker = ["running shell:", "tool:", "tool_use", "mcp:", "command:"]
3749        .iter()
3750        .find(|marker| lowered.contains(**marker))?;
3751    Some(ToolUsage {
3752        name: marker.trim_end_matches(':').to_string(),
3753        kind: provider.to_string(),
3754        status: "observed".to_string(),
3755        detail: truncate(trimmed, 240),
3756    })
3757}
3758
3759fn short_json_detail(value: &Value) -> String {
3760    serde_json::to_string(value)
3761        .map(|text| truncate(&text, 240))
3762        .unwrap_or_default()
3763}
3764
3765fn dedupe_tools(tools: Vec<ToolUsage>) -> Vec<ToolUsage> {
3766    let mut seen = HashSet::new();
3767    tools
3768        .into_iter()
3769        .filter(|tool| seen.insert(format!("{}:{}:{}", tool.kind, tool.name, tool.detail)))
3770        .collect()
3771}
3772
3773fn json_values(text: &str) -> Vec<Value> {
3774    let trimmed = text.trim();
3775    if trimmed.is_empty() {
3776        return vec![];
3777    }
3778    let mut values = Vec::new();
3779    if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
3780        values.push(value);
3781        return values;
3782    }
3783    for line in trimmed.lines() {
3784        if let Ok(value) = serde_json::from_str::<Value>(line.trim()) {
3785            values.push(value);
3786        }
3787    }
3788    values
3789}
3790
3791fn estimate_tokens(text: &str) -> usize {
3792    if text.trim().is_empty() {
3793        0
3794    } else {
3795        text.len().div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN)
3796    }
3797}
3798
3799fn format_command(command: &str, args: &[String]) -> String {
3800    std::iter::once(command.to_string())
3801        .chain(args.iter().map(|arg| shell_quote(arg)))
3802        .collect::<Vec<_>>()
3803        .join(" ")
3804}
3805
3806fn shell_quote(value: &str) -> String {
3807    if value
3808        .chars()
3809        .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
3810    {
3811        value.to_string()
3812    } else {
3813        serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
3814    }
3815}
3816
3817fn truncate(text: &str, max_chars: usize) -> String {
3818    if text.chars().count() <= max_chars {
3819        text.to_string()
3820    } else {
3821        let mut truncated = text.chars().take(max_chars).collect::<String>();
3822        truncated.push_str("\n...[truncated]");
3823        truncated
3824    }
3825}
3826
3827fn should_show_banner(raw: &CliArgs) -> bool {
3828    !raw.no_banner && !raw.headless && !raw.json && !raw.json_stream && !raw.plain
3829}
3830
3831fn linear_delivery_requested(raw: &CliArgs) -> bool {
3832    raw.deliver_linear
3833        || raw.linear_until_complete
3834        || raw.linear_watch
3835        || !raw.linear_issue.is_empty()
3836        || raw
3837            .linear_query
3838            .as_ref()
3839            .is_some_and(|value| !value.trim().is_empty())
3840        || !raw.linear_project.is_empty()
3841        || !raw.linear_epic.is_empty()
3842}
3843
3844fn render_banner() -> &'static str {
3845    "    _                         _   _            \n   / \\   _ __ ___   ___  _ __ | | | | ___ _ __  \n  / _ \\ | '_ ` _ \\ / _ \\| '_ \\| |_| |/ _ \\ '_ \\ \n / ___ \\| | | | | | (_) | | | |  _  |  __/ | | |\n/_/   \\_\\_| |_| |_|\\___/|_| |_|_| |_|\\___|_| |_|"
3846}
3847
3848fn render_human_result(result: &AmonHenResult, verbose: bool) -> String {
3849    let mut lines = Vec::new();
3850    if verbose {
3851        lines.push(format!(
3852            "Amon Hen consulted: {}",
3853            result.members_requested.join(", ")
3854        ));
3855        for command in &result.prompt_commands {
3856            lines.push(format!(
3857                "cmd [{}] {} ({:.1}s)",
3858                command.status,
3859                command.command,
3860                command.duration_ms as f64 / 1000.0
3861            ));
3862        }
3863        for (index, member) in result.members.iter().enumerate() {
3864            lines.push(format!(
3865                "{}. [{}] {} ({:.1}s, tokens:{}, tools:{}, sub-agents:{}){}",
3866                index + 1,
3867                member.status,
3868                member.name,
3869                member.duration_ms as f64 / 1000.0,
3870                member.token_usage.total,
3871                member.tool_calls.len(),
3872                member.sub_agents.len(),
3873                if member.detail.is_empty() { "" } else { ": " }
3874            ));
3875            if !member.detail.is_empty() {
3876                lines.push(format!("   {}", member.detail));
3877            }
3878            for sub_agent in &member.sub_agents {
3879                lines.push(format!(
3880                    "   - {} [{}] tokens:{} tools:{}",
3881                    sub_agent.role,
3882                    sub_agent.status,
3883                    sub_agent.token_usage.total,
3884                    sub_agent.tool_calls.len()
3885                ));
3886            }
3887            if member.status == "ok" {
3888                lines.push(indent(&member.output, "   "));
3889            }
3890        }
3891        lines.push("----------- synthesis -----------".to_string());
3892    }
3893    if result.summary.status == "ok" {
3894        lines.push(result.summary.output.trim().to_string());
3895    } else {
3896        lines.push(format!("Synthesis failed: {}", result.summary.detail));
3897    }
3898    lines.join("\n")
3899}
3900
3901fn indent(text: &str, prefix: &str) -> String {
3902    text.lines()
3903        .map(|line| format!("{prefix}{line}"))
3904        .collect::<Vec<_>>()
3905        .join("\n")
3906}
3907
3908fn is_success(result: &AmonHenResult) -> bool {
3909    result.members.iter().any(|member| member.status == "ok") && result.summary.status == "ok"
3910}
3911
3912#[cfg(test)]
3913mod tests {
3914    use super::*;
3915    use std::sync::Mutex as TestMutex;
3916
3917    static ENV_LOCK: TestMutex<()> = TestMutex::new(());
3918
3919    struct ProviderEnvGuard {
3920        saved: Vec<(&'static str, Option<OsString>)>,
3921    }
3922
3923    impl ProviderEnvGuard {
3924        fn install_echo_bins() -> Option<Self> {
3925            let echo = Path::new("/bin/echo");
3926            if !echo.is_file() {
3927                return None;
3928            }
3929            let vars = [
3930                "AMON_HEN_CODEX_BIN",
3931                "AMON_HEN_CLAUDE_BIN",
3932                "AMON_HEN_GEMINI_BIN",
3933            ];
3934            let saved = vars
3935                .into_iter()
3936                .map(|name| (name, std::env::var_os(name)))
3937                .collect::<Vec<_>>();
3938            for (name, _) in &saved {
3939                unsafe {
3940                    std::env::set_var(name, echo);
3941                }
3942            }
3943            Some(Self { saved })
3944        }
3945    }
3946
3947    impl Drop for ProviderEnvGuard {
3948        fn drop(&mut self) {
3949            for (name, value) in &self.saved {
3950                unsafe {
3951                    if let Some(value) = value {
3952                        std::env::set_var(name, value);
3953                    } else {
3954                        std::env::remove_var(name);
3955                    }
3956                }
3957            }
3958        }
3959    }
3960
3961    #[test]
3962    fn help_flags_use_success_exit_code() {
3963        let long_help = CliArgs::try_parse_from(["amon-hen", "--help"]).unwrap_err();
3964        let short_help = CliArgs::try_parse_from(["amon-hen", "-h"]).unwrap_err();
3965
3966        assert_eq!(long_help.kind(), ErrorKind::DisplayHelp);
3967        assert_eq!(short_help.kind(), ErrorKind::DisplayHelp);
3968        assert_eq!(parse_error_exit_code(long_help.kind()), 0);
3969        assert_eq!(parse_error_exit_code(short_help.kind()), 0);
3970    }
3971
3972    #[test]
3973    fn rendered_help_is_grouped_and_compact() {
3974        let help = render_cli_help();
3975
3976        assert!(help.contains("Core run:"));
3977        assert!(help.contains("Models, effort, and permissions:"));
3978        assert!(help.contains("Auth and provider capabilities:"));
3979        assert!(help.contains("Linear delivery:"));
3980        assert!(help.contains("--members codex,claude,gemini"));
3981        assert!(help.contains("--claude-permission-mode MODE"));
3982        assert!(!help.contains("\n\n\n"));
3983    }
3984
3985    #[test]
3986    fn parses_members_and_provider_flags() {
3987        let args = CliArgs::try_parse_from([
3988            "amon-hen",
3989            "--members",
3990            "codex,claude",
3991            "--planner",
3992            "codex",
3993            "--lead",
3994            "claude",
3995            "--codex-effort",
3996            "xhigh",
3997            "--claude-capabilities",
3998            "override",
3999            "--claude-allowed-tools",
4000            "Read,Bash(git:*)",
4001            "--claude-tools",
4002            "Read,Edit",
4003            "--claude-agent",
4004            "reviewer",
4005            "--gemini-allowed-mcp-servers",
4006            "linear,github",
4007            "ship it",
4008        ])
4009        .unwrap();
4010        let resolved = resolve_args(args).unwrap();
4011        assert_eq!(resolved.members, vec!["codex", "claude"]);
4012        assert_eq!(resolved.raw.codex_effort.as_deref(), Some("xhigh"));
4013        assert_eq!(
4014            resolved.raw.claude_allowed_tools,
4015            vec!["Read", "Bash(git:*)"]
4016        );
4017        assert_eq!(resolved.raw.claude_tools, vec!["Read", "Edit"]);
4018        assert_eq!(resolved.raw.claude_agent.as_deref(), Some("reviewer"));
4019        assert_eq!(
4020            resolved.raw.gemini_allowed_mcp_servers,
4021            vec!["linear", "github"]
4022        );
4023    }
4024
4025    #[test]
4026    fn parses_historical_linear_flags_in_rust() {
4027        let args = CliArgs::try_parse_from([
4028            "amon-hen",
4029            "--banner",
4030            "--auth-open-browser",
4031            "--members",
4032            "codex,claude,gemini",
4033            "--linear-project",
4034            "ENG",
4035            "--linear-endpoint",
4036            "https://linear.example/graphql",
4037            "--linear-auth",
4038            "oauth",
4039            "--linear-max-polls",
4040            "2",
4041            "--linear-max-concurrency",
4042            "3",
4043            "--linear-workflow-file",
4044            "docs/linear-workflow.md",
4045            "--linear-workspace-strategy",
4046            "copy",
4047            "--linear-completion-gate",
4048            "review-or-ci",
4049            "--delivery-phases",
4050            "plan,implement,verify,ship",
4051            "deliver it",
4052        ])
4053        .unwrap();
4054        let resolved = resolve_args(args).unwrap();
4055
4056        assert!(linear_delivery_requested(&resolved.raw));
4057        assert_eq!(
4058            resolved.raw.linear_endpoint.as_deref(),
4059            Some("https://linear.example/graphql")
4060        );
4061        assert_eq!(resolved.raw.linear_auth, "oauth");
4062        assert_eq!(resolved.raw.linear_max_polls, Some(2));
4063        assert_eq!(resolved.raw.linear_max_concurrency, 3);
4064        assert_eq!(
4065            resolved.raw.linear_workflow_file.as_deref(),
4066            Some(Path::new("docs/linear-workflow.md"))
4067        );
4068        assert_eq!(resolved.raw.linear_workspace_strategy, "copy");
4069        assert_eq!(resolved.raw.linear_completion_gate, "review-or-ci");
4070        assert_eq!(
4071            resolved.raw.delivery_phases,
4072            vec!["plan", "implement", "verify", "ship"]
4073        );
4074    }
4075
4076    #[test]
4077    fn cli_role_flag_matrix_reaches_provider_options_and_prompts() {
4078        let args = CliArgs::try_parse_from([
4079            "amon-hen",
4080            "--members",
4081            "codex,claude,gemini",
4082            "--planner",
4083            "codex",
4084            "--lead",
4085            "claude",
4086            "--handoff",
4087            "--iterations",
4088            "3",
4089            "--team-work",
4090            "2",
4091            "--codex-sub-agents",
4092            "4",
4093            "--claude-sub-agents",
4094            "1",
4095            "--gemini-sub-agents",
4096            "0",
4097            "--codex-model",
4098            "gpt-5.2",
4099            "--codex-effort",
4100            "xhigh",
4101            "--codex-sandbox",
4102            "danger-full-access",
4103            "--codex-config",
4104            "sandbox_workspace_write.network_access=true",
4105            "--codex-mcp-profile",
4106            "repo",
4107            "--claude-model",
4108            "sonnet",
4109            "--claude-effort",
4110            "max",
4111            "--claude-permission-mode",
4112            "bypassPermissions",
4113            "--claude-allowed-tools",
4114            "Read,Edit",
4115            "--claude-tools",
4116            "Bash",
4117            "--claude-agent",
4118            "lead-reviewer",
4119            "--gemini-model",
4120            "gemini-2.5-pro",
4121            "--gemini-effort",
4122            "high",
4123            "--gemini-settings",
4124            "gemini-settings.json",
4125            "--gemini-tools-profile",
4126            "repo,ci",
4127            "--gemini-allowed-mcp-servers",
4128            "linear",
4129            "Fix the regression",
4130        ])
4131        .unwrap();
4132        let resolved = resolve_args(args).unwrap();
4133        let workflow = build_workflow(&resolved);
4134        let previous = vec![EngineResult {
4135            name: "gemini".to_string(),
4136            bin: Some("gemini".to_string()),
4137            status: "ok".to_string(),
4138            duration_ms: 1,
4139            detail: String::new(),
4140            exit_code: Some(0),
4141            stdout: String::new(),
4142            stderr: String::new(),
4143            output: "previous iteration context".to_string(),
4144            command: "gemini".to_string(),
4145            token_usage: token_usage("a", "b"),
4146            tool_calls: vec![],
4147            sub_agents: vec![],
4148            role: "executor".to_string(),
4149            iteration: 1,
4150            total_iterations: 3,
4151            team_size: 0,
4152        }];
4153        let handoff = vec![EngineResult {
4154            name: "codex".to_string(),
4155            bin: Some("codex".to_string()),
4156            status: "ok".to_string(),
4157            duration_ms: 1,
4158            detail: String::new(),
4159            exit_code: Some(0),
4160            stdout: String::new(),
4161            stderr: String::new(),
4162            output: "planner handoff context".to_string(),
4163            command: "codex".to_string(),
4164            token_usage: token_usage("a", "b"),
4165            tool_calls: vec![],
4166            sub_agents: vec![],
4167            role: "planner".to_string(),
4168            iteration: 2,
4169            total_iterations: 3,
4170            team_size: 4,
4171        }];
4172
4173        assert_eq!(workflow.iterations, 3);
4174        assert!(workflow.handoff);
4175        assert_eq!(workflow.team_work, 2);
4176        assert_eq!(workflow.teams.get("codex"), Some(&4));
4177        assert_eq!(workflow.teams.get("claude"), Some(&1));
4178        assert_eq!(workflow.teams.get("gemini"), Some(&0));
4179        assert_eq!(role_for("codex", &workflow), "planner");
4180        assert_eq!(role_for("claude", &workflow), "lead");
4181        assert_eq!(role_for("gemini", &workflow), "executor");
4182
4183        let codex_prompt = build_member_prompt(MemberPromptInput {
4184            query: "Fix the regression",
4185            role: "planner",
4186            workflow: &workflow,
4187            iteration: 2,
4188            team_size: effective_team_size(&workflow, "codex"),
4189            previous_iteration: &previous,
4190            handoff_results: &handoff,
4191            plan_output: "plan output",
4192        });
4193        assert!(codex_prompt.contains("Amon Hen workflow: iteration 2 of 3."));
4194        assert!(codex_prompt.contains("Lead model: claude."));
4195        assert!(codex_prompt.contains("Planner model: codex."));
4196        assert!(codex_prompt.contains("Your assigned role: planner."));
4197        assert!(codex_prompt.contains("Handoff mode is enabled."));
4198        assert!(codex_prompt.contains("Team work: you may coordinate up to 4 internal sub-agents"));
4199        assert!(codex_prompt.contains("Planner handoff:\nplan output"));
4200        assert!(codex_prompt.contains("previous iteration context"));
4201        assert!(codex_prompt.contains("planner handoff context"));
4202
4203        let codex_options = engine_options(
4204            &resolved,
4205            EngineOptionsInput {
4206                member: "codex",
4207                prompt: codex_prompt,
4208                role: "planner",
4209                iteration: 2,
4210                workflow: &workflow,
4211                progress: None,
4212                cancel: None,
4213            },
4214        );
4215        assert_eq!(codex_options.model.as_deref(), Some("gpt-5.2"));
4216        assert_eq!(codex_options.effort.as_deref(), Some("xhigh"));
4217        assert_eq!(
4218            codex_options.permission.as_deref(),
4219            Some("danger-full-access")
4220        );
4221        assert_eq!(codex_options.role, "planner");
4222        assert_eq!(codex_options.iteration, 2);
4223        assert_eq!(codex_options.total_iterations, 3);
4224        assert_eq!(codex_options.team_size, 4);
4225        assert_eq!(codex_options.capability.mode, CAPABILITY_OVERRIDE);
4226        assert_eq!(
4227            codex_options.capability.config,
4228            vec!["sandbox_workspace_write.network_access=true"]
4229        );
4230        assert_eq!(
4231            codex_options.capability.mcp_profile.as_deref(),
4232            Some("repo")
4233        );
4234
4235        let claude_options = engine_options(
4236            &resolved,
4237            EngineOptionsInput {
4238                member: "claude",
4239                prompt: build_member_prompt(MemberPromptInput {
4240                    query: "Fix the regression",
4241                    role: "lead",
4242                    workflow: &workflow,
4243                    iteration: 2,
4244                    team_size: effective_team_size(&workflow, "claude"),
4245                    previous_iteration: &[],
4246                    handoff_results: &[],
4247                    plan_output: "",
4248                }),
4249                role: "lead",
4250                iteration: 2,
4251                workflow: &workflow,
4252                progress: None,
4253                cancel: None,
4254            },
4255        );
4256        assert_eq!(claude_options.model.as_deref(), Some("sonnet"));
4257        assert_eq!(claude_options.effort.as_deref(), Some("max"));
4258        assert_eq!(
4259            claude_options.permission.as_deref(),
4260            Some("bypassPermissions")
4261        );
4262        assert_eq!(claude_options.role, "lead");
4263        assert_eq!(claude_options.team_size, 1);
4264        assert_eq!(claude_options.capability.mode, CAPABILITY_OVERRIDE);
4265        assert_eq!(
4266            claude_options.capability.allowed_tools,
4267            vec!["Read", "Edit"]
4268        );
4269        assert_eq!(claude_options.capability.tools, vec!["Bash"]);
4270        assert_eq!(
4271            claude_options.capability.agent.as_deref(),
4272            Some("lead-reviewer")
4273        );
4274
4275        let gemini_options = engine_options(
4276            &resolved,
4277            EngineOptionsInput {
4278                member: "gemini",
4279                prompt: build_member_prompt(MemberPromptInput {
4280                    query: "Fix the regression",
4281                    role: "executor",
4282                    workflow: &workflow,
4283                    iteration: 2,
4284                    team_size: effective_team_size(&workflow, "gemini"),
4285                    previous_iteration: &[],
4286                    handoff_results: &[],
4287                    plan_output: "",
4288                }),
4289                role: "executor",
4290                iteration: 2,
4291                workflow: &workflow,
4292                progress: None,
4293                cancel: None,
4294            },
4295        );
4296        assert_eq!(gemini_options.model.as_deref(), Some("gemini-2.5-pro"));
4297        assert_eq!(gemini_options.effort.as_deref(), Some("high"));
4298        assert_eq!(gemini_options.permission, None);
4299        assert_eq!(gemini_options.role, "executor");
4300        assert_eq!(gemini_options.team_size, 0);
4301        assert_eq!(gemini_options.capability.mode, CAPABILITY_OVERRIDE);
4302        assert_eq!(
4303            gemini_options.capability.settings.as_deref(),
4304            Some("gemini-settings.json")
4305        );
4306        assert_eq!(gemini_options.capability.tools_profile, vec!["repo", "ci"]);
4307        assert_eq!(
4308            gemini_options.capability.allowed_mcp_servers,
4309            vec!["linear"]
4310        );
4311    }
4312
4313    #[test]
4314    fn provider_command_args_include_role_matrix_settings() {
4315        let cwd = std::env::current_dir().unwrap();
4316        let codex_options = EngineRunOptions {
4317            prompt: "codex prompt".to_string(),
4318            cwd: cwd.clone(),
4319            timeout_ms: 1,
4320            effort: Some("high".to_string()),
4321            model: Some("gpt-5.2".to_string()),
4322            permission: Some("workspace-write".to_string()),
4323            auth: DEFAULT_AUTH_MODE.to_string(),
4324            capability: ProviderCapability {
4325                mode: CAPABILITY_OVERRIDE.to_string(),
4326                config: vec!["tools.web_search=true".to_string()],
4327                mcp_profile: Some("repo".to_string()),
4328                mcp_config: vec![],
4329                allowed_tools: vec![],
4330                disallowed_tools: vec![],
4331                tools: vec![],
4332                agent: None,
4333                agents_json: None,
4334                plugin_dirs: vec![],
4335                strict_mcp_config: false,
4336                disable_slash_commands: false,
4337                settings: None,
4338                tools_profile: vec![],
4339                allowed_mcp_servers: vec![],
4340                policy: vec![],
4341                admin_policy: vec![],
4342            },
4343            role: "planner".to_string(),
4344            iteration: 2,
4345            total_iterations: 3,
4346            team_size: 4,
4347            is_sub_agent: false,
4348            live: false,
4349            progress: None,
4350            cancel: None,
4351        };
4352        let codex = run_codex("definitely-missing-codex-test-bin", &codex_options);
4353        assert_eq!(codex.args[0], "exec");
4354        assert!(codex
4355            .args
4356            .windows(2)
4357            .any(|pair| pair == ["--model", "gpt-5.2"]));
4358        assert!(codex
4359            .args
4360            .windows(2)
4361            .any(|pair| pair == ["-c", "model_reasoning_effort=high"]));
4362        assert!(codex
4363            .args
4364            .windows(2)
4365            .any(|pair| pair == ["-c", "tools.web_search=true"]));
4366        assert!(codex
4367            .args
4368            .windows(2)
4369            .any(|pair| pair == ["--profile", "repo"]));
4370        assert!(codex
4371            .args
4372            .windows(2)
4373            .any(|pair| pair == ["--sandbox", "workspace-write"]));
4374
4375        let claude_options = EngineRunOptions {
4376            prompt: "claude prompt".to_string(),
4377            cwd: cwd.clone(),
4378            timeout_ms: 1,
4379            effort: Some("max".to_string()),
4380            model: Some("sonnet".to_string()),
4381            permission: Some("bypassPermissions".to_string()),
4382            auth: "social-login".to_string(),
4383            capability: ProviderCapability {
4384                mode: CAPABILITY_OVERRIDE.to_string(),
4385                config: vec![],
4386                mcp_profile: None,
4387                mcp_config: vec!["mcp.json".to_string()],
4388                allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
4389                disallowed_tools: vec!["WebFetch".to_string()],
4390                tools: vec!["Bash".to_string()],
4391                agent: Some("lead-reviewer".to_string()),
4392                agents_json: Some("agents.json".to_string()),
4393                plugin_dirs: vec!["plugins/a".to_string(), "plugins/b".to_string()],
4394                strict_mcp_config: true,
4395                disable_slash_commands: true,
4396                settings: None,
4397                tools_profile: vec![],
4398                allowed_mcp_servers: vec![],
4399                policy: vec![],
4400                admin_policy: vec![],
4401            },
4402            role: "lead".to_string(),
4403            iteration: 2,
4404            total_iterations: 3,
4405            team_size: 1,
4406            is_sub_agent: false,
4407            live: false,
4408            progress: None,
4409            cancel: None,
4410        };
4411        let claude = run_claude("definitely-missing-claude-test-bin", &claude_options);
4412        assert!(!claude.args.iter().any(|arg| arg == "--bare"));
4413        assert!(claude
4414            .args
4415            .windows(2)
4416            .any(|pair| pair == ["--permission-mode", "bypassPermissions"]));
4417        assert!(claude
4418            .args
4419            .windows(2)
4420            .any(|pair| pair == ["--model", "sonnet"]));
4421        assert!(claude
4422            .args
4423            .windows(2)
4424            .any(|pair| pair == ["--effort", "max"]));
4425        assert!(claude
4426            .args
4427            .windows(3)
4428            .any(|triple| triple == ["--allowedTools", "Read", "Edit"]));
4429        assert!(claude
4430            .args
4431            .windows(2)
4432            .any(|pair| pair == ["--agent", "lead-reviewer"]));
4433        assert!(claude.args.iter().any(|arg| arg == "--strict-mcp-config"));
4434        assert!(claude
4435            .args
4436            .iter()
4437            .any(|arg| arg == "--disable-slash-commands"));
4438
4439        let gemini_options = EngineRunOptions {
4440            prompt: "gemini prompt".to_string(),
4441            cwd,
4442            timeout_ms: 1,
4443            effort: Some("high".to_string()),
4444            model: Some("gemini-2.5-pro".to_string()),
4445            permission: None,
4446            auth: DEFAULT_AUTH_MODE.to_string(),
4447            capability: ProviderCapability {
4448                mode: CAPABILITY_OVERRIDE.to_string(),
4449                config: vec![],
4450                mcp_profile: None,
4451                mcp_config: vec![],
4452                allowed_tools: vec![],
4453                disallowed_tools: vec![],
4454                tools: vec![],
4455                agent: None,
4456                agents_json: None,
4457                plugin_dirs: vec![],
4458                strict_mcp_config: false,
4459                disable_slash_commands: false,
4460                settings: None,
4461                tools_profile: vec!["repo".to_string(), "ci".to_string()],
4462                allowed_mcp_servers: vec!["linear".to_string()],
4463                policy: vec!["default".to_string()],
4464                admin_policy: vec!["locked".to_string()],
4465            },
4466            role: "executor".to_string(),
4467            iteration: 2,
4468            total_iterations: 3,
4469            team_size: 0,
4470            is_sub_agent: false,
4471            live: false,
4472            progress: None,
4473            cancel: None,
4474        };
4475        let gemini = run_gemini("definitely-missing-gemini-test-bin", &gemini_options);
4476        assert!(gemini
4477            .args
4478            .windows(2)
4479            .any(|pair| pair == ["--model", "gemini-2.5-pro"]));
4480        assert!(gemini
4481            .args
4482            .windows(3)
4483            .any(|triple| triple == ["--extensions", "repo", "ci"]));
4484        assert!(gemini
4485            .args
4486            .windows(2)
4487            .any(|pair| pair == ["--allowed-mcp-server-names", "linear"]));
4488        assert!(gemini
4489            .args
4490            .windows(2)
4491            .any(|pair| pair == ["--policy", "default"]));
4492        assert!(gemini
4493            .args
4494            .windows(2)
4495            .any(|pair| pair == ["--admin-policy", "locked"]));
4496        assert!(gemini
4497            .args
4498            .windows(2)
4499            .any(|pair| pair == ["-p", "gemini prompt"]));
4500    }
4501
4502    #[test]
4503    fn builds_member_prompt_with_roles_and_handoff() {
4504        let workflow = Workflow {
4505            handoff: true,
4506            lead: Some("claude".to_string()),
4507            planner: Some("codex".to_string()),
4508            iterations: 2,
4509            team_work: 1,
4510            teams: HashMap::new(),
4511        };
4512        let prompt = build_member_prompt(MemberPromptInput {
4513            query: "Fix the bug",
4514            role: "planner",
4515            workflow: &workflow,
4516            iteration: 1,
4517            team_size: 1,
4518            previous_iteration: &[],
4519            handoff_results: &[],
4520            plan_output: "",
4521        });
4522        assert!(prompt.contains("Amon Hen workflow: iteration 1 of 2."));
4523        assert!(prompt.contains("Lead model: claude."));
4524        assert!(prompt.contains("Planner model: codex."));
4525        assert!(prompt.contains("Your assigned role: planner."));
4526        assert!(prompt.contains("Current user query:"));
4527    }
4528
4529    #[test]
4530    fn parses_claude_stream_json_result() {
4531        let text = r#"{"type":"system","subtype":"status","status":"requesting"}
4532{"type":"result","result":"done"}"#;
4533        assert_eq!(parse_claude_output(text), "done");
4534    }
4535
4536    #[test]
4537    fn parses_gemini_json_response() {
4538        assert_eq!(parse_gemini_output(r#"{"response":"hello"}"#), "hello");
4539    }
4540
4541    #[test]
4542    fn estimates_tokens_with_ceiling_chunks() {
4543        assert_eq!(estimate_tokens(""), 0);
4544        assert_eq!(estimate_tokens("abc"), 1);
4545        assert_eq!(estimate_tokens("abcd"), 1);
4546        assert_eq!(estimate_tokens("abcde"), 2);
4547    }
4548
4549    #[test]
4550    fn extracts_social_login_urls() {
4551        assert_eq!(
4552            extract_auth_urls("Open https://example.com/callback?code=123, then continue"),
4553            vec!["https://example.com/callback?code=123"]
4554        );
4555        assert_eq!(
4556            extract_auth_urls("deeplink: claude://login/complete."),
4557            vec!["claude://login/complete"]
4558        );
4559    }
4560
4561    #[test]
4562    fn parses_provider_token_usage() {
4563        let stdout = r#"{"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}"#;
4564        let usage = extract_token_usage(stdout, "hello", "world").unwrap();
4565        assert_eq!(usage.input, 12);
4566        assert_eq!(usage.output, 8);
4567        assert_eq!(usage.total, 20);
4568        assert!(!usage.estimated);
4569    }
4570
4571    #[test]
4572    fn extracts_tool_usage_from_provider_streams() {
4573        let stdout = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}"#;
4574        let tools = extract_tool_usage("claude", stdout, "");
4575        assert!(tools.iter().any(|tool| tool.name == "Bash"));
4576    }
4577
4578    #[test]
4579    fn builds_real_sub_agent_handoff_prompt() {
4580        let agent = EngineResult {
4581            name: "codex".to_string(),
4582            bin: Some("codex".to_string()),
4583            status: "ok".to_string(),
4584            duration_ms: 1,
4585            detail: String::new(),
4586            exit_code: Some(0),
4587            stdout: String::new(),
4588            stderr: String::new(),
4589            output: "inspect parser".to_string(),
4590            command: "codex exec".to_string(),
4591            token_usage: token_usage("a", "b"),
4592            tool_calls: vec![],
4593            sub_agents: vec![],
4594            role: "executor:sub-agent-1".to_string(),
4595            iteration: 1,
4596            total_iterations: 1,
4597            team_size: 0,
4598        };
4599        let prompt = build_team_lead_prompt("ship it", &[agent]);
4600        assert!(prompt.contains("Sub-agent handoffs"));
4601        assert!(prompt.contains("inspect parser"));
4602        assert!(prompt.contains("Original provider prompt"));
4603    }
4604
4605    #[test]
4606    fn json_stream_events_emit_progress_before_final_result() {
4607        let bus = RuntimeEventBus::new();
4608        let events = Arc::new(Mutex::new(Vec::new()));
4609        let captured = Arc::clone(&events);
4610        bus.add_sink(Arc::new(move |event| {
4611            captured.lock().unwrap().push(event);
4612        }));
4613        let progress = bus.progress_sink();
4614
4615        progress(progress_event_with_context(
4616            None,
4617            None,
4618            ProgressStage::Start,
4619            None,
4620            Some(1),
4621            Some(1),
4622            false,
4623            None,
4624            None,
4625            vec![],
4626            "[amon-hen] iteration 1/1 started",
4627        ));
4628        progress(progress_event_with_context(
4629            Some("codex"),
4630            Some("planner"),
4631            ProgressStage::Done,
4632            Some("ok"),
4633            Some(1),
4634            Some(1),
4635            false,
4636            Some(25),
4637            Some(token_usage("prompt", "answer")),
4638            vec![],
4639            "[amon-hen] done codex role=planner status=ok",
4640        ));
4641        let result = test_amon_hen_result(vec![test_engine_result("codex", "planner", 1)], 1);
4642        bus.emit_result(&result);
4643
4644        let events = events.lock().unwrap();
4645        assert!(events.len() >= 3);
4646        assert!(events[..events.len() - 1]
4647            .iter()
4648            .any(|event| event.progress.stage != ProgressStage::Done));
4649        assert!(events
4650            .iter()
4651            .any(|event| event.progress.kind == RuntimeEventKind::TokenUsage));
4652        assert_eq!(
4653            events.last().unwrap().progress.kind,
4654            RuntimeEventKind::Result
4655        );
4656        assert!(events.last().unwrap().result.is_some());
4657        for event in events.iter() {
4658            serde_json::to_string(event).expect("json-stream event should serialize");
4659        }
4660    }
4661
4662    #[test]
4663    fn runtime_event_bus_preserves_stream_sequence_order_under_fanout() {
4664        let bus = RuntimeEventBus::new();
4665        let events = Arc::new(Mutex::new(Vec::new()));
4666        let captured = Arc::clone(&events);
4667        bus.add_sink(Arc::new(move |event| {
4668            captured.lock().unwrap().push(event.sequence);
4669        }));
4670        let barrier = Arc::new(std::sync::Barrier::new(16));
4671        let mut handles = Vec::new();
4672
4673        for index in 0..16 {
4674            let progress = bus.progress_sink();
4675            let barrier = Arc::clone(&barrier);
4676            handles.push(thread::spawn(move || {
4677                barrier.wait();
4678                progress(progress_event(
4679                    Some("codex"),
4680                    Some("planner"),
4681                    ProgressStage::Heartbeat,
4682                    Some("running"),
4683                    format!("[amon-hen] fanout event {index}"),
4684                ));
4685            }));
4686        }
4687        for handle in handles {
4688            handle.join().unwrap();
4689        }
4690
4691        let sequences = events.lock().unwrap().clone();
4692        assert_eq!(sequences, (1..=16).collect::<Vec<_>>());
4693    }
4694
4695    #[test]
4696    fn multiple_iterations_preserve_timeline_entries() {
4697        let first = vec![test_engine_result("codex", "planner", 1)];
4698        let second = vec![test_engine_result("claude", "lead", 2)];
4699        let timeline = vec![
4700            iteration_record(1, 2, first.clone(), 10, None, None),
4701            iteration_record(
4702                2,
4703                2,
4704                second.clone(),
4705                15,
4706                iteration_handoff_context(&first, DEFAULT_MAX_MEMBER_CHARS),
4707                Some("summary context".to_string()),
4708            ),
4709        ];
4710        let result = test_amon_hen_result_with_iterations(second, timeline);
4711
4712        assert_eq!(result.iterations.len(), 2);
4713        assert_eq!(result.iterations[0].iteration, 1);
4714        assert_eq!(result.iterations[1].iteration, 2);
4715        assert!(result.iterations[1].handoff_context.is_some());
4716        assert_eq!(result.members[0].name, "claude");
4717    }
4718
4719    #[test]
4720    fn sub_agent_roles_remain_correct_in_timeline() {
4721        let mut member = test_engine_result("gemini", "lead", 1);
4722        member.sub_agents = vec![
4723            test_engine_result("gemini", "lead:sub-agent-1", 1),
4724            test_engine_result("gemini", "lead:sub-agent-2", 1),
4725        ];
4726        let timeline = vec![iteration_record(1, 1, vec![member], 20, None, None)];
4727
4728        assert_eq!(
4729            timeline[0]
4730                .sub_agents
4731                .iter()
4732                .map(|agent| agent.role.as_str())
4733                .collect::<Vec<_>>(),
4734            vec!["lead:sub-agent-1", "lead:sub-agent-2"]
4735        );
4736        assert_eq!(timeline[0].members[0].role, "lead");
4737    }
4738
4739    #[test]
4740    fn runtime_role_matrix_reaches_json_result_and_stream_events() {
4741        let _lock = ENV_LOCK.lock().unwrap();
4742        let Some(_env) = ProviderEnvGuard::install_echo_bins() else {
4743            return;
4744        };
4745        let args = CliArgs::try_parse_from([
4746            "amon-hen",
4747            "--members",
4748            "codex,claude,gemini",
4749            "--planner",
4750            "codex",
4751            "--lead",
4752            "claude",
4753            "--handoff",
4754            "--iterations",
4755            "2",
4756            "--team-work",
4757            "1",
4758            "--codex-sub-agents",
4759            "2",
4760            "--claude-sub-agents",
4761            "1",
4762            "--gemini-sub-agents",
4763            "0",
4764            "--timeout",
4765            "5",
4766            "role matrix smoke",
4767        ])
4768        .unwrap();
4769        let resolved = resolve_args(args).unwrap();
4770        let bus = RuntimeEventBus::new();
4771        let events = Arc::new(Mutex::new(Vec::new()));
4772        let captured = Arc::clone(&events);
4773        bus.add_sink(Arc::new(move |event| {
4774            captured.lock().unwrap().push(event);
4775        }));
4776        let prompt_context =
4777            build_prompt_context_with_progress(&resolved, Some(bus.progress_sink())).unwrap();
4778        let result = run_amon_hen_with_progress_and_cancel(
4779            &resolved,
4780            prompt_context.prompt,
4781            prompt_context.commands,
4782            Some(bus.progress_sink()),
4783            None,
4784        );
4785        bus.emit_result(&result);
4786
4787        assert_eq!(result.workflow.iterations, 2);
4788        assert_eq!(result.workflow.teams.get("codex"), Some(&2));
4789        assert_eq!(result.workflow.teams.get("claude"), Some(&1));
4790        assert_eq!(result.workflow.teams.get("gemini"), Some(&0));
4791        assert_eq!(result.iterations.len(), 2);
4792        for iteration in &result.iterations {
4793            assert_eq!(
4794                iteration
4795                    .members
4796                    .iter()
4797                    .map(|member| (member.name.as_str(), member.role.as_str()))
4798                    .collect::<Vec<_>>(),
4799                vec![
4800                    ("codex", "planner"),
4801                    ("gemini", "executor"),
4802                    ("claude", "lead")
4803                ]
4804            );
4805            assert_eq!(
4806                iteration
4807                    .sub_agents
4808                    .iter()
4809                    .map(|agent| (agent.name.as_str(), agent.role.as_str()))
4810                    .collect::<Vec<_>>(),
4811                vec![
4812                    ("codex", "planner:sub-agent-1"),
4813                    ("codex", "planner:sub-agent-2"),
4814                    ("claude", "lead:sub-agent-1")
4815                ]
4816            );
4817        }
4818
4819        let events = events.lock().unwrap();
4820        assert!(events.iter().any(|event| {
4821            event.progress.provider.as_deref() == Some("codex")
4822                && event.progress.role.as_deref() == Some("planner")
4823                && !event.progress.is_sub_agent
4824        }));
4825        assert!(events.iter().any(|event| {
4826            event.progress.provider.as_deref() == Some("claude")
4827                && event.progress.role.as_deref() == Some("lead:sub-agent-1")
4828                && event.progress.is_sub_agent
4829        }));
4830        let final_event = events.last().expect("final result event should exist");
4831        assert_eq!(final_event.progress.kind, RuntimeEventKind::Result);
4832        assert!(final_event.result.is_some());
4833    }
4834
4835    #[test]
4836    fn per_provider_sub_agent_overrides_are_reflected_in_prompts() {
4837        let args = CliArgs::try_parse_from([
4838            "amon-hen",
4839            "--members",
4840            "codex,gemini",
4841            "--team-work",
4842            "2",
4843            "--codex-sub-agents",
4844            "0",
4845            "--gemini-sub-agents",
4846            "3",
4847            "ship",
4848        ])
4849        .unwrap();
4850        let resolved = resolve_args(args).unwrap();
4851        let workflow = build_workflow(&resolved);
4852        let codex_prompt = build_member_prompt(MemberPromptInput {
4853            query: "ship",
4854            role: "executor",
4855            workflow: &workflow,
4856            iteration: 1,
4857            team_size: effective_team_size(&workflow, "codex"),
4858            previous_iteration: &[],
4859            handoff_results: &[],
4860            plan_output: "",
4861        });
4862        let gemini_prompt = build_member_prompt(MemberPromptInput {
4863            query: "ship",
4864            role: "executor",
4865            workflow: &workflow,
4866            iteration: 1,
4867            team_size: effective_team_size(&workflow, "gemini"),
4868            previous_iteration: &[],
4869            handoff_results: &[],
4870            plan_output: "",
4871        });
4872
4873        assert!(!codex_prompt.contains("Team work:"));
4874        assert!(gemini_prompt.contains("up to 3 internal sub-agents"));
4875    }
4876
4877    #[test]
4878    fn duplicate_members_are_deduped_in_input_order() {
4879        let args = CliArgs::try_parse_from([
4880            "amon-hen",
4881            "--members",
4882            "codex,claude,codex,gemini,claude",
4883            "hello",
4884        ])
4885        .unwrap();
4886        let resolved = resolve_args(args).unwrap();
4887        assert_eq!(resolved.members, vec!["codex", "claude", "gemini"]);
4888    }
4889
4890    #[test]
4891    fn command_cancel_stops_child_process_promptly() {
4892        if !command_available("sh") {
4893            return;
4894        }
4895        let cwd = std::env::current_dir().unwrap();
4896        let cancel = Arc::new(AtomicBool::new(true));
4897        let started = Instant::now();
4898        let args = ["-c".to_string(), "sleep 30".to_string()];
4899        let result =
4900            run_command(CommandRequest::new("sh", &args, &cwd, 30_000).cancel(Some(cancel)));
4901
4902        assert!(result.cancelled);
4903        assert_eq!(result.error.as_deref(), Some("cancelled"));
4904        assert!(
4905            started.elapsed() < Duration::from_secs(3),
4906            "cancelled command took {:?}",
4907            started.elapsed()
4908        );
4909    }
4910
4911    #[test]
4912    fn missing_provider_preflight_avoids_team_fanout() {
4913        let args = CliArgs::try_parse_from([
4914            "amon-hen",
4915            "--members",
4916            "codex",
4917            "--team-work",
4918            "5",
4919            "hello",
4920        ])
4921        .unwrap();
4922        let resolved = resolve_args(args).unwrap();
4923        let options = EngineRunOptions {
4924            prompt: "hello".to_string(),
4925            cwd: resolved.cwd.clone(),
4926            timeout_ms: 1_000,
4927            effort: None,
4928            model: None,
4929            permission: None,
4930            auth: DEFAULT_AUTH_MODE.to_string(),
4931            capability: provider_capability(&resolved, "codex"),
4932            role: "planner".to_string(),
4933            iteration: 1,
4934            total_iterations: 1,
4935            team_size: 5,
4936            is_sub_agent: false,
4937            live: false,
4938            progress: None,
4939            cancel: None,
4940        };
4941
4942        let result = run_engine("definitely-missing-amon-hen-test-bin", options);
4943
4944        assert_eq!(result.status, "missing");
4945        assert!(result.sub_agents.is_empty());
4946        assert!(result.detail.contains("not found in PATH"));
4947    }
4948
4949    #[test]
4950    fn system_time_is_available_for_test_environment() {
4951        assert!(std::time::SystemTime::now()
4952            .duration_since(std::time::UNIX_EPOCH)
4953            .is_ok());
4954    }
4955
4956    #[test]
4957    fn cli_hygiene_has_no_legacy_npm_cli_or_council_binary_references() {
4958        let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
4959        let repo_root = crate_dir
4960            .parent()
4961            .and_then(Path::parent)
4962            .expect("crate lives under crates/amon-hen");
4963        assert!(!repo_root.join("package.json").exists());
4964        assert!(!repo_root.join("package-lock.json").exists());
4965        assert!(!repo_root.join("npm").exists());
4966        assert!(!repo_root.join("bin").join("council").exists());
4967
4968        let docs = [
4969            repo_root.join("README.md"),
4970            crate_dir.join("README.md"),
4971            crate_dir.join("CHANGELOG.md"),
4972        ];
4973        for path in docs {
4974            let text = fs::read_to_string(&path).unwrap();
4975            assert!(
4976                !text.contains("`council`") && !text.contains(" council "),
4977                "legacy binary reference remains in {}",
4978                path.display()
4979            );
4980        }
4981
4982        let help = render_cli_help();
4983        assert!(!help.contains("council"));
4984        assert!(help.contains("amon-hen"));
4985    }
4986
4987    fn test_amon_hen_result(members: Vec<EngineResult>, total_iterations: usize) -> AmonHenResult {
4988        let timeline = vec![iteration_record(
4989            total_iterations,
4990            total_iterations,
4991            members.clone(),
4992            25,
4993            None,
4994            Some("summary context".to_string()),
4995        )];
4996        test_amon_hen_result_with_iterations(members, timeline)
4997    }
4998
4999    fn test_amon_hen_result_with_iterations(
5000        members: Vec<EngineResult>,
5001        iterations: Vec<IterationRecord>,
5002    ) -> AmonHenResult {
5003        let mut teams = HashMap::new();
5004        teams.insert("codex".to_string(), 0);
5005        teams.insert("claude".to_string(), 0);
5006        teams.insert("gemini".to_string(), 0);
5007        let workflow = Workflow {
5008            handoff: true,
5009            lead: Some("claude".to_string()),
5010            planner: Some("codex".to_string()),
5011            iterations: iterations.len().max(1),
5012            team_work: 0,
5013            teams,
5014        };
5015        AmonHenResult {
5016            query: "ship it".to_string(),
5017            cwd: ".".to_string(),
5018            members_requested: members.iter().map(|member| member.name.clone()).collect(),
5019            summarizer_requested: "auto".to_string(),
5020            workflow,
5021            prompt_commands: vec![],
5022            iterations,
5023            members,
5024            summary: test_engine_result("codex", "summary", 1),
5025        }
5026    }
5027
5028    fn test_engine_result(name: &str, role: &str, iteration: usize) -> EngineResult {
5029        EngineResult {
5030            name: name.to_string(),
5031            bin: Some(name.to_string()),
5032            status: "ok".to_string(),
5033            duration_ms: 10,
5034            detail: String::new(),
5035            exit_code: Some(0),
5036            stdout: String::new(),
5037            stderr: String::new(),
5038            output: format!("{name} output"),
5039            command: format!("{name} exec"),
5040            token_usage: token_usage("prompt", "output"),
5041            tool_calls: vec![ToolUsage {
5042                name: "tool".to_string(),
5043                kind: name.to_string(),
5044                status: "observed".to_string(),
5045                detail: "detail".to_string(),
5046            }],
5047            sub_agents: vec![],
5048            role: role.to_string(),
5049            iteration,
5050            total_iterations: iteration.max(1),
5051            team_size: 0,
5052        }
5053    }
5054}