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