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        let mut stream_state = ProviderStreamState::new();
3326        loop {
3327            let read = match pipe.read(&mut buffer) {
3328                Ok(0) => break,
3329                Ok(read) => read,
3330                Err(_) => break,
3331            };
3332            let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
3333            text.push_str(&chunk);
3334            pending.push_str(&chunk);
3335            while let Some(newline) = pending.find('\n') {
3336                let line = pending[..newline].trim_end_matches('\r').to_string();
3337                pending = pending[newline + 1..].to_string();
3338                if stream_state.maybe_emit_assistant_delta(
3339                    &progress,
3340                    stream,
3341                    &line,
3342                    &text,
3343                    &mut last_stream_event,
3344                ) {
3345                    continue;
3346                }
3347                maybe_emit_provider_stream_line(
3348                    &progress,
3349                    stream,
3350                    &line,
3351                    &text,
3352                    &mut last_stream_event,
3353                );
3354            }
3355            if !chunk.contains('\n') {
3356                maybe_emit_provider_stream_chunk(
3357                    &progress,
3358                    stream,
3359                    &chunk,
3360                    &text,
3361                    &mut last_stream_event,
3362                );
3363            }
3364        }
3365        stream_state.flush(&progress, stream, &text);
3366        if !pending.trim().is_empty() {
3367            emit_provider_stream_line(&progress, stream, &pending, &text);
3368        }
3369        text
3370    })
3371}
3372
3373struct ProviderStreamState {
3374    assistant_text: String,
3375    emitted_assistant_chars: usize,
3376}
3377
3378impl ProviderStreamState {
3379    fn new() -> Self {
3380        Self {
3381            assistant_text: String::new(),
3382            emitted_assistant_chars: 0,
3383        }
3384    }
3385
3386    fn maybe_emit_assistant_delta(
3387        &mut self,
3388        progress: &Option<CommandProgress>,
3389        stream: &str,
3390        line: &str,
3391        accumulated: &str,
3392        last_stream_event: &mut Instant,
3393    ) -> bool {
3394        let Some(progress) = progress else {
3395            return false;
3396        };
3397        let Some(provider) = provider_from_live_label(&progress.label) else {
3398            return false;
3399        };
3400        let Some(delta) = provider_assistant_text_delta(provider, line) else {
3401            return false;
3402        };
3403        if delta.is_empty() {
3404            return true;
3405        }
3406        self.assistant_text.push_str(&delta);
3407        let now = Instant::now();
3408        let chars_since_emit = self
3409            .assistant_text
3410            .chars()
3411            .count()
3412            .saturating_sub(self.emitted_assistant_chars);
3413        if chars_since_emit >= 96
3414            || now.duration_since(*last_stream_event) >= PROVIDER_STREAM_EVENT_MIN_INTERVAL
3415        {
3416            self.emit_snapshot(progress, stream, accumulated);
3417            *last_stream_event = now;
3418        }
3419        true
3420    }
3421
3422    fn flush(&mut self, progress: &Option<CommandProgress>, stream: &str, accumulated: &str) {
3423        let Some(progress) = progress else {
3424            return;
3425        };
3426        if self.assistant_text.chars().count() > self.emitted_assistant_chars {
3427            self.emit_snapshot(progress, stream, accumulated);
3428        }
3429    }
3430
3431    fn emit_snapshot(&mut self, progress: &CommandProgress, stream: &str, accumulated: &str) {
3432        let visible = format!(
3433            "assistant live: {}",
3434            sanitize_status_detail(&tail_chars(&self.assistant_text, 900))
3435        );
3436        emit_provider_stream_visible(progress, stream, accumulated, &visible, Vec::new());
3437        self.emitted_assistant_chars = self.assistant_text.chars().count();
3438    }
3439}
3440
3441fn maybe_emit_provider_stream_chunk(
3442    progress: &Option<CommandProgress>,
3443    stream: &str,
3444    chunk: &str,
3445    accumulated: &str,
3446    last_stream_event: &mut Instant,
3447) {
3448    let clipped = chunk.trim();
3449    if clipped.len() >= 24 {
3450        maybe_emit_provider_stream_line(progress, stream, clipped, accumulated, last_stream_event);
3451    }
3452}
3453
3454fn maybe_emit_provider_stream_line(
3455    progress: &Option<CommandProgress>,
3456    stream: &str,
3457    line: &str,
3458    accumulated: &str,
3459    last_stream_event: &mut Instant,
3460) {
3461    let now = Instant::now();
3462    let important = provider_stream_line_is_important(line);
3463    if important || now.duration_since(*last_stream_event) >= PROVIDER_STREAM_EVENT_MIN_INTERVAL {
3464        emit_provider_stream_line(progress, stream, line, accumulated);
3465        *last_stream_event = now;
3466    }
3467}
3468
3469fn provider_stream_line_is_important(line: &str) -> bool {
3470    let line = line.to_ascii_lowercase();
3471    [
3472        "tool_use",
3473        "tool_call",
3474        "functioncall",
3475        "function_call",
3476        "command_execution",
3477        "item.completed",
3478        "\"result\"",
3479        "\"error\"",
3480        "error",
3481        "failed",
3482        "permission",
3483    ]
3484    .iter()
3485    .any(|needle| line.contains(needle))
3486}
3487
3488fn emit_provider_stream_line(
3489    progress: &Option<CommandProgress>,
3490    stream: &str,
3491    line: &str,
3492    accumulated: &str,
3493) {
3494    let Some(progress) = progress else {
3495        return;
3496    };
3497    let line = line.trim();
3498    if line.is_empty() {
3499        return;
3500    }
3501    let provider = provider_from_live_label(&progress.label);
3502    let tool_calls = provider
3503        .map(|provider| extract_tool_usage(provider, line, ""))
3504        .unwrap_or_default();
3505    let visible = match provider_visible_stream(provider, line, &tool_calls) {
3506        StreamDisplay::Visible(visible) => visible,
3507        StreamDisplay::Suppress => return,
3508    };
3509    emit_provider_stream_visible(progress, stream, accumulated, &visible, tool_calls);
3510}
3511
3512fn emit_provider_stream_visible(
3513    progress: &CommandProgress,
3514    stream: &str,
3515    accumulated: &str,
3516    visible: &str,
3517    tool_calls: Vec<ToolUsage>,
3518) {
3519    let provider = provider_from_live_label(&progress.label);
3520    let role = role_from_live_label(&progress.label);
3521    let output_tokens = estimate_tokens(accumulated);
3522    let token_usage = provider.map(|_| TokenUsage {
3523        input: progress.input_tokens,
3524        output: output_tokens,
3525        total: progress.input_tokens + output_tokens,
3526        estimated: true,
3527        source: "live-stream-estimate".to_string(),
3528    });
3529    emit_runtime_event(
3530        &progress.sink,
3531        progress.sink.is_none(),
3532        progress_event_with_context(
3533            provider,
3534            role,
3535            ProgressStage::Heartbeat,
3536            Some("streaming"),
3537            iteration_from_live_label(&progress.label),
3538            total_iterations_from_live_label(&progress.label),
3539            role.is_some_and(|role| role.contains(":sub-agent-")),
3540            None,
3541            token_usage,
3542            tool_calls,
3543            format!(
3544                "[amon-hen] stream {} {stream}: {}",
3545                progress.label,
3546                truncate(visible, 220)
3547            ),
3548        ),
3549    );
3550}
3551
3552#[derive(Debug, Eq, PartialEq)]
3553enum StreamDisplay {
3554    Visible(String),
3555    Suppress,
3556}
3557
3558fn provider_visible_stream(
3559    provider: Option<&str>,
3560    line: &str,
3561    tool_calls: &[ToolUsage],
3562) -> StreamDisplay {
3563    let Some(provider) = provider else {
3564        return StreamDisplay::Visible(sanitize_status_detail(line));
3565    };
3566    let Ok(value) = serde_json::from_str::<Value>(line) else {
3567        if looks_like_json_line(line) {
3568            return StreamDisplay::Suppress;
3569        }
3570        return StreamDisplay::Visible(sanitize_status_detail(line));
3571    };
3572    let visible = match provider {
3573        "claude" => claude_visible_stream(&value),
3574        "gemini" => gemini_visible_stream(&value),
3575        "codex" => codex_visible_stream(&value),
3576        _ => None,
3577    }
3578    .or_else(|| tool_calls.first().map(visible_tool_summary));
3579    visible
3580        .filter(|text| !text.trim().is_empty())
3581        .map(StreamDisplay::Visible)
3582        .unwrap_or(StreamDisplay::Suppress)
3583}
3584
3585fn provider_assistant_text_delta(provider: &str, line: &str) -> Option<String> {
3586    let value = serde_json::from_str::<Value>(line).ok()?;
3587    match provider {
3588        "claude" => claude_assistant_text_delta(&value),
3589        _ => None,
3590    }
3591}
3592
3593fn claude_assistant_text_delta(value: &Value) -> Option<String> {
3594    if value.get("type").and_then(Value::as_str) == Some("stream_event") {
3595        return value.get("event").and_then(claude_assistant_text_delta);
3596    }
3597    match value.get("type").and_then(Value::as_str) {
3598        Some("content_block_delta") => {
3599            let delta = value.get("delta")?;
3600            if delta.get("type").and_then(Value::as_str) == Some("text_delta") {
3601                return delta
3602                    .get("text")
3603                    .and_then(Value::as_str)
3604                    .map(str::to_string);
3605            }
3606        }
3607        Some("message_delta" | "message_start" | "message_stop" | "content_block_stop") => {}
3608        _ => {
3609            if let Some(text) = value.pointer("/delta/text").and_then(Value::as_str) {
3610                return Some(text.to_string());
3611            }
3612        }
3613    }
3614    None
3615}
3616
3617fn tail_chars(text: &str, max_chars: usize) -> String {
3618    let total = text.chars().count();
3619    if total <= max_chars {
3620        return text.to_string();
3621    }
3622    let tail = text
3623        .chars()
3624        .skip(total.saturating_sub(max_chars))
3625        .collect::<String>();
3626    format!("...{tail}")
3627}
3628
3629fn visible_tool_summary(tool: &ToolUsage) -> String {
3630    let detail = sanitize_status_detail(&tool.detail);
3631    if detail.is_empty() {
3632        format!("tool {}: {}", tool.status, tool.name)
3633    } else {
3634        format!("tool {}: {} {}", tool.status, tool.name, detail)
3635    }
3636}
3637
3638fn claude_visible_stream(value: &Value) -> Option<String> {
3639    if value.get("type").and_then(Value::as_str) == Some("stream_event") {
3640        return value.get("event").and_then(claude_visible_stream);
3641    }
3642    match value.get("type").and_then(Value::as_str) {
3643        Some("content_block_delta") => return claude_visible_delta(value.get("delta")?),
3644        Some("content_block_start") => {
3645            return value
3646                .get("content_block")
3647                .and_then(visible_content_block)
3648                .map(|text| sanitize_status_detail(&text));
3649        }
3650        Some("content_block_stop" | "message_start" | "message_delta" | "message_stop") => {
3651            return None;
3652        }
3653        _ => {}
3654    }
3655    if let Some(result) = non_empty_str(value.get("result")) {
3656        return Some(format!("result: {}", sanitize_status_detail(result)));
3657    }
3658    if let Some(content) = value.pointer("/message/content").and_then(Value::as_array) {
3659        if let Some(text) = visible_content_blocks(content) {
3660            return Some(text);
3661        }
3662    }
3663    if let Some(delta) = value.pointer("/delta/text").and_then(Value::as_str) {
3664        return Some(format!("assistant: {}", sanitize_status_detail(delta)));
3665    }
3666    if let Some(block) = value.pointer("/content_block") {
3667        if let Some(text) = visible_content_block(block) {
3668            return Some(text);
3669        }
3670    }
3671    if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
3672        return Some(format!("error: {}", sanitize_status_detail(error)));
3673    }
3674    None
3675}
3676
3677fn claude_visible_delta(delta: &Value) -> Option<String> {
3678    match delta.get("type").and_then(Value::as_str) {
3679        Some("text_delta") => delta
3680            .get("text")
3681            .and_then(Value::as_str)
3682            .filter(|text| !text.trim().is_empty())
3683            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
3684        Some("input_json_delta" | "thinking_delta" | "signature_delta") => None,
3685        _ => delta
3686            .get("text")
3687            .and_then(Value::as_str)
3688            .filter(|text| !text.trim().is_empty())
3689            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
3690    }
3691}
3692
3693fn claude_output_text(value: &Value) -> Option<String> {
3694    if value.get("type").and_then(Value::as_str) == Some("stream_event") {
3695        return value.get("event").and_then(claude_output_text);
3696    }
3697    match value.get("type").and_then(Value::as_str) {
3698        Some("content_block_delta") => {
3699            let delta = value.get("delta")?;
3700            return match delta.get("type").and_then(Value::as_str) {
3701                Some("text_delta") => non_empty_str(delta.get("text")).map(str::to_string),
3702                _ => None,
3703            };
3704        }
3705        Some("content_block_start") => {
3706            let block = value.get("content_block")?;
3707            if matches!(
3708                block.get("type").and_then(Value::as_str),
3709                Some("text" | "output_text")
3710            ) {
3711                return non_empty_str(block.get("text")).map(str::to_string);
3712            }
3713        }
3714        _ => {}
3715    }
3716    if let Some(result) = non_empty_str(value.get("result")) {
3717        return Some(result.to_string());
3718    }
3719    value
3720        .pointer("/message/content")
3721        .and_then(Value::as_array)
3722        .map(|content| {
3723            content
3724                .iter()
3725                .filter_map(|block| {
3726                    matches!(
3727                        block.get("type").and_then(Value::as_str),
3728                        Some("text" | "output_text")
3729                    )
3730                    .then(|| non_empty_str(block.get("text")))
3731                    .flatten()
3732                })
3733                .collect::<String>()
3734        })
3735        .filter(|text| !text.trim().is_empty())
3736}
3737
3738fn gemini_visible_stream(value: &Value) -> Option<String> {
3739    if let Some(response) = non_empty_str(value.get("response")) {
3740        return Some(format!("assistant: {}", sanitize_status_detail(response)));
3741    }
3742    if let Some(text) = non_empty_str(value.get("text")) {
3743        return Some(format!("assistant: {}", sanitize_status_detail(text)));
3744    }
3745    if let Some(text) = value
3746        .pointer("/candidates/0/content/parts/0/text")
3747        .and_then(Value::as_str)
3748        .filter(|text| !text.trim().is_empty())
3749    {
3750        return Some(format!("assistant: {}", sanitize_status_detail(text)));
3751    }
3752    if let Some(call) = value
3753        .pointer("/functionCall")
3754        .or_else(|| value.pointer("/candidates/0/content/parts/0/functionCall"))
3755    {
3756        return Some(format!("tool: {}", function_call_summary(call)));
3757    }
3758    if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
3759        return Some(format!("error: {}", sanitize_status_detail(error)));
3760    }
3761    None
3762}
3763
3764fn codex_visible_stream(value: &Value) -> Option<String> {
3765    if let Some(message) = non_empty_str(value.get("message")) {
3766        return Some(format!("assistant: {}", sanitize_status_detail(message)));
3767    }
3768    if let Some(text) = non_empty_str(value.get("text")) {
3769        return Some(format!("assistant: {}", sanitize_status_detail(text)));
3770    }
3771    if let Some(item) = value.get("item") {
3772        return codex_visible_item(item);
3773    }
3774    if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
3775        return Some(format!("error: {}", sanitize_status_detail(error)));
3776    }
3777    None
3778}
3779
3780fn codex_visible_item(item: &Value) -> Option<String> {
3781    let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
3782    match item_type {
3783        "message" => codex_message_text(item),
3784        "command_execution" => {
3785            let command = item
3786                .get("command")
3787                .and_then(Value::as_str)
3788                .map(sanitize_status_detail)
3789                .unwrap_or_else(|| "command".to_string());
3790            let output = item
3791                .get("aggregated_output")
3792                .or_else(|| item.get("output"))
3793                .and_then(Value::as_str)
3794                .map(sanitize_status_detail)
3795                .unwrap_or_default();
3796            if output.trim().is_empty() {
3797                Some(format!("tool: shell {command}"))
3798            } else {
3799                Some(format!(
3800                    "tool: shell {command} -> {}",
3801                    truncate(&output, 140)
3802                ))
3803            }
3804        }
3805        "function_call" | "tool_call" => Some(format!("tool: {}", function_call_summary(item))),
3806        "error" => item
3807            .get("message")
3808            .and_then(Value::as_str)
3809            .map(|message| format!("error: {}", sanitize_status_detail(message))),
3810        "reasoning" => item
3811            .get("summary")
3812            .and_then(Value::as_str)
3813            .filter(|summary| !summary.trim().is_empty())
3814            .map(|summary| format!("reasoning summary: {}", sanitize_status_detail(summary))),
3815        _ => None,
3816    }
3817}
3818
3819fn codex_message_text(item: &Value) -> Option<String> {
3820    if let Some(text) = non_empty_str(item.get("text")) {
3821        return Some(sanitize_status_detail(text));
3822    }
3823    item.get("content")
3824        .and_then(Value::as_array)
3825        .and_then(|blocks| visible_content_blocks(blocks))
3826}
3827
3828fn visible_content_blocks(blocks: &[Value]) -> Option<String> {
3829    let parts = blocks
3830        .iter()
3831        .filter_map(visible_content_block)
3832        .collect::<Vec<_>>();
3833    (!parts.is_empty()).then(|| parts.join(" | "))
3834}
3835
3836fn visible_content_block(block: &Value) -> Option<String> {
3837    match block.get("type").and_then(Value::as_str) {
3838        Some("text") | Some("output_text") => non_empty_str(block.get("text"))
3839            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
3840        Some("tool_use") => {
3841            let name = block.get("name").and_then(Value::as_str).unwrap_or("tool");
3842            let input = block
3843                .get("input")
3844                .map(short_json_detail)
3845                .unwrap_or_default();
3846            if input.is_empty() {
3847                Some(format!("tool: {name}"))
3848            } else {
3849                Some(format!("tool: {name} {}", truncate(&input, 140)))
3850            }
3851        }
3852        Some("function_call") | Some("tool_call") => {
3853            Some(format!("tool: {}", function_call_summary(block)))
3854        }
3855        _ => non_empty_str(block.get("text"))
3856            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
3857    }
3858}
3859
3860fn function_call_summary(value: &Value) -> String {
3861    let name = value
3862        .get("name")
3863        .or_else(|| value.get("tool"))
3864        .or_else(|| value.get("function"))
3865        .and_then(Value::as_str)
3866        .unwrap_or("tool");
3867    let args = value
3868        .get("args")
3869        .or_else(|| value.get("arguments"))
3870        .or_else(|| value.get("input"))
3871        .map(short_json_detail)
3872        .unwrap_or_default();
3873    if args.is_empty() {
3874        name.to_string()
3875    } else {
3876        format!("{name} {}", truncate(&args, 140))
3877    }
3878}
3879
3880fn non_empty_str(value: Option<&Value>) -> Option<&str> {
3881    value
3882        .and_then(Value::as_str)
3883        .map(str::trim)
3884        .filter(|text| !text.is_empty())
3885}
3886
3887fn finalize_engine(
3888    name: &str,
3889    bin: &str,
3890    duration_ms: u128,
3891    command_result: CommandResult,
3892    options: EngineRunOptions,
3893    sub_agents: Vec<EngineResult>,
3894) -> EngineResult {
3895    let output = match Engine::parse(name) {
3896        Some(Engine::Claude) => parse_claude_output(&command_result.stdout),
3897        Some(Engine::Gemini) => parse_gemini_output(&command_result.stdout),
3898        Some(Engine::Codex) => parse_codex_output(&command_result.stdout),
3899        None => command_result.stdout.trim().to_string(),
3900    };
3901    let status = if command_result.cancelled {
3902        "cancelled"
3903    } else if let Some(error) = command_result.error.as_deref() {
3904        spawn_failure_status(error)
3905    } else if command_result.timed_out {
3906        "timeout"
3907    } else if command_result.code != Some(0) || output.trim().is_empty() {
3908        "error"
3909    } else {
3910        "ok"
3911    };
3912    let detail = if status == "ok" {
3913        String::new()
3914    } else if command_result.cancelled {
3915        "Cancelled by Studio/user request.".to_string()
3916    } else if command_result.timed_out {
3917        format!("Timed out after {}s.", command_result.timeout_ms / 1000)
3918    } else {
3919        compact_failure(&command_result)
3920    };
3921    let usage = aggregate_token_usage(
3922        extract_token_usage(&command_result.stdout, &options.prompt, &output)
3923            .unwrap_or_else(|| token_usage(&options.prompt, &output)),
3924        &sub_agents,
3925    );
3926    let tool_calls = extract_tool_usage(name, &command_result.stdout, &command_result.stderr);
3927    EngineResult {
3928        name: name.to_string(),
3929        bin: Some(bin.to_string()),
3930        status: status.to_string(),
3931        duration_ms,
3932        detail,
3933        exit_code: command_result.code,
3934        stdout: command_result.stdout,
3935        stderr: command_result.stderr,
3936        output,
3937        command: format_command(&command_result.command, &command_result.args),
3938        token_usage: usage,
3939        tool_calls,
3940        sub_agents,
3941        role: options.role,
3942        iteration: options.iteration,
3943        total_iterations: options.total_iterations,
3944        team_size: options.team_size,
3945    }
3946}
3947
3948fn compact_failure(result: &CommandResult) -> String {
3949    if let Some(error) = &result.error {
3950        return error.clone();
3951    }
3952    if !result.stderr.trim().is_empty() {
3953        return result.stderr.trim().to_string();
3954    }
3955    if !result.stdout.trim().is_empty() {
3956        return result.stdout.trim().to_string();
3957    }
3958    result
3959        .code
3960        .map(|code| format!("Exited with code {code}."))
3961        .unwrap_or_else(|| "Command failed.".to_string())
3962}
3963
3964fn command_telemetry(result: &CommandResult) -> CommandTelemetry {
3965    let status = if result.cancelled {
3966        "cancelled"
3967    } else if let Some(error) = result.error.as_deref() {
3968        spawn_failure_status(error)
3969    } else if result.timed_out {
3970        "timeout"
3971    } else if result.code == Some(0) {
3972        "ok"
3973    } else {
3974        "error"
3975    };
3976    CommandTelemetry {
3977        command: format_command(&result.command, &result.args),
3978        status: status.to_string(),
3979        detail: truncate(&sanitize_status_detail(&compact_failure(result)), 600),
3980        exit_code: result.code,
3981        duration_ms: result.duration_ms,
3982        stdout_chars: result.stdout.len(),
3983        stderr_chars: result.stderr.len(),
3984        timed_out: result.timed_out,
3985    }
3986}
3987
3988fn spawn_error_status(error: &io::Error) -> &'static str {
3989    if error.kind() == io::ErrorKind::NotFound {
3990        "missing"
3991    } else {
3992        "error"
3993    }
3994}
3995
3996fn spawn_failure_status(error: &str) -> &'static str {
3997    let lowered = error.to_ascii_lowercase();
3998    if lowered.contains("no such file")
3999        || lowered.contains("not found")
4000        || lowered.contains("cannot find")
4001    {
4002        "missing"
4003    } else {
4004        "error"
4005    }
4006}
4007
4008fn parse_codex_output(stdout: &str) -> String {
4009    stdout.trim().to_string()
4010}
4011
4012fn parse_claude_output(stdout: &str) -> String {
4013    let trimmed = stdout.trim();
4014    if trimmed.is_empty() {
4015        return String::new();
4016    }
4017    if let Some(value) = extract_json(trimmed) {
4018        if let Some(result) = value.get("result").and_then(Value::as_str) {
4019            return result.trim().to_string();
4020        }
4021    }
4022    let mut latest = String::new();
4023    let mut streamed_text = String::new();
4024    for line in trimmed.lines() {
4025        let Ok(value) = serde_json::from_str::<Value>(line) else {
4026            continue;
4027        };
4028        if value.get("type").and_then(Value::as_str) == Some("result") {
4029            if let Some(result) = value.get("result").and_then(Value::as_str) {
4030                latest = result.trim().to_string();
4031            }
4032            continue;
4033        }
4034        if let Some(text) = claude_output_text(&value) {
4035            if value.get("type").and_then(Value::as_str) == Some("stream_event") {
4036                streamed_text.push_str(&text);
4037                latest = streamed_text.trim().to_string();
4038            } else if !text.trim().is_empty() {
4039                latest = text.trim().to_string();
4040            }
4041        }
4042    }
4043    if latest.is_empty() {
4044        trimmed.to_string()
4045    } else {
4046        latest
4047    }
4048}
4049
4050fn parse_gemini_output(stdout: &str) -> String {
4051    let trimmed = stdout.trim();
4052    if trimmed.is_empty() {
4053        return String::new();
4054    }
4055    if let Some(value) = extract_json(trimmed) {
4056        if let Some(response) = value.get("response").and_then(Value::as_str) {
4057            return response.trim().to_string();
4058        }
4059    }
4060    trimmed.to_string()
4061}
4062
4063fn extract_json(text: &str) -> Option<Value> {
4064    serde_json::from_str::<Value>(text).ok().or_else(|| {
4065        let start = text.find('{')?;
4066        let end = text.rfind('}')?;
4067        serde_json::from_str::<Value>(&text[start..=end]).ok()
4068    })
4069}
4070
4071fn build_member_prompt(input: MemberPromptInput<'_>) -> String {
4072    let mut sections = vec![
4073        "You are one member of a multi-model Amon Hen run.".to_string(),
4074        format!(
4075            "Amon Hen workflow: iteration {iteration} of {}.",
4076            input.workflow.iterations,
4077            iteration = input.iteration
4078        ),
4079        input
4080            .workflow
4081            .lead
4082            .as_ref()
4083            .map(|lead| format!("Lead model: {lead}."))
4084            .unwrap_or_else(|| "Lead model: auto.".to_string()),
4085        input
4086            .workflow
4087            .planner
4088            .as_ref()
4089            .map(|planner| format!("Planner model: {planner}."))
4090            .unwrap_or_else(|| "Planner model: none.".to_string()),
4091        format!("Your assigned role: {}.", input.role),
4092        role_instruction(input.role).to_string(),
4093        "Answer the user query directly.".to_string(),
4094        "Do not introduce yourself.".to_string(),
4095        "Do not describe your tools, environment, or capabilities unless the user explicitly asks."
4096            .to_string(),
4097        "Be concise unless the user asks for depth.".to_string(),
4098    ];
4099    if input.team_size > 0 {
4100        sections.push(format!(
4101            "Team work: you may coordinate up to {} internal sub-agents or subtasks inside your own CLI if that helps.",
4102            input.team_size
4103        ));
4104    }
4105    if input.workflow.handoff {
4106        sections.push(
4107            "Handoff mode is enabled. Treat earlier Amon Hen outputs as working context."
4108                .to_string(),
4109        );
4110    }
4111    if !input.plan_output.trim().is_empty() {
4112        sections.push(format!("Planner handoff:\n{}", input.plan_output.trim()));
4113    }
4114    let context = input
4115        .previous_iteration
4116        .iter()
4117        .chain(input.handoff_results.iter())
4118        .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
4119        .map(|result| format!("### {}\n{}", result.name, result.output.trim()))
4120        .collect::<Vec<_>>()
4121        .join("\n\n");
4122    if !context.is_empty() {
4123        sections.push(format!("Earlier Amon Hen handoffs:\n{context}"));
4124    }
4125    sections.push(format!("Current user query:\n{}", input.query.trim()));
4126    sections.join("\n\n")
4127}
4128
4129fn build_summary_prompt(
4130    query: &str,
4131    responses: &[EngineResult],
4132    workflow: &Workflow,
4133    max_member_chars: usize,
4134) -> String {
4135    let blocks = responses
4136        .iter()
4137        .map(|response| {
4138            let output = truncate(&response.output, max_member_chars);
4139            format!("### {}\n{}", response.name, output.trim())
4140        })
4141        .collect::<Vec<_>>()
4142        .join("\n\n");
4143    format!(
4144        "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{}",
4145        workflow.iterations,
4146        if workflow.iterations == 1 { "" } else { "s" },
4147        if workflow.handoff { "handoff enabled" } else { "parallel consultation" },
4148        workflow.lead.as_deref().unwrap_or("auto"),
4149        workflow.planner.as_deref().unwrap_or("none"),
4150        query.trim(),
4151        blocks
4152    )
4153}
4154
4155fn role_instruction(role: &str) -> &'static str {
4156    match role {
4157        "planner" => "Plan the work: identify the approach, risks, checkpoints, and useful handoffs for the executors.",
4158        "lead" => "Lead the work: make the strongest direct attempt while watching for conflicts you may need to resolve in synthesis.",
4159        "lead+planner" => "Plan and lead the work: produce a practical plan, then make the strongest direct attempt from that plan.",
4160        _ => "Execute the work: use any plan or handoff context, then produce your independent best answer.",
4161    }
4162}
4163
4164fn execution_order(members: &[String], planner: Option<&str>, lead: Option<&str>) -> Vec<String> {
4165    let mut ordered = Vec::new();
4166    if let Some(planner) = planner {
4167        if members.iter().any(|member| member == planner) {
4168            ordered.push(planner.to_string());
4169        }
4170    }
4171    ordered.extend(
4172        members
4173            .iter()
4174            .filter(|member| Some(member.as_str()) != planner && Some(member.as_str()) != lead)
4175            .cloned(),
4176    );
4177    if lead != planner {
4178        if let Some(lead) = lead {
4179            if members.iter().any(|member| member == lead) {
4180                ordered.push(lead.to_string());
4181            }
4182        }
4183    }
4184    ordered
4185}
4186
4187fn role_for(member: &str, workflow: &Workflow) -> String {
4188    let is_lead = workflow.lead.as_deref() == Some(member);
4189    let is_planner = workflow.planner.as_deref() == Some(member);
4190    match (is_lead, is_planner) {
4191        (true, true) => "lead+planner",
4192        (true, false) => "lead",
4193        (false, true) => "planner",
4194        (false, false) => "executor",
4195    }
4196    .to_string()
4197}
4198
4199fn pick_summarizer(resolved: &ResolvedArgs, successes: &[EngineResult]) -> String {
4200    if resolved.raw.summarizer != "auto" {
4201        return resolved.raw.summarizer.clone();
4202    }
4203    if let Some(lead) = &resolved.raw.lead {
4204        if successes.iter().any(|result| &result.name == lead) {
4205            return lead.clone();
4206        }
4207    }
4208    DEFAULT_SUMMARIZER_ORDER
4209        .iter()
4210        .find(|name| successes.iter().any(|result| result.name == **name))
4211        .unwrap_or(&successes[0].name.as_str())
4212        .to_string()
4213}
4214
4215fn resolve_binary(name: &str) -> String {
4216    let Some(engine) = Engine::parse(name) else {
4217        return name.to_string();
4218    };
4219    std::env::var(engine.binary_env_var())
4220        .ok()
4221        .filter(|value| !value.trim().is_empty())
4222        .unwrap_or_else(|| engine.as_str().to_string())
4223}
4224
4225fn token_usage(prompt: &str, output: &str) -> TokenUsage {
4226    let input = estimate_tokens(prompt);
4227    let output = estimate_tokens(output);
4228    TokenUsage {
4229        input,
4230        output,
4231        total: input + output,
4232        estimated: true,
4233        source: "estimate".to_string(),
4234    }
4235}
4236
4237fn aggregate_token_usage(mut usage: TokenUsage, sub_agents: &[EngineResult]) -> TokenUsage {
4238    if sub_agents.is_empty() {
4239        return usage;
4240    }
4241    for agent in sub_agents {
4242        usage.input += agent.token_usage.input;
4243        usage.output += agent.token_usage.output;
4244        usage.total += agent.token_usage.total;
4245        usage.estimated |= agent.token_usage.estimated;
4246    }
4247    usage.source = format!("{}+sub-agents", usage.source);
4248    usage
4249}
4250
4251fn aggregate_results_token_usage<'a>(
4252    results: impl IntoIterator<Item = &'a EngineResult>,
4253) -> TokenUsage {
4254    let mut usage = TokenUsage {
4255        input: 0,
4256        output: 0,
4257        total: 0,
4258        estimated: false,
4259        source: "aggregate".to_string(),
4260    };
4261    for result in results {
4262        usage.input += result.token_usage.input;
4263        usage.output += result.token_usage.output;
4264        usage.total += result.token_usage.total;
4265        usage.estimated |= result.token_usage.estimated;
4266    }
4267    usage
4268}
4269
4270fn extract_token_usage(stdout: &str, prompt: &str, output: &str) -> Option<TokenUsage> {
4271    let mut usage = TokenAccumulator::default();
4272    for value in json_values(stdout) {
4273        collect_token_counts(&value, &mut usage);
4274    }
4275    if usage.input.is_none() && usage.output.is_none() && usage.total.is_none() {
4276        return None;
4277    }
4278    let input = usage.input.unwrap_or_else(|| estimate_tokens(prompt));
4279    let output = usage.output.unwrap_or_else(|| estimate_tokens(output));
4280    let total = usage.total.unwrap_or(input + output);
4281    Some(TokenUsage {
4282        input,
4283        output,
4284        total,
4285        estimated: usage.input.is_none() || usage.output.is_none(),
4286        source: "provider".to_string(),
4287    })
4288}
4289
4290#[derive(Default)]
4291struct TokenAccumulator {
4292    input: Option<usize>,
4293    output: Option<usize>,
4294    total: Option<usize>,
4295}
4296
4297fn collect_token_counts(value: &Value, usage: &mut TokenAccumulator) {
4298    match value {
4299        Value::Object(map) => {
4300            for (key, value) in map {
4301                let normalized = key.replace(['_', '-'], "").to_ascii_lowercase();
4302                if let Some(count) = value.as_u64().map(|value| value as usize) {
4303                    match normalized.as_str() {
4304                        "inputtokens" | "inputtokencount" | "prompttokens" | "prompttokencount" => {
4305                            usage.input = Some(count)
4306                        }
4307                        "outputtokens"
4308                        | "outputtokencount"
4309                        | "completiontokens"
4310                        | "candidatestokencount" => usage.output = Some(count),
4311                        "totaltokens" | "totaltokencount" => usage.total = Some(count),
4312                        _ => {}
4313                    }
4314                }
4315                collect_token_counts(value, usage);
4316            }
4317        }
4318        Value::Array(values) => {
4319            for value in values {
4320                collect_token_counts(value, usage);
4321            }
4322        }
4323        _ => {}
4324    }
4325}
4326
4327fn extract_tool_usage(provider: &str, stdout: &str, stderr: &str) -> Vec<ToolUsage> {
4328    let mut tools = Vec::new();
4329    for value in json_values(stdout) {
4330        collect_tool_usage(&value, &mut tools);
4331    }
4332    for line in stdout.lines().chain(stderr.lines()) {
4333        if let Some(tool) = line_tool_usage(provider, line) {
4334            tools.push(tool);
4335        }
4336    }
4337    dedupe_tools(tools)
4338}
4339
4340fn collect_tool_usage(value: &Value, tools: &mut Vec<ToolUsage>) {
4341    match value {
4342        Value::Object(map) => {
4343            let type_name = map.get("type").and_then(Value::as_str).unwrap_or_default();
4344            let name = map
4345                .get("name")
4346                .or_else(|| map.get("tool"))
4347                .or_else(|| map.get("tool_name"))
4348                .or_else(|| map.get("toolName"))
4349                .and_then(Value::as_str);
4350            if let Some(name) = name.filter(|_| {
4351                type_name.contains("tool")
4352                    || map.contains_key("input")
4353                    || map.contains_key("arguments")
4354                    || map.contains_key("toolCall")
4355            }) {
4356                tools.push(ToolUsage {
4357                    name: name.to_string(),
4358                    kind: if type_name.is_empty() {
4359                        "tool"
4360                    } else {
4361                        type_name
4362                    }
4363                    .to_string(),
4364                    status: "observed".to_string(),
4365                    detail: short_json_detail(value),
4366                });
4367            }
4368            for value in map.values() {
4369                collect_tool_usage(value, tools);
4370            }
4371        }
4372        Value::Array(values) => {
4373            for value in values {
4374                collect_tool_usage(value, tools);
4375            }
4376        }
4377        _ => {}
4378    }
4379}
4380
4381fn line_tool_usage(provider: &str, line: &str) -> Option<ToolUsage> {
4382    let trimmed = line.trim();
4383    if looks_like_json_line(trimmed) {
4384        return None;
4385    }
4386    let lowered = trimmed.to_ascii_lowercase();
4387    let marker = ["running shell:", "tool:", "tool_use", "mcp:", "command:"]
4388        .iter()
4389        .find(|marker| lowered.contains(**marker))?;
4390    Some(ToolUsage {
4391        name: marker.trim_end_matches(':').to_string(),
4392        kind: provider.to_string(),
4393        status: "observed".to_string(),
4394        detail: truncate(trimmed, 240),
4395    })
4396}
4397
4398fn short_json_detail(value: &Value) -> String {
4399    serde_json::to_string(value)
4400        .map(|text| truncate(&text, 240))
4401        .unwrap_or_default()
4402}
4403
4404fn dedupe_tools(tools: Vec<ToolUsage>) -> Vec<ToolUsage> {
4405    let mut seen = HashSet::new();
4406    tools
4407        .into_iter()
4408        .filter(|tool| seen.insert(format!("{}:{}:{}", tool.kind, tool.name, tool.detail)))
4409        .collect()
4410}
4411
4412fn json_values(text: &str) -> Vec<Value> {
4413    let trimmed = text.trim();
4414    if trimmed.is_empty() {
4415        return vec![];
4416    }
4417    let mut values = Vec::new();
4418    if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
4419        values.push(value);
4420        return values;
4421    }
4422    for line in trimmed.lines() {
4423        if let Ok(value) = serde_json::from_str::<Value>(line.trim()) {
4424            values.push(value);
4425        }
4426    }
4427    values
4428}
4429
4430fn looks_like_json_line(line: &str) -> bool {
4431    let trimmed = line.trim_start();
4432    trimmed.starts_with('{') || trimmed.starts_with('[')
4433}
4434
4435fn estimate_tokens(text: &str) -> usize {
4436    if text.trim().is_empty() {
4437        0
4438    } else {
4439        text.len().div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN)
4440    }
4441}
4442
4443fn format_command(command: &str, args: &[String]) -> String {
4444    std::iter::once(command.to_string())
4445        .chain(args.iter().map(|arg| shell_quote(arg)))
4446        .collect::<Vec<_>>()
4447        .join(" ")
4448}
4449
4450fn shell_quote(value: &str) -> String {
4451    if value
4452        .chars()
4453        .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
4454    {
4455        value.to_string()
4456    } else {
4457        serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
4458    }
4459}
4460
4461fn truncate(text: &str, max_chars: usize) -> String {
4462    if text.chars().count() <= max_chars {
4463        text.to_string()
4464    } else {
4465        let mut truncated = text.chars().take(max_chars).collect::<String>();
4466        truncated.push_str("\n...[truncated]");
4467        truncated
4468    }
4469}
4470
4471fn should_show_banner(raw: &CliArgs) -> bool {
4472    !raw.no_banner && !raw.headless && !raw.json && !raw.json_stream && !raw.plain
4473}
4474
4475fn linear_delivery_requested(raw: &CliArgs) -> bool {
4476    raw.deliver_linear
4477        || raw.linear_until_complete
4478        || raw.linear_watch
4479        || !raw.linear_issue.is_empty()
4480        || raw
4481            .linear_query
4482            .as_ref()
4483            .is_some_and(|value| !value.trim().is_empty())
4484        || !raw.linear_project.is_empty()
4485        || !raw.linear_epic.is_empty()
4486}
4487
4488fn render_banner() -> &'static str {
4489    "    _                         _   _            \n   / \\   _ __ ___   ___  _ __ | | | | ___ _ __  \n  / _ \\ | '_ ` _ \\ / _ \\| '_ \\| |_| |/ _ \\ '_ \\ \n / ___ \\| | | | | | (_) | | | |  _  |  __/ | | |\n/_/   \\_\\_| |_| |_|\\___/|_| |_|_| |_|\\___|_| |_|"
4490}
4491
4492fn render_human_result(result: &AmonHenResult, verbose: bool) -> String {
4493    let mut lines = Vec::new();
4494    if verbose {
4495        lines.push(format!(
4496            "Amon Hen consulted: {}",
4497            result.members_requested.join(", ")
4498        ));
4499        for command in &result.prompt_commands {
4500            lines.push(format!(
4501                "cmd [{}] {} ({:.1}s)",
4502                command.status,
4503                command.command,
4504                command.duration_ms as f64 / 1000.0
4505            ));
4506        }
4507        for (index, member) in result.members.iter().enumerate() {
4508            lines.push(format!(
4509                "{}. [{}] {} ({:.1}s, tokens:{}, tools:{}, sub-agents:{}){}",
4510                index + 1,
4511                member.status,
4512                member.name,
4513                member.duration_ms as f64 / 1000.0,
4514                member.token_usage.total,
4515                member.tool_calls.len(),
4516                member.sub_agents.len(),
4517                if member.detail.is_empty() { "" } else { ": " }
4518            ));
4519            if !member.detail.is_empty() {
4520                lines.push(format!("   {}", member.detail));
4521            }
4522            for sub_agent in &member.sub_agents {
4523                lines.push(format!(
4524                    "   - {} [{}] tokens:{} tools:{}",
4525                    sub_agent.role,
4526                    sub_agent.status,
4527                    sub_agent.token_usage.total,
4528                    sub_agent.tool_calls.len()
4529                ));
4530            }
4531            if member.status == "ok" {
4532                lines.push(indent(&member.output, "   "));
4533            }
4534        }
4535        lines.push("----------- synthesis -----------".to_string());
4536    }
4537    if result.summary.status == "ok" {
4538        lines.push(result.summary.output.trim().to_string());
4539    } else {
4540        lines.push(format!("Synthesis failed: {}", result.summary.detail));
4541    }
4542    lines.join("\n")
4543}
4544
4545fn indent(text: &str, prefix: &str) -> String {
4546    text.lines()
4547        .map(|line| format!("{prefix}{line}"))
4548        .collect::<Vec<_>>()
4549        .join("\n")
4550}
4551
4552fn is_success(result: &AmonHenResult) -> bool {
4553    result.members.iter().any(|member| member.status == "ok") && result.summary.status == "ok"
4554}
4555
4556#[cfg(test)]
4557mod tests {
4558    use super::*;
4559    use std::sync::Mutex as TestMutex;
4560
4561    static ENV_LOCK: TestMutex<()> = TestMutex::new(());
4562
4563    struct ProviderEnvGuard {
4564        saved: Vec<(&'static str, Option<OsString>)>,
4565    }
4566
4567    impl ProviderEnvGuard {
4568        fn install_echo_bins() -> Option<Self> {
4569            let echo = Path::new("/bin/echo");
4570            if !echo.is_file() {
4571                return None;
4572            }
4573            let vars = [
4574                "AMON_HEN_CODEX_BIN",
4575                "AMON_HEN_CLAUDE_BIN",
4576                "AMON_HEN_GEMINI_BIN",
4577            ];
4578            let saved = vars
4579                .into_iter()
4580                .map(|name| (name, std::env::var_os(name)))
4581                .collect::<Vec<_>>();
4582            for (name, _) in &saved {
4583                unsafe {
4584                    std::env::set_var(name, echo);
4585                }
4586            }
4587            Some(Self { saved })
4588        }
4589    }
4590
4591    impl Drop for ProviderEnvGuard {
4592        fn drop(&mut self) {
4593            for (name, value) in &self.saved {
4594                unsafe {
4595                    if let Some(value) = value {
4596                        std::env::set_var(name, value);
4597                    } else {
4598                        std::env::remove_var(name);
4599                    }
4600                }
4601            }
4602        }
4603    }
4604
4605    #[test]
4606    fn help_flags_use_success_exit_code() {
4607        let long_help = CliArgs::try_parse_from(["amon-hen", "--help"]).unwrap_err();
4608        let short_help = CliArgs::try_parse_from(["amon-hen", "-h"]).unwrap_err();
4609
4610        assert_eq!(long_help.kind(), ErrorKind::DisplayHelp);
4611        assert_eq!(short_help.kind(), ErrorKind::DisplayHelp);
4612        assert_eq!(parse_error_exit_code(long_help.kind()), 0);
4613        assert_eq!(parse_error_exit_code(short_help.kind()), 0);
4614    }
4615
4616    #[test]
4617    fn rendered_help_is_grouped_and_compact() {
4618        let help = render_cli_help();
4619
4620        assert!(help.contains("Core run:"));
4621        assert!(help.contains("Models, effort, and permissions:"));
4622        assert!(help.contains("Auth and provider capabilities:"));
4623        assert!(help.contains("Linear delivery:"));
4624        assert!(help.contains("--members codex,claude,gemini"));
4625        assert!(help.contains("--claude-permission-mode MODE"));
4626        assert!(help.contains("--gemini-approval-mode MODE"));
4627        assert!(!help.contains("\n\n\n"));
4628    }
4629
4630    #[test]
4631    fn parses_members_and_provider_flags() {
4632        let args = CliArgs::try_parse_from([
4633            "amon-hen",
4634            "--members",
4635            "codex,claude",
4636            "--planner",
4637            "codex",
4638            "--planner-mode",
4639            "parallel",
4640            "--handoff",
4641            "--lead",
4642            "claude",
4643            "--codex-effort",
4644            "xhigh",
4645            "--claude-capabilities",
4646            "override",
4647            "--claude-allowed-tools",
4648            "Read,Bash(git:*)",
4649            "--claude-tools",
4650            "Read,Edit",
4651            "--claude-agent",
4652            "reviewer",
4653            "--gemini-allowed-mcp-servers",
4654            "linear,github",
4655            "ship it",
4656        ])
4657        .unwrap();
4658        let resolved = resolve_args(args).unwrap();
4659        assert_eq!(resolved.members, vec!["codex", "claude"]);
4660        assert_eq!(resolved.raw.planner_mode, PLANNER_MODE_PARALLEL);
4661        assert_eq!(resolved.raw.codex_effort.as_deref(), Some("xhigh"));
4662        assert_eq!(
4663            resolved.raw.claude_allowed_tools,
4664            vec!["Read", "Bash(git:*)"]
4665        );
4666        assert_eq!(resolved.raw.claude_tools, vec!["Read", "Edit"]);
4667        assert_eq!(resolved.raw.claude_agent.as_deref(), Some("reviewer"));
4668        assert_eq!(
4669            resolved.raw.gemini_allowed_mcp_servers,
4670            vec!["linear", "github"]
4671        );
4672    }
4673
4674    #[test]
4675    fn planner_parallel_mode_keeps_executors_unblocked() {
4676        let args = CliArgs::try_parse_from([
4677            "amon-hen",
4678            "--members",
4679            "codex,claude,gemini",
4680            "--planner",
4681            "claude",
4682            "--planner-mode",
4683            "parallel",
4684            "--handoff",
4685            "--lead",
4686            "claude",
4687            "ship it",
4688        ])
4689        .unwrap();
4690        let resolved = resolve_args(args).unwrap();
4691        let workflow = build_workflow(&resolved);
4692        let ordered = execution_order(
4693            &resolved.members,
4694            workflow.planner.as_deref(),
4695            workflow.lead.as_deref(),
4696        );
4697        let planner = workflow
4698            .planner
4699            .as_ref()
4700            .filter(|planner| ordered.contains(planner))
4701            .cloned();
4702        let blocking_planner = workflow.planner_mode == PLANNER_MODE_BLOCKING;
4703        let executors = ordered
4704            .into_iter()
4705            .filter(|member| !(blocking_planner && Some(member) == planner.as_ref()))
4706            .collect::<Vec<_>>();
4707
4708        assert_eq!(workflow.planner_mode, PLANNER_MODE_PARALLEL);
4709        assert!(workflow.handoff);
4710        assert_eq!(executors, vec!["claude", "codex", "gemini"]);
4711        assert_eq!(role_for("claude", &workflow), "lead+planner");
4712        assert_eq!(role_for("codex", &workflow), "executor");
4713        assert_eq!(role_for("gemini", &workflow), "executor");
4714    }
4715
4716    #[test]
4717    fn parses_historical_linear_flags_in_rust() {
4718        let args = CliArgs::try_parse_from([
4719            "amon-hen",
4720            "--banner",
4721            "--auth-open-browser",
4722            "--members",
4723            "codex,claude,gemini",
4724            "--linear-project",
4725            "ENG",
4726            "--linear-endpoint",
4727            "https://linear.example/graphql",
4728            "--linear-auth",
4729            "oauth",
4730            "--linear-max-polls",
4731            "2",
4732            "--linear-max-concurrency",
4733            "3",
4734            "--linear-workflow-file",
4735            "docs/linear-workflow.md",
4736            "--linear-workspace-strategy",
4737            "copy",
4738            "--linear-completion-gate",
4739            "review-or-ci",
4740            "--delivery-phases",
4741            "plan,implement,verify,ship",
4742            "deliver it",
4743        ])
4744        .unwrap();
4745        let resolved = resolve_args(args).unwrap();
4746
4747        assert!(linear_delivery_requested(&resolved.raw));
4748        assert_eq!(
4749            resolved.raw.linear_endpoint.as_deref(),
4750            Some("https://linear.example/graphql")
4751        );
4752        assert_eq!(resolved.raw.linear_auth, "oauth");
4753        assert_eq!(resolved.raw.linear_max_polls, Some(2));
4754        assert_eq!(resolved.raw.linear_max_concurrency, 3);
4755        assert_eq!(
4756            resolved.raw.linear_workflow_file.as_deref(),
4757            Some(Path::new("docs/linear-workflow.md"))
4758        );
4759        assert_eq!(resolved.raw.linear_workspace_strategy, "copy");
4760        assert_eq!(resolved.raw.linear_completion_gate, "review-or-ci");
4761        assert_eq!(
4762            resolved.raw.delivery_phases,
4763            vec!["plan", "implement", "verify", "ship"]
4764        );
4765    }
4766
4767    #[test]
4768    fn cli_role_flag_matrix_reaches_provider_options_and_prompts() {
4769        let args = CliArgs::try_parse_from([
4770            "amon-hen",
4771            "--members",
4772            "codex,claude,gemini",
4773            "--planner",
4774            "codex",
4775            "--lead",
4776            "claude",
4777            "--handoff",
4778            "--iterations",
4779            "3",
4780            "--team-work",
4781            "2",
4782            "--codex-sub-agents",
4783            "4",
4784            "--claude-sub-agents",
4785            "1",
4786            "--gemini-sub-agents",
4787            "0",
4788            "--codex-model",
4789            "gpt-5.2",
4790            "--codex-effort",
4791            "xhigh",
4792            "--codex-sandbox",
4793            "danger-full-access",
4794            "--codex-config",
4795            "sandbox_workspace_write.network_access=true",
4796            "--codex-mcp-profile",
4797            "repo",
4798            "--claude-model",
4799            "sonnet",
4800            "--claude-effort",
4801            "max",
4802            "--claude-permission-mode",
4803            "bypassPermissions",
4804            "--claude-allowed-tools",
4805            "Read,Edit",
4806            "--claude-tools",
4807            "Bash",
4808            "--claude-agent",
4809            "lead-reviewer",
4810            "--gemini-model",
4811            "gemini-2.5-pro",
4812            "--gemini-effort",
4813            "high",
4814            "--gemini-approval-mode",
4815            "auto_edit",
4816            "--gemini-settings",
4817            "gemini-settings.json",
4818            "--gemini-tools-profile",
4819            "repo,ci",
4820            "--gemini-allowed-mcp-servers",
4821            "linear",
4822            "Fix the regression",
4823        ])
4824        .unwrap();
4825        let resolved = resolve_args(args).unwrap();
4826        let workflow = build_workflow(&resolved);
4827        let previous = vec![EngineResult {
4828            name: "gemini".to_string(),
4829            bin: Some("gemini".to_string()),
4830            status: "ok".to_string(),
4831            duration_ms: 1,
4832            detail: String::new(),
4833            exit_code: Some(0),
4834            stdout: String::new(),
4835            stderr: String::new(),
4836            output: "previous iteration context".to_string(),
4837            command: "gemini".to_string(),
4838            token_usage: token_usage("a", "b"),
4839            tool_calls: vec![],
4840            sub_agents: vec![],
4841            role: "executor".to_string(),
4842            iteration: 1,
4843            total_iterations: 3,
4844            team_size: 0,
4845        }];
4846        let handoff = vec![EngineResult {
4847            name: "codex".to_string(),
4848            bin: Some("codex".to_string()),
4849            status: "ok".to_string(),
4850            duration_ms: 1,
4851            detail: String::new(),
4852            exit_code: Some(0),
4853            stdout: String::new(),
4854            stderr: String::new(),
4855            output: "planner handoff context".to_string(),
4856            command: "codex".to_string(),
4857            token_usage: token_usage("a", "b"),
4858            tool_calls: vec![],
4859            sub_agents: vec![],
4860            role: "planner".to_string(),
4861            iteration: 2,
4862            total_iterations: 3,
4863            team_size: 4,
4864        }];
4865
4866        assert_eq!(workflow.iterations, 3);
4867        assert!(workflow.handoff);
4868        assert_eq!(workflow.team_work, 2);
4869        assert_eq!(workflow.teams.get("codex"), Some(&4));
4870        assert_eq!(workflow.teams.get("claude"), Some(&1));
4871        assert_eq!(workflow.teams.get("gemini"), Some(&0));
4872        assert_eq!(role_for("codex", &workflow), "planner");
4873        assert_eq!(role_for("claude", &workflow), "lead");
4874        assert_eq!(role_for("gemini", &workflow), "executor");
4875
4876        let codex_prompt = build_member_prompt(MemberPromptInput {
4877            query: "Fix the regression",
4878            role: "planner",
4879            workflow: &workflow,
4880            iteration: 2,
4881            team_size: effective_team_size(&workflow, "codex"),
4882            previous_iteration: &previous,
4883            handoff_results: &handoff,
4884            plan_output: "plan output",
4885        });
4886        assert!(codex_prompt.contains("Amon Hen workflow: iteration 2 of 3."));
4887        assert!(codex_prompt.contains("Lead model: claude."));
4888        assert!(codex_prompt.contains("Planner model: codex."));
4889        assert!(codex_prompt.contains("Your assigned role: planner."));
4890        assert!(codex_prompt.contains("Handoff mode is enabled."));
4891        assert!(codex_prompt.contains("Team work: you may coordinate up to 4 internal sub-agents"));
4892        assert!(codex_prompt.contains("Planner handoff:\nplan output"));
4893        assert!(codex_prompt.contains("previous iteration context"));
4894        assert!(codex_prompt.contains("planner handoff context"));
4895
4896        let codex_options = engine_options(
4897            &resolved,
4898            EngineOptionsInput {
4899                member: "codex",
4900                prompt: codex_prompt,
4901                role: "planner",
4902                iteration: 2,
4903                workflow: &workflow,
4904                progress: None,
4905                cancel: None,
4906            },
4907        );
4908        assert_eq!(codex_options.model.as_deref(), Some("gpt-5.2"));
4909        assert_eq!(codex_options.effort.as_deref(), Some("xhigh"));
4910        assert_eq!(
4911            codex_options.permission.as_deref(),
4912            Some("danger-full-access")
4913        );
4914        assert_eq!(codex_options.role, "planner");
4915        assert_eq!(codex_options.iteration, 2);
4916        assert_eq!(codex_options.total_iterations, 3);
4917        assert_eq!(codex_options.team_size, 4);
4918        assert_eq!(codex_options.capability.mode, CAPABILITY_OVERRIDE);
4919        assert_eq!(
4920            codex_options.capability.config,
4921            vec!["sandbox_workspace_write.network_access=true"]
4922        );
4923        assert_eq!(
4924            codex_options.capability.mcp_profile.as_deref(),
4925            Some("repo")
4926        );
4927
4928        let claude_options = engine_options(
4929            &resolved,
4930            EngineOptionsInput {
4931                member: "claude",
4932                prompt: build_member_prompt(MemberPromptInput {
4933                    query: "Fix the regression",
4934                    role: "lead",
4935                    workflow: &workflow,
4936                    iteration: 2,
4937                    team_size: effective_team_size(&workflow, "claude"),
4938                    previous_iteration: &[],
4939                    handoff_results: &[],
4940                    plan_output: "",
4941                }),
4942                role: "lead",
4943                iteration: 2,
4944                workflow: &workflow,
4945                progress: None,
4946                cancel: None,
4947            },
4948        );
4949        assert_eq!(claude_options.model.as_deref(), Some("sonnet"));
4950        assert_eq!(claude_options.effort.as_deref(), Some("max"));
4951        assert_eq!(
4952            claude_options.permission.as_deref(),
4953            Some("bypassPermissions")
4954        );
4955        assert_eq!(claude_options.role, "lead");
4956        assert_eq!(claude_options.team_size, 1);
4957        assert_eq!(claude_options.capability.mode, CAPABILITY_OVERRIDE);
4958        assert_eq!(
4959            claude_options.capability.allowed_tools,
4960            vec!["Read", "Edit"]
4961        );
4962        assert_eq!(claude_options.capability.tools, vec!["Bash"]);
4963        assert_eq!(
4964            claude_options.capability.agent.as_deref(),
4965            Some("lead-reviewer")
4966        );
4967
4968        let gemini_options = engine_options(
4969            &resolved,
4970            EngineOptionsInput {
4971                member: "gemini",
4972                prompt: build_member_prompt(MemberPromptInput {
4973                    query: "Fix the regression",
4974                    role: "executor",
4975                    workflow: &workflow,
4976                    iteration: 2,
4977                    team_size: effective_team_size(&workflow, "gemini"),
4978                    previous_iteration: &[],
4979                    handoff_results: &[],
4980                    plan_output: "",
4981                }),
4982                role: "executor",
4983                iteration: 2,
4984                workflow: &workflow,
4985                progress: None,
4986                cancel: None,
4987            },
4988        );
4989        assert_eq!(gemini_options.model.as_deref(), Some("gemini-2.5-pro"));
4990        assert_eq!(gemini_options.effort.as_deref(), Some("high"));
4991        assert_eq!(gemini_options.permission.as_deref(), Some("auto_edit"));
4992        assert_eq!(gemini_options.role, "executor");
4993        assert_eq!(gemini_options.team_size, 0);
4994        assert_eq!(gemini_options.capability.mode, CAPABILITY_OVERRIDE);
4995        assert_eq!(
4996            gemini_options.capability.settings.as_deref(),
4997            Some("gemini-settings.json")
4998        );
4999        assert_eq!(gemini_options.capability.tools_profile, vec!["repo", "ci"]);
5000        assert_eq!(
5001            gemini_options.capability.allowed_mcp_servers,
5002            vec!["linear"]
5003        );
5004    }
5005
5006    #[test]
5007    fn provider_command_args_include_role_matrix_settings() {
5008        let cwd = std::env::current_dir().unwrap();
5009        let codex_options = EngineRunOptions {
5010            prompt: "codex prompt".to_string(),
5011            cwd: cwd.clone(),
5012            timeout_ms: 1,
5013            effort: Some("high".to_string()),
5014            model: Some("gpt-5.2".to_string()),
5015            permission: Some("workspace-write".to_string()),
5016            auth: DEFAULT_AUTH_MODE.to_string(),
5017            capability: ProviderCapability {
5018                mode: CAPABILITY_OVERRIDE.to_string(),
5019                config: vec!["tools.web_search=true".to_string()],
5020                mcp_profile: Some("repo".to_string()),
5021                mcp_config: vec![],
5022                allowed_tools: vec![],
5023                disallowed_tools: vec![],
5024                tools: vec![],
5025                agent: None,
5026                agents_json: None,
5027                plugin_dirs: vec![],
5028                strict_mcp_config: false,
5029                disable_slash_commands: false,
5030                settings: None,
5031                tools_profile: vec![],
5032                allowed_mcp_servers: vec![],
5033                policy: vec![],
5034                admin_policy: vec![],
5035            },
5036            role: "planner".to_string(),
5037            iteration: 2,
5038            total_iterations: 3,
5039            team_size: 4,
5040            is_sub_agent: false,
5041            live: false,
5042            progress: None,
5043            cancel: None,
5044        };
5045        let codex = run_codex("definitely-missing-codex-test-bin", &codex_options);
5046        assert_eq!(codex.args[0], "exec");
5047        assert!(codex
5048            .args
5049            .windows(2)
5050            .any(|pair| pair == ["--model", "gpt-5.2"]));
5051        assert!(codex
5052            .args
5053            .windows(2)
5054            .any(|pair| pair == ["-c", "model_reasoning_effort=high"]));
5055        assert!(codex
5056            .args
5057            .windows(2)
5058            .any(|pair| pair == ["-c", "tools.web_search=true"]));
5059        assert!(codex
5060            .args
5061            .windows(2)
5062            .any(|pair| pair == ["--profile", "repo"]));
5063        assert!(codex
5064            .args
5065            .windows(2)
5066            .any(|pair| pair == ["--sandbox", "workspace-write"]));
5067
5068        let claude_options = EngineRunOptions {
5069            prompt: "claude prompt".to_string(),
5070            cwd: cwd.clone(),
5071            timeout_ms: 1,
5072            effort: Some("max".to_string()),
5073            model: Some("sonnet".to_string()),
5074            permission: Some("bypassPermissions".to_string()),
5075            auth: "social-login".to_string(),
5076            capability: ProviderCapability {
5077                mode: CAPABILITY_OVERRIDE.to_string(),
5078                config: vec![],
5079                mcp_profile: None,
5080                mcp_config: vec!["mcp.json".to_string()],
5081                allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
5082                disallowed_tools: vec!["WebFetch".to_string()],
5083                tools: vec!["Bash".to_string()],
5084                agent: Some("lead-reviewer".to_string()),
5085                agents_json: Some("agents.json".to_string()),
5086                plugin_dirs: vec!["plugins/a".to_string(), "plugins/b".to_string()],
5087                strict_mcp_config: true,
5088                disable_slash_commands: true,
5089                settings: None,
5090                tools_profile: vec![],
5091                allowed_mcp_servers: vec![],
5092                policy: vec![],
5093                admin_policy: vec![],
5094            },
5095            role: "lead".to_string(),
5096            iteration: 2,
5097            total_iterations: 3,
5098            team_size: 1,
5099            is_sub_agent: false,
5100            live: false,
5101            progress: None,
5102            cancel: None,
5103        };
5104        let claude = run_claude("definitely-missing-claude-test-bin", &claude_options);
5105        assert!(!claude.args.iter().any(|arg| arg == "--bare"));
5106        assert!(claude
5107            .args
5108            .windows(2)
5109            .any(|pair| pair == ["--permission-mode", "bypassPermissions"]));
5110        assert!(claude
5111            .args
5112            .windows(2)
5113            .any(|pair| pair == ["--model", "sonnet"]));
5114        assert!(claude
5115            .args
5116            .windows(2)
5117            .any(|pair| pair == ["--effort", "max"]));
5118        assert!(claude
5119            .args
5120            .windows(3)
5121            .any(|triple| triple == ["--allowedTools", "Read", "Edit"]));
5122        assert!(claude
5123            .args
5124            .windows(2)
5125            .any(|pair| pair == ["--agent", "lead-reviewer"]));
5126        assert!(claude.args.iter().any(|arg| arg == "--strict-mcp-config"));
5127        assert!(claude
5128            .args
5129            .iter()
5130            .any(|arg| arg == "--disable-slash-commands"));
5131
5132        let gemini_options = EngineRunOptions {
5133            prompt: "gemini prompt".to_string(),
5134            cwd,
5135            timeout_ms: 1,
5136            effort: Some("high".to_string()),
5137            model: Some("gemini-2.5-pro".to_string()),
5138            permission: Some("auto_edit".to_string()),
5139            auth: DEFAULT_AUTH_MODE.to_string(),
5140            capability: ProviderCapability {
5141                mode: CAPABILITY_OVERRIDE.to_string(),
5142                config: vec![],
5143                mcp_profile: None,
5144                mcp_config: vec![],
5145                allowed_tools: vec![],
5146                disallowed_tools: vec![],
5147                tools: vec![],
5148                agent: None,
5149                agents_json: None,
5150                plugin_dirs: vec![],
5151                strict_mcp_config: false,
5152                disable_slash_commands: false,
5153                settings: None,
5154                tools_profile: vec!["repo".to_string(), "ci".to_string()],
5155                allowed_mcp_servers: vec!["linear".to_string()],
5156                policy: vec!["default".to_string()],
5157                admin_policy: vec!["locked".to_string()],
5158            },
5159            role: "executor".to_string(),
5160            iteration: 2,
5161            total_iterations: 3,
5162            team_size: 0,
5163            is_sub_agent: false,
5164            live: false,
5165            progress: None,
5166            cancel: None,
5167        };
5168        let gemini = run_gemini("definitely-missing-gemini-test-bin", &gemini_options);
5169        assert!(gemini
5170            .args
5171            .windows(2)
5172            .any(|pair| pair == ["--model", "gemini-2.5-pro"]));
5173        assert!(gemini
5174            .args
5175            .windows(3)
5176            .any(|triple| triple == ["--extensions", "repo", "ci"]));
5177        assert!(gemini
5178            .args
5179            .windows(2)
5180            .any(|pair| pair == ["--allowed-mcp-server-names", "linear"]));
5181        assert!(gemini
5182            .args
5183            .windows(2)
5184            .any(|pair| pair == ["--policy", "default"]));
5185        assert!(gemini
5186            .args
5187            .windows(2)
5188            .any(|pair| pair == ["--admin-policy", "locked"]));
5189        assert!(gemini.args.windows(2).any(|pair| pair == ["-p", ""]));
5190        assert!(gemini
5191            .args
5192            .windows(2)
5193            .any(|pair| pair == ["--approval-mode", "auto_edit"]));
5194    }
5195
5196    #[test]
5197    #[cfg(unix)]
5198    fn gemini_prompt_is_sent_on_stdin_to_avoid_arg_limit() {
5199        use std::os::unix::fs::PermissionsExt;
5200
5201        let temp = tempfile::tempdir().unwrap();
5202        let fake_gemini = temp.path().join("gemini-fake");
5203        fs::write(
5204            &fake_gemini,
5205            "#!/bin/sh\nprintf 'ARGS:'\nfor arg in \"$@\"; do printf '<%s>' \"$arg\"; done\nprintf '\\nSTDIN:'\ncat\n",
5206        )
5207        .unwrap();
5208        let mut permissions = fs::metadata(&fake_gemini).unwrap().permissions();
5209        permissions.set_mode(0o755);
5210        fs::set_permissions(&fake_gemini, permissions).unwrap();
5211
5212        let prompt = "x".repeat(300_000);
5213        let options = EngineRunOptions {
5214            prompt: prompt.clone(),
5215            cwd: std::env::current_dir().unwrap(),
5216            timeout_ms: 5_000,
5217            effort: None,
5218            model: Some("gemini-test".to_string()),
5219            permission: None,
5220            auth: DEFAULT_AUTH_MODE.to_string(),
5221            capability: ProviderCapability {
5222                mode: CAPABILITY_INHERIT.to_string(),
5223                config: vec![],
5224                mcp_profile: None,
5225                mcp_config: vec![],
5226                allowed_tools: vec![],
5227                disallowed_tools: vec![],
5228                tools: vec![],
5229                agent: None,
5230                agents_json: None,
5231                plugin_dirs: vec![],
5232                strict_mcp_config: false,
5233                disable_slash_commands: false,
5234                settings: None,
5235                tools_profile: vec![],
5236                allowed_mcp_servers: vec![],
5237                policy: vec![],
5238                admin_policy: vec![],
5239            },
5240            role: "executor".to_string(),
5241            iteration: 1,
5242            total_iterations: 1,
5243            team_size: 0,
5244            is_sub_agent: false,
5245            live: false,
5246            progress: None,
5247            cancel: None,
5248        };
5249        let result = run_gemini(fake_gemini.to_str().unwrap(), &options);
5250
5251        assert_eq!(result.code, Some(0));
5252        assert!(result.args.iter().any(|arg| arg == "-p"));
5253        assert!(!result.args.iter().any(|arg| arg == &prompt));
5254        assert!(result.stdout.contains("ARGS:<--model><gemini-test><-p><>"));
5255        assert!(result.stdout.contains("<--approval-mode><plan>"));
5256        assert!(result.stdout.ends_with(&prompt));
5257    }
5258
5259    #[test]
5260    fn spawn_failure_status_distinguishes_missing_from_other_spawn_errors() {
5261        assert_eq!(spawn_failure_status("No such file or directory"), "missing");
5262        assert_eq!(
5263            spawn_failure_status("Argument list too long (os error 7)"),
5264            "error"
5265        );
5266    }
5267
5268    #[test]
5269    fn builds_member_prompt_with_roles_and_handoff() {
5270        let workflow = Workflow {
5271            handoff: true,
5272            lead: Some("claude".to_string()),
5273            planner: Some("codex".to_string()),
5274            planner_mode: PLANNER_MODE_BLOCKING.to_string(),
5275            iterations: 2,
5276            team_work: 1,
5277            teams: HashMap::new(),
5278        };
5279        let prompt = build_member_prompt(MemberPromptInput {
5280            query: "Fix the bug",
5281            role: "planner",
5282            workflow: &workflow,
5283            iteration: 1,
5284            team_size: 1,
5285            previous_iteration: &[],
5286            handoff_results: &[],
5287            plan_output: "",
5288        });
5289        assert!(prompt.contains("Amon Hen workflow: iteration 1 of 2."));
5290        assert!(prompt.contains("Lead model: claude."));
5291        assert!(prompt.contains("Planner model: codex."));
5292        assert!(prompt.contains("Your assigned role: planner."));
5293        assert!(prompt.contains("Current user query:"));
5294    }
5295
5296    #[test]
5297    fn parses_claude_stream_json_result() {
5298        let text = r#"{"type":"system","subtype":"status","status":"requesting"}
5299{"type":"result","result":"done"}"#;
5300        assert_eq!(parse_claude_output(text), "done");
5301    }
5302
5303    #[test]
5304    fn parses_gemini_json_response() {
5305        assert_eq!(parse_gemini_output(r#"{"response":"hello"}"#), "hello");
5306    }
5307
5308    #[test]
5309    fn estimates_tokens_with_ceiling_chunks() {
5310        assert_eq!(estimate_tokens(""), 0);
5311        assert_eq!(estimate_tokens("abc"), 1);
5312        assert_eq!(estimate_tokens("abcd"), 1);
5313        assert_eq!(estimate_tokens("abcde"), 2);
5314    }
5315
5316    #[test]
5317    fn extracts_social_login_urls() {
5318        assert_eq!(
5319            extract_auth_urls("Open https://example.com/callback?code=123, then continue"),
5320            vec!["https://example.com/callback?code=123"]
5321        );
5322        assert_eq!(
5323            extract_auth_urls("deeplink: claude://login/complete."),
5324            vec!["claude://login/complete"]
5325        );
5326    }
5327
5328    #[test]
5329    fn parses_provider_token_usage() {
5330        let stdout = r#"{"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}"#;
5331        let usage = extract_token_usage(stdout, "hello", "world").unwrap();
5332        assert_eq!(usage.input, 12);
5333        assert_eq!(usage.output, 8);
5334        assert_eq!(usage.total, 20);
5335        assert!(!usage.estimated);
5336    }
5337
5338    #[test]
5339    fn extracts_tool_usage_from_provider_streams() {
5340        let stdout = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}"#;
5341        let tools = extract_tool_usage("claude", stdout, "");
5342        assert!(tools.iter().any(|tool| tool.name == "Bash"));
5343    }
5344
5345    #[test]
5346    fn provider_stream_json_renders_readable_messages() {
5347        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"}}]}}"#;
5348        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"}}"#;
5349        let gemini = r#"{"candidates":[{"content":{"parts":[{"text":"Gate waterfall evidence is still thin."}]}}]}"#;
5350
5351        assert_eq!(
5352            provider_visible_stream(Some("claude"), claude, &[]),
5353            StreamDisplay::Visible(
5354                "assistant: Inspecting the truth predicates now. | tool: Bash {\"command\":\"git status -sb\"}"
5355                    .to_string()
5356            )
5357        );
5358        let codex_visible = match provider_visible_stream(Some("codex"), codex, &[]) {
5359            StreamDisplay::Visible(text) => text,
5360            StreamDisplay::Suppress => panic!("codex command event should be visible"),
5361        };
5362        assert!(codex_visible.contains("tool: shell"));
5363        assert!(codex_visible.contains("execution/live_router.py"));
5364        assert!(!codex_visible.contains("\"type\":\"item.completed\""));
5365        assert_eq!(
5366            provider_visible_stream(Some("gemini"), gemini, &[]),
5367            StreamDisplay::Visible("assistant: Gate waterfall evidence is still thin.".to_string())
5368        );
5369    }
5370
5371    #[test]
5372    fn claude_stream_event_json_suppresses_input_shards() {
5373        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"}}"#;
5374        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"}}"#;
5375        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"}}"#;
5376
5377        assert_eq!(
5378            provider_visible_stream(Some("claude"), input_delta, &[]),
5379            StreamDisplay::Suppress
5380        );
5381        assert!(extract_tool_usage("claude", input_delta, "").is_empty());
5382
5383        let visible_tool = match provider_visible_stream(Some("claude"), tool_start, &[]) {
5384            StreamDisplay::Visible(text) => text,
5385            StreamDisplay::Suppress => panic!("tool start should be visible"),
5386        };
5387        assert!(visible_tool.contains("tool: Agent"));
5388        assert!(visible_tool.contains("CLOB/WS Timing"));
5389        assert!(!visible_tool.contains("stream_event"));
5390        assert!(!visible_tool.contains("partial_json"));
5391
5392        assert_eq!(
5393            provider_visible_stream(Some("claude"), text_delta, &[]),
5394            StreamDisplay::Visible("assistant: I found the first timing issue.".to_string())
5395        );
5396        assert_eq!(
5397            parse_claude_output(&format!("{text_delta}\n{text_delta}\n")),
5398            "I found the first timing issue.I found the first timing issue."
5399        );
5400    }
5401
5402    #[test]
5403    fn provider_stream_json_suppresses_session_plumbing() {
5404        let claude_hook = r#"{"type":"system","subtype":"hook_response","hook_name":"SessionStart:startup","output":"noise"}"#;
5405        let codex_session = r#"{"type":"session.started","session_id":"abc"}"#;
5406
5407        assert_eq!(
5408            provider_visible_stream(Some("claude"), claude_hook, &[]),
5409            StreamDisplay::Suppress
5410        );
5411        assert_eq!(
5412            provider_visible_stream(Some("codex"), codex_session, &[]),
5413            StreamDisplay::Suppress
5414        );
5415    }
5416
5417    #[test]
5418    fn builds_real_sub_agent_handoff_prompt() {
5419        let agent = EngineResult {
5420            name: "codex".to_string(),
5421            bin: Some("codex".to_string()),
5422            status: "ok".to_string(),
5423            duration_ms: 1,
5424            detail: String::new(),
5425            exit_code: Some(0),
5426            stdout: String::new(),
5427            stderr: String::new(),
5428            output: "inspect parser".to_string(),
5429            command: "codex exec".to_string(),
5430            token_usage: token_usage("a", "b"),
5431            tool_calls: vec![],
5432            sub_agents: vec![],
5433            role: "executor:sub-agent-1".to_string(),
5434            iteration: 1,
5435            total_iterations: 1,
5436            team_size: 0,
5437        };
5438        let prompt = build_team_lead_prompt("ship it", &[agent]);
5439        assert!(prompt.contains("Sub-agent handoffs"));
5440        assert!(prompt.contains("inspect parser"));
5441        assert!(prompt.contains("Original provider prompt"));
5442    }
5443
5444    #[test]
5445    fn json_stream_events_emit_progress_before_final_result() {
5446        let bus = RuntimeEventBus::new();
5447        let events = Arc::new(Mutex::new(Vec::new()));
5448        let captured = Arc::clone(&events);
5449        bus.add_sink(Arc::new(move |event| {
5450            captured.lock().unwrap().push(event);
5451        }));
5452        let progress = bus.progress_sink();
5453
5454        progress(progress_event_with_context(
5455            None,
5456            None,
5457            ProgressStage::Start,
5458            None,
5459            Some(1),
5460            Some(1),
5461            false,
5462            None,
5463            None,
5464            vec![],
5465            "[amon-hen] iteration 1/1 started",
5466        ));
5467        progress(progress_event_with_context(
5468            Some("codex"),
5469            Some("planner"),
5470            ProgressStage::Done,
5471            Some("ok"),
5472            Some(1),
5473            Some(1),
5474            false,
5475            Some(25),
5476            Some(token_usage("prompt", "answer")),
5477            vec![],
5478            "[amon-hen] done codex role=planner status=ok",
5479        ));
5480        let result = test_amon_hen_result(vec![test_engine_result("codex", "planner", 1)], 1);
5481        bus.emit_result(&result);
5482
5483        let events = events.lock().unwrap();
5484        assert!(events.len() >= 3);
5485        assert!(events[..events.len() - 1]
5486            .iter()
5487            .any(|event| event.progress.stage != ProgressStage::Done));
5488        assert!(events
5489            .iter()
5490            .any(|event| event.progress.kind == RuntimeEventKind::TokenUsage));
5491        assert_eq!(
5492            events.last().unwrap().progress.kind,
5493            RuntimeEventKind::Result
5494        );
5495        assert!(events.last().unwrap().result.is_some());
5496        for event in events.iter() {
5497            serde_json::to_string(event).expect("json-stream event should serialize");
5498        }
5499    }
5500
5501    #[test]
5502    fn runtime_event_bus_preserves_stream_sequence_order_under_fanout() {
5503        let bus = RuntimeEventBus::new();
5504        let events = Arc::new(Mutex::new(Vec::new()));
5505        let captured = Arc::clone(&events);
5506        bus.add_sink(Arc::new(move |event| {
5507            captured.lock().unwrap().push(event.sequence);
5508        }));
5509        let barrier = Arc::new(std::sync::Barrier::new(16));
5510        let mut handles = Vec::new();
5511
5512        for index in 0..16 {
5513            let progress = bus.progress_sink();
5514            let barrier = Arc::clone(&barrier);
5515            handles.push(thread::spawn(move || {
5516                barrier.wait();
5517                progress(progress_event(
5518                    Some("codex"),
5519                    Some("planner"),
5520                    ProgressStage::Heartbeat,
5521                    Some("running"),
5522                    format!("[amon-hen] fanout event {index}"),
5523                ));
5524            }));
5525        }
5526        for handle in handles {
5527            handle.join().unwrap();
5528        }
5529
5530        let sequences = events.lock().unwrap().clone();
5531        assert_eq!(sequences, (1..=16).collect::<Vec<_>>());
5532    }
5533
5534    #[test]
5535    fn multiple_iterations_preserve_timeline_entries() {
5536        let first = vec![test_engine_result("codex", "planner", 1)];
5537        let second = vec![test_engine_result("claude", "lead", 2)];
5538        let timeline = vec![
5539            iteration_record(1, 2, first.clone(), 10, None, None),
5540            iteration_record(
5541                2,
5542                2,
5543                second.clone(),
5544                15,
5545                iteration_handoff_context(&first, DEFAULT_MAX_MEMBER_CHARS),
5546                Some("summary context".to_string()),
5547            ),
5548        ];
5549        let result = test_amon_hen_result_with_iterations(second, timeline);
5550
5551        assert_eq!(result.iterations.len(), 2);
5552        assert_eq!(result.iterations[0].iteration, 1);
5553        assert_eq!(result.iterations[1].iteration, 2);
5554        assert!(result.iterations[1].handoff_context.is_some());
5555        assert_eq!(result.members[0].name, "claude");
5556    }
5557
5558    #[test]
5559    fn sub_agent_roles_remain_correct_in_timeline() {
5560        let mut member = test_engine_result("gemini", "lead", 1);
5561        member.sub_agents = vec![
5562            test_engine_result("gemini", "lead:sub-agent-1", 1),
5563            test_engine_result("gemini", "lead:sub-agent-2", 1),
5564        ];
5565        let timeline = vec![iteration_record(1, 1, vec![member], 20, None, None)];
5566
5567        assert_eq!(
5568            timeline[0]
5569                .sub_agents
5570                .iter()
5571                .map(|agent| agent.role.as_str())
5572                .collect::<Vec<_>>(),
5573            vec!["lead:sub-agent-1", "lead:sub-agent-2"]
5574        );
5575        assert_eq!(timeline[0].members[0].role, "lead");
5576    }
5577
5578    #[test]
5579    fn runtime_role_matrix_reaches_json_result_and_stream_events() {
5580        let _lock = ENV_LOCK.lock().unwrap();
5581        let Some(_env) = ProviderEnvGuard::install_echo_bins() else {
5582            return;
5583        };
5584        let args = CliArgs::try_parse_from([
5585            "amon-hen",
5586            "--members",
5587            "codex,claude,gemini",
5588            "--planner",
5589            "codex",
5590            "--lead",
5591            "claude",
5592            "--handoff",
5593            "--iterations",
5594            "2",
5595            "--team-work",
5596            "1",
5597            "--codex-sub-agents",
5598            "2",
5599            "--claude-sub-agents",
5600            "1",
5601            "--gemini-sub-agents",
5602            "0",
5603            "--timeout",
5604            "5",
5605            "role matrix smoke",
5606        ])
5607        .unwrap();
5608        let resolved = resolve_args(args).unwrap();
5609        let bus = RuntimeEventBus::new();
5610        let events = Arc::new(Mutex::new(Vec::new()));
5611        let captured = Arc::clone(&events);
5612        bus.add_sink(Arc::new(move |event| {
5613            captured.lock().unwrap().push(event);
5614        }));
5615        let prompt_context =
5616            build_prompt_context_with_progress(&resolved, Some(bus.progress_sink())).unwrap();
5617        let result = run_amon_hen_with_progress_and_cancel(
5618            &resolved,
5619            prompt_context.prompt,
5620            prompt_context.commands,
5621            Some(bus.progress_sink()),
5622            None,
5623        );
5624        bus.emit_result(&result);
5625
5626        assert_eq!(result.workflow.iterations, 2);
5627        assert_eq!(result.workflow.teams.get("codex"), Some(&2));
5628        assert_eq!(result.workflow.teams.get("claude"), Some(&1));
5629        assert_eq!(result.workflow.teams.get("gemini"), Some(&0));
5630        assert_eq!(result.iterations.len(), 2);
5631        for iteration in &result.iterations {
5632            assert_eq!(
5633                iteration
5634                    .members
5635                    .iter()
5636                    .map(|member| (member.name.as_str(), member.role.as_str()))
5637                    .collect::<Vec<_>>(),
5638                vec![
5639                    ("codex", "planner"),
5640                    ("gemini", "executor"),
5641                    ("claude", "lead")
5642                ]
5643            );
5644            assert_eq!(
5645                iteration
5646                    .sub_agents
5647                    .iter()
5648                    .map(|agent| (agent.name.as_str(), agent.role.as_str()))
5649                    .collect::<Vec<_>>(),
5650                vec![
5651                    ("codex", "planner:sub-agent-1"),
5652                    ("codex", "planner:sub-agent-2"),
5653                    ("claude", "lead:sub-agent-1")
5654                ]
5655            );
5656        }
5657
5658        let events = events.lock().unwrap();
5659        assert!(events.iter().any(|event| {
5660            event.progress.provider.as_deref() == Some("codex")
5661                && event.progress.role.as_deref() == Some("planner")
5662                && !event.progress.is_sub_agent
5663        }));
5664        assert!(events.iter().any(|event| {
5665            event.progress.provider.as_deref() == Some("claude")
5666                && event.progress.role.as_deref() == Some("lead:sub-agent-1")
5667                && event.progress.is_sub_agent
5668        }));
5669        let final_event = events.last().expect("final result event should exist");
5670        assert_eq!(final_event.progress.kind, RuntimeEventKind::Result);
5671        assert!(final_event.result.is_some());
5672    }
5673
5674    #[test]
5675    fn per_provider_sub_agent_overrides_are_reflected_in_prompts() {
5676        let args = CliArgs::try_parse_from([
5677            "amon-hen",
5678            "--members",
5679            "codex,gemini",
5680            "--team-work",
5681            "2",
5682            "--codex-sub-agents",
5683            "0",
5684            "--gemini-sub-agents",
5685            "3",
5686            "ship",
5687        ])
5688        .unwrap();
5689        let resolved = resolve_args(args).unwrap();
5690        let workflow = build_workflow(&resolved);
5691        let codex_prompt = build_member_prompt(MemberPromptInput {
5692            query: "ship",
5693            role: "executor",
5694            workflow: &workflow,
5695            iteration: 1,
5696            team_size: effective_team_size(&workflow, "codex"),
5697            previous_iteration: &[],
5698            handoff_results: &[],
5699            plan_output: "",
5700        });
5701        let gemini_prompt = build_member_prompt(MemberPromptInput {
5702            query: "ship",
5703            role: "executor",
5704            workflow: &workflow,
5705            iteration: 1,
5706            team_size: effective_team_size(&workflow, "gemini"),
5707            previous_iteration: &[],
5708            handoff_results: &[],
5709            plan_output: "",
5710        });
5711
5712        assert!(!codex_prompt.contains("Team work:"));
5713        assert!(gemini_prompt.contains("up to 3 internal sub-agents"));
5714    }
5715
5716    #[test]
5717    fn duplicate_members_are_deduped_in_input_order() {
5718        let args = CliArgs::try_parse_from([
5719            "amon-hen",
5720            "--members",
5721            "codex,claude,codex,gemini,claude",
5722            "hello",
5723        ])
5724        .unwrap();
5725        let resolved = resolve_args(args).unwrap();
5726        assert_eq!(resolved.members, vec!["codex", "claude", "gemini"]);
5727    }
5728
5729    #[test]
5730    fn command_cancel_stops_child_process_promptly() {
5731        if !command_available("sh") {
5732            return;
5733        }
5734        let cwd = std::env::current_dir().unwrap();
5735        let cancel = Arc::new(AtomicBool::new(true));
5736        let started = Instant::now();
5737        let args = ["-c".to_string(), "sleep 30".to_string()];
5738        let result =
5739            run_command(CommandRequest::new("sh", &args, &cwd, 30_000).cancel(Some(cancel)));
5740
5741        assert!(result.cancelled);
5742        assert_eq!(result.error.as_deref(), Some("cancelled"));
5743        assert!(
5744            started.elapsed() < Duration::from_secs(3),
5745            "cancelled command took {:?}",
5746            started.elapsed()
5747        );
5748    }
5749
5750    #[test]
5751    fn command_streams_visible_output_and_live_token_usage() {
5752        if !command_available("sh") {
5753            return;
5754        }
5755        let cwd = std::env::current_dir().unwrap();
5756        let events = Arc::new(Mutex::new(Vec::new()));
5757        let captured = Arc::clone(&events);
5758        let progress: ProgressSink = Arc::new(move |event| {
5759            captured.lock().unwrap().push(event);
5760        });
5761        let args = [
5762            "-c".to_string(),
5763            "printf 'visible-work\\n'; printf '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"name\":\"Bash\"}]}}\\n'"
5764                .to_string(),
5765        ];
5766
5767        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
5768            CommandProgress {
5769                label: "codex planner:sub-agent-1 iteration 1/1".to_string(),
5770                sink: Some(progress),
5771                input_tokens: 10,
5772            },
5773        )));
5774
5775        assert_eq!(result.code, Some(0));
5776        let events = events.lock().unwrap();
5777        assert!(events.iter().any(|event| {
5778            event.message.contains("visible-work")
5779                && event
5780                    .token_usage
5781                    .as_ref()
5782                    .is_some_and(|usage| usage.total > 10)
5783        }));
5784        assert!(events.iter().any(|event| {
5785            event.kind == RuntimeEventKind::ToolUsage
5786                && event.tool_calls.iter().any(|tool| tool.name == "Bash")
5787        }));
5788    }
5789
5790    #[test]
5791    fn command_stream_sampling_preserves_important_tool_events() {
5792        if !command_available("sh") {
5793            return;
5794        }
5795        let cwd = std::env::current_dir().unwrap();
5796        let events = Arc::new(Mutex::new(Vec::new()));
5797        let captured = Arc::clone(&events);
5798        let progress: ProgressSink = Arc::new(move |event| {
5799            captured.lock().unwrap().push(event);
5800        });
5801        let args = [
5802            "-c".to_string(),
5803            "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'"
5804                .to_string(),
5805        ];
5806
5807        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
5808            CommandProgress {
5809                label: "codex planner iteration 1/1".to_string(),
5810                sink: Some(progress),
5811                input_tokens: 10,
5812            },
5813        )));
5814
5815        assert_eq!(result.code, Some(0));
5816        let events = events.lock().unwrap();
5817        let streaming_events = events
5818            .iter()
5819            .filter(|event| event.status.as_deref() == Some("streaming"))
5820            .count();
5821        assert!(
5822            streaming_events < 20,
5823            "expected sampled streaming events, got {streaming_events}"
5824        );
5825        assert!(events.iter().any(|event| {
5826            event.kind == RuntimeEventKind::ToolUsage
5827                && event.tool_calls.iter().any(|tool| tool.name == "Bash")
5828        }));
5829    }
5830
5831    #[test]
5832    fn command_stream_decodes_provider_json_before_dashboard_events() {
5833        if !command_available("sh") {
5834            return;
5835        }
5836        let cwd = std::env::current_dir().unwrap();
5837        let events = Arc::new(Mutex::new(Vec::new()));
5838        let captured = Arc::clone(&events);
5839        let progress: ProgressSink = Arc::new(move |event| {
5840            captured.lock().unwrap().push(event);
5841        });
5842        let args = [
5843            "-c".to_string(),
5844            "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\"}}'"
5845                .to_string(),
5846        ];
5847
5848        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
5849            CommandProgress {
5850                label: "codex executor iteration 1/1".to_string(),
5851                sink: Some(progress),
5852                input_tokens: 10,
5853            },
5854        )));
5855
5856        assert_eq!(result.code, Some(0));
5857        let events = events.lock().unwrap();
5858        assert!(!events
5859            .iter()
5860            .any(|event| event.message.contains("session.started")));
5861        assert!(events.iter().any(|event| {
5862            event.message.contains("tool: shell")
5863                && event.message.contains("line one")
5864                && !event.message.contains("\"type\":\"item.completed\"")
5865        }));
5866    }
5867
5868    #[test]
5869    fn claude_text_deltas_stream_as_coalesced_live_answer() {
5870        if !command_available("sh") {
5871            return;
5872        }
5873        let cwd = std::env::current_dir().unwrap();
5874        let events = Arc::new(Mutex::new(Vec::new()));
5875        let captured = Arc::clone(&events);
5876        let progress: ProgressSink = Arc::new(move |event| {
5877            captured.lock().unwrap().push(event);
5878        });
5879        let script = concat!(
5880            "printf '%s\\n' '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Reviewing \"}}}';",
5881            "printf '%s\\n' '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"gates now.\"}}}'"
5882        );
5883        let args = ["-c".to_string(), script.to_string()];
5884
5885        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
5886            CommandProgress {
5887                label: "claude lead+planner iteration 1/1".to_string(),
5888                sink: Some(progress),
5889                input_tokens: 10,
5890            },
5891        )));
5892
5893        assert_eq!(result.code, Some(0));
5894        let events = events.lock().unwrap();
5895        assert!(events.iter().any(|event| {
5896            event
5897                .message
5898                .contains("assistant live: Reviewing gates now.")
5899                && event.provider.as_deref() == Some("claude")
5900        }));
5901        assert!(!events.iter().any(|event| {
5902            event.message.contains("stdout: assistant: Reviewing ")
5903                || event.message.contains("stdout: assistant: gates now.")
5904        }));
5905    }
5906
5907    #[test]
5908    fn missing_provider_preflight_avoids_team_fanout() {
5909        let args = CliArgs::try_parse_from([
5910            "amon-hen",
5911            "--members",
5912            "codex",
5913            "--team-work",
5914            "5",
5915            "hello",
5916        ])
5917        .unwrap();
5918        let resolved = resolve_args(args).unwrap();
5919        let options = EngineRunOptions {
5920            prompt: "hello".to_string(),
5921            cwd: resolved.cwd.clone(),
5922            timeout_ms: 1_000,
5923            effort: None,
5924            model: None,
5925            permission: None,
5926            auth: DEFAULT_AUTH_MODE.to_string(),
5927            capability: provider_capability(&resolved, "codex"),
5928            role: "planner".to_string(),
5929            iteration: 1,
5930            total_iterations: 1,
5931            team_size: 5,
5932            is_sub_agent: false,
5933            live: false,
5934            progress: None,
5935            cancel: None,
5936        };
5937
5938        let result = run_engine("definitely-missing-amon-hen-test-bin", options);
5939
5940        assert_eq!(result.status, "missing");
5941        assert!(result.sub_agents.is_empty());
5942        assert!(result.detail.contains("not found in PATH"));
5943    }
5944
5945    #[test]
5946    fn system_time_is_available_for_test_environment() {
5947        assert!(std::time::SystemTime::now()
5948            .duration_since(std::time::UNIX_EPOCH)
5949            .is_ok());
5950    }
5951
5952    #[test]
5953    fn cli_hygiene_has_no_legacy_npm_cli_or_council_binary_references() {
5954        let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
5955        let repo_root = crate_dir
5956            .parent()
5957            .and_then(Path::parent)
5958            .expect("crate lives under crates/amon-hen");
5959        assert!(!repo_root.join("package.json").exists());
5960        assert!(!repo_root.join("package-lock.json").exists());
5961        assert!(!repo_root.join("npm").exists());
5962        assert!(!repo_root.join("bin").join("council").exists());
5963
5964        let docs = [
5965            repo_root.join("README.md"),
5966            crate_dir.join("README.md"),
5967            crate_dir.join("CHANGELOG.md"),
5968        ];
5969        for path in docs {
5970            let text = fs::read_to_string(&path).unwrap();
5971            assert!(
5972                !text.contains("`council`") && !text.contains(" council "),
5973                "legacy binary reference remains in {}",
5974                path.display()
5975            );
5976        }
5977
5978        let help = render_cli_help();
5979        assert!(!help.contains("council"));
5980        assert!(help.contains("amon-hen"));
5981    }
5982
5983    fn test_amon_hen_result(members: Vec<EngineResult>, total_iterations: usize) -> AmonHenResult {
5984        let timeline = vec![iteration_record(
5985            total_iterations,
5986            total_iterations,
5987            members.clone(),
5988            25,
5989            None,
5990            Some("summary context".to_string()),
5991        )];
5992        test_amon_hen_result_with_iterations(members, timeline)
5993    }
5994
5995    fn test_amon_hen_result_with_iterations(
5996        members: Vec<EngineResult>,
5997        iterations: Vec<IterationRecord>,
5998    ) -> AmonHenResult {
5999        let mut teams = HashMap::new();
6000        teams.insert("codex".to_string(), 0);
6001        teams.insert("claude".to_string(), 0);
6002        teams.insert("gemini".to_string(), 0);
6003        let workflow = Workflow {
6004            handoff: true,
6005            lead: Some("claude".to_string()),
6006            planner: Some("codex".to_string()),
6007            planner_mode: PLANNER_MODE_BLOCKING.to_string(),
6008            iterations: iterations.len().max(1),
6009            team_work: 0,
6010            teams,
6011        };
6012        AmonHenResult {
6013            query: "ship it".to_string(),
6014            cwd: ".".to_string(),
6015            members_requested: members.iter().map(|member| member.name.clone()).collect(),
6016            summarizer_requested: "auto".to_string(),
6017            workflow,
6018            prompt_commands: vec![],
6019            iterations,
6020            members,
6021            summary: test_engine_result("codex", "summary", 1),
6022        }
6023    }
6024
6025    fn test_engine_result(name: &str, role: &str, iteration: usize) -> EngineResult {
6026        EngineResult {
6027            name: name.to_string(),
6028            bin: Some(name.to_string()),
6029            status: "ok".to_string(),
6030            duration_ms: 10,
6031            detail: String::new(),
6032            exit_code: Some(0),
6033            stdout: String::new(),
6034            stderr: String::new(),
6035            output: format!("{name} output"),
6036            command: format!("{name} exec"),
6037            token_usage: token_usage("prompt", "output"),
6038            tool_calls: vec![ToolUsage {
6039                name: "tool".to_string(),
6040                kind: name.to_string(),
6041                status: "observed".to_string(),
6042                detail: "detail".to_string(),
6043            }],
6044            sub_agents: vec![],
6045            role: role.to_string(),
6046            iteration,
6047            total_iterations: iteration.max(1),
6048            team_size: 0,
6049        }
6050    }
6051}