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