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