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