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