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 cleaned = strip_terminal_control_sequences(detail);
1285    let detail = cleaned.trim();
1286    if detail.is_empty() {
1287        return "No status detail returned.".to_string();
1288    }
1289    let detail = detail
1290        .lines()
1291        .take(6)
1292        .map(str::trim)
1293        .filter(|line| !line.is_empty())
1294        .map(redact_possible_secret)
1295        .collect::<Vec<_>>()
1296        .join(" ");
1297    redact_local_paths(&detail)
1298}
1299
1300fn strip_terminal_control_sequences(value: &str) -> String {
1301    let chars = value.chars().collect::<Vec<_>>();
1302    let mut output = String::with_capacity(value.len());
1303    let mut index = 0;
1304    while index < chars.len() {
1305        if chars[index] == '\x1b' {
1306            index = skip_terminal_escape(&chars, index + 1);
1307            continue;
1308        }
1309        if chars[index] == '\u{9b}' {
1310            index = skip_csi_sequence(&chars, index + 1);
1311            continue;
1312        }
1313        if let Some(after_escape) = escaped_escape_end(&chars, index) {
1314            index = skip_terminal_escape(&chars, after_escape);
1315            continue;
1316        }
1317        let ch = chars[index];
1318        if ch.is_control() && !matches!(ch, '\n' | '\r' | '\t') {
1319            output.push(' ');
1320        } else {
1321            output.push(ch);
1322        }
1323        index += 1;
1324    }
1325    output
1326}
1327
1328fn escaped_escape_end(chars: &[char], index: usize) -> Option<usize> {
1329    if chars.get(index) != Some(&'\\') {
1330        return None;
1331    }
1332    let mut cursor = index;
1333    while chars.get(cursor) == Some(&'\\') {
1334        cursor += 1;
1335    }
1336    if starts_with_chars(chars, cursor, &['u', '0', '0', '1', 'b'])
1337        || starts_with_chars(chars, cursor, &['u', '0', '0', '1', 'B'])
1338    {
1339        return Some(cursor + 5);
1340    }
1341    if starts_with_chars(chars, cursor, &['u', '{', '1', 'b', '}'])
1342        || starts_with_chars(chars, cursor, &['u', '{', '1', 'B', '}'])
1343    {
1344        return Some(cursor + 5);
1345    }
1346    if starts_with_chars(chars, cursor, &['x', '1', 'b'])
1347        || starts_with_chars(chars, cursor, &['x', '1', 'B'])
1348    {
1349        return Some(cursor + 3);
1350    }
1351    if starts_with_chars(chars, cursor, &['0', '3', '3']) {
1352        return Some(cursor + 3);
1353    }
1354    if starts_with_chars(chars, cursor, &['e']) {
1355        return Some(cursor + 1);
1356    }
1357    None
1358}
1359
1360fn starts_with_chars(chars: &[char], index: usize, needle: &[char]) -> bool {
1361    chars
1362        .get(index..index.saturating_add(needle.len()))
1363        .is_some_and(|slice| slice == needle)
1364}
1365
1366fn skip_terminal_escape(chars: &[char], index: usize) -> usize {
1367    let Some(ch) = chars.get(index).copied() else {
1368        return index;
1369    };
1370    match ch {
1371        '[' => skip_csi_sequence(chars, index + 1),
1372        ']' => skip_terminated_control_string(chars, index + 1),
1373        'P' | '^' | '_' | 'X' => skip_terminated_control_string(chars, index + 1),
1374        '(' | ')' | '*' | '+' | '-' | '.' | '/' => (index + 2).min(chars.len()),
1375        '@'..='_' => (index + 1).min(chars.len()),
1376        _ => index,
1377    }
1378}
1379
1380fn skip_csi_sequence(chars: &[char], mut index: usize) -> usize {
1381    while index < chars.len() {
1382        let ch = chars[index];
1383        index += 1;
1384        if ('@'..='~').contains(&ch) {
1385            break;
1386        }
1387    }
1388    index
1389}
1390
1391fn skip_terminated_control_string(chars: &[char], mut index: usize) -> usize {
1392    while index < chars.len() {
1393        if chars[index] == '\u{7}' {
1394            return index + 1;
1395        }
1396        if chars[index] == '\x1b' && chars.get(index + 1) == Some(&'\\') {
1397            return index + 2;
1398        }
1399        if let Some(after_escape) = escaped_escape_end(chars, index) {
1400            if chars.get(after_escape) == Some(&'\\') {
1401                return after_escape + 1;
1402            }
1403            index = after_escape;
1404            continue;
1405        }
1406        index += 1;
1407    }
1408    index
1409}
1410
1411fn redact_possible_secret(value: &str) -> String {
1412    value
1413        .split_whitespace()
1414        .map(|token| {
1415            if looks_sensitive_token(token) {
1416                "[redacted]"
1417            } else if looks_like_email(token) {
1418                "[email]"
1419            } else {
1420                token
1421            }
1422        })
1423        .collect::<Vec<_>>()
1424        .join(" ")
1425}
1426
1427fn looks_sensitive_token(token: &str) -> bool {
1428    let trimmed =
1429        token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_');
1430    trimmed.len() >= 28
1431        && (trimmed.starts_with("sk-")
1432            || trimmed.starts_with("sk_")
1433            || trimmed.starts_with("gho_")
1434            || trimmed.starts_with("ghp_")
1435            || trimmed.starts_with("ghs_")
1436            || trimmed.starts_with("ghu_")
1437            || trimmed.starts_with("AIza")
1438            || trimmed.starts_with("AQ."))
1439}
1440
1441fn looks_like_email(token: &str) -> bool {
1442    let trimmed = token.trim_matches(|ch: char| {
1443        !ch.is_ascii_alphanumeric() && !matches!(ch, '@' | '.' | '_' | '-' | '+')
1444    });
1445    trimmed.contains('@') && trimmed.rsplit_once('.').is_some()
1446}
1447
1448fn redact_local_paths(value: &str) -> String {
1449    let mut redacted = value.to_string();
1450    if let Ok(home) = std::env::var("HOME") {
1451        if !home.trim().is_empty() {
1452            redacted = redacted.replace(&home, "~");
1453        }
1454    }
1455    redacted
1456}
1457
1458fn render_auth_statuses(statuses: &[ProviderAuthStatus]) -> String {
1459    let mut lines = vec!["Provider auth status".to_string()];
1460    for status in statuses {
1461        lines.push(format!(
1462            "- {}: {} via {} ({})",
1463            status.provider, status.status, status.source, status.detail
1464        ));
1465    }
1466    lines.join("\n")
1467}
1468
1469fn collect_provider_capability_statuses(resolved: &ResolvedArgs) -> Vec<ProviderCapabilityStatus> {
1470    collect_provider_capability_statuses_with_cancel(resolved, None)
1471}
1472
1473fn collect_provider_capability_statuses_with_cancel(
1474    resolved: &ResolvedArgs,
1475    cancel: Option<Arc<AtomicBool>>,
1476) -> Vec<ProviderCapabilityStatus> {
1477    resolved
1478        .members
1479        .iter()
1480        .map(|provider| provider_capability_status(provider, resolved, cancel.clone()))
1481        .collect()
1482}
1483
1484fn provider_capability_status(
1485    provider: &str,
1486    resolved: &ResolvedArgs,
1487    cancel: Option<Arc<AtomicBool>>,
1488) -> ProviderCapabilityStatus {
1489    match provider {
1490        "codex" => {
1491            let bin = resolve_binary("codex");
1492            let mcp = run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone());
1493            ProviderCapabilityStatus {
1494                provider: provider.to_string(),
1495                mcp,
1496                skills: None,
1497                tools: Some(run_capability_probe(
1498                    &bin,
1499                    &["plugin", "marketplace", "--help"],
1500                    &resolved.cwd,
1501                    cancel,
1502                )),
1503                detail:
1504                    "Codex inherits ~/.codex config unless --codex-capabilities override is set."
1505                        .to_string(),
1506            }
1507        }
1508        "claude" => {
1509            let bin = resolve_binary("claude");
1510            ProviderCapabilityStatus {
1511                provider: provider.to_string(),
1512                mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
1513                skills: Some(run_capability_probe(
1514                    &bin,
1515                    &["plugin", "list"],
1516                    &resolved.cwd,
1517                    cancel.clone(),
1518                )),
1519                tools: Some(run_capability_probe(
1520                    &bin,
1521                    &["agents"],
1522                    &resolved.cwd,
1523                    cancel,
1524                )),
1525                detail: "Claude override can manage MCP config, tools, agents, plugin dirs, and slash-command skills.".to_string(),
1526            }
1527        }
1528        "gemini" => {
1529            let bin = resolve_binary("gemini");
1530            ProviderCapabilityStatus {
1531                provider: provider.to_string(),
1532                mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
1533                skills: Some(run_capability_probe(
1534                    &bin,
1535                    &["skills", "list"],
1536                    &resolved.cwd,
1537                    cancel.clone(),
1538                )),
1539                tools: Some(run_capability_probe(
1540                    &bin,
1541                    &["extensions", "list"],
1542                    &resolved.cwd,
1543                    cancel,
1544                )),
1545                detail: "Gemini override can manage settings, extensions, MCP server allowlists, and policy files.".to_string(),
1546            }
1547        }
1548        _ => ProviderCapabilityStatus {
1549            provider: provider.to_string(),
1550            mcp: CommandTelemetry {
1551                command: provider.to_string(),
1552                status: "unknown".to_string(),
1553                detail: "Unknown provider.".to_string(),
1554                exit_code: None,
1555                duration_ms: 0,
1556                stdout_chars: 0,
1557                stderr_chars: 0,
1558                timed_out: false,
1559            },
1560            skills: None,
1561            tools: None,
1562            detail: "Unknown provider.".to_string(),
1563        },
1564    }
1565}
1566
1567fn run_capability_probe(
1568    bin: &str,
1569    args: &[&str],
1570    cwd: &Path,
1571    cancel: Option<Arc<AtomicBool>>,
1572) -> CommandTelemetry {
1573    let args = args
1574        .iter()
1575        .map(|arg| (*arg).to_string())
1576        .collect::<Vec<_>>();
1577    command_telemetry(&run_command(
1578        CommandRequest::new(bin, &args, cwd, 20_000).cancel(cancel),
1579    ))
1580}
1581
1582fn emit_runtime_event(
1583    progress: &Option<ProgressSink>,
1584    fallback_to_stderr: bool,
1585    event: ProgressEvent,
1586) {
1587    if let Some(progress) = progress {
1588        progress(event);
1589    } else if fallback_to_stderr {
1590        eprintln!("{}", event.message);
1591    }
1592}
1593
1594impl RuntimeEventBus {
1595    fn new() -> Self {
1596        Self {
1597            started: Instant::now(),
1598            sequence: Arc::new(AtomicU64::new(1)),
1599            emit_lock: Arc::new(Mutex::new(())),
1600            sinks: Arc::new(Mutex::new(Vec::new())),
1601        }
1602    }
1603
1604    fn add_sink(&self, sink: RuntimeEventSink) {
1605        if let Ok(mut sinks) = self.sinks.lock() {
1606            sinks.push(sink);
1607        }
1608    }
1609
1610    fn progress_sink(&self) -> ProgressSink {
1611        let bus = self.clone();
1612        Arc::new(move |event| bus.emit(event, None, None))
1613    }
1614
1615    fn emit_result(&self, result: &AmonHenResult) {
1616        let status = if is_success(result) { "ok" } else { "error" };
1617        self.emit(
1618            ProgressEvent {
1619                kind: RuntimeEventKind::Result,
1620                provider: Some(result.summary.name.clone()),
1621                role: Some(result.summary.role.clone()),
1622                stage: ProgressStage::Done,
1623                status: Some(status.to_string()),
1624                iteration: Some(result.workflow.iterations),
1625                total_iterations: Some(result.workflow.iterations),
1626                is_sub_agent: false,
1627                duration_ms: Some(result.summary.duration_ms),
1628                token_usage: Some(result.summary.token_usage.clone()),
1629                tool_calls: result.summary.tool_calls.clone(),
1630                message: format!("[amon-hen] result {status}"),
1631            },
1632            Some(Box::new(result.clone())),
1633            None,
1634        );
1635    }
1636
1637    fn emit_linear_result(&self, result: &linear_delivery::LinearDeliveryResult) {
1638        let status = if result.success { "ok" } else { "error" };
1639        self.emit(
1640            progress_event_with_context(
1641                Some("linear"),
1642                Some("delivery"),
1643                ProgressStage::Done,
1644                Some(status),
1645                None,
1646                None,
1647                false,
1648                Some(result.duration_ms),
1649                None,
1650                vec![],
1651                format!("[amon-hen] linear delivery {status}"),
1652            ),
1653            None,
1654            Some(Box::new(result.clone())),
1655        );
1656    }
1657
1658    fn emit(
1659        &self,
1660        progress: ProgressEvent,
1661        result: Option<Box<AmonHenResult>>,
1662        linear_result: Option<Box<linear_delivery::LinearDeliveryResult>>,
1663    ) {
1664        let _emit_guard = self.emit_lock.lock().ok();
1665        let event = RuntimeEvent {
1666            sequence: self.sequence.fetch_add(1, Ordering::SeqCst),
1667            elapsed_ms: self.started.elapsed().as_millis(),
1668            progress,
1669            result,
1670            linear_result,
1671        };
1672        let sinks = self
1673            .sinks
1674            .lock()
1675            .map(|sinks| sinks.clone())
1676            .unwrap_or_default();
1677        for sink in sinks {
1678            sink(event.clone());
1679        }
1680    }
1681}
1682
1683fn progress_event(
1684    provider: Option<&str>,
1685    role: Option<&str>,
1686    stage: ProgressStage,
1687    status: Option<&str>,
1688    message: impl Into<String>,
1689) -> ProgressEvent {
1690    progress_event_with_context(
1691        provider,
1692        role,
1693        stage,
1694        status,
1695        None,
1696        None,
1697        false,
1698        None,
1699        None,
1700        vec![],
1701        message,
1702    )
1703}
1704
1705#[allow(clippy::too_many_arguments)]
1706fn progress_event_with_context(
1707    provider: Option<&str>,
1708    role: Option<&str>,
1709    stage: ProgressStage,
1710    status: Option<&str>,
1711    iteration: Option<usize>,
1712    total_iterations: Option<usize>,
1713    is_sub_agent: bool,
1714    duration_ms: Option<u128>,
1715    token_usage: Option<TokenUsage>,
1716    tool_calls: Vec<ToolUsage>,
1717    message: impl Into<String>,
1718) -> ProgressEvent {
1719    ProgressEvent {
1720        kind: runtime_event_kind(provider, stage, token_usage.as_ref(), &tool_calls),
1721        provider: provider.map(ToString::to_string),
1722        role: role.map(ToString::to_string),
1723        stage,
1724        status: status.map(ToString::to_string),
1725        iteration,
1726        total_iterations,
1727        is_sub_agent,
1728        duration_ms,
1729        token_usage,
1730        tool_calls,
1731        message: message.into(),
1732    }
1733}
1734
1735fn runtime_event_kind(
1736    provider: Option<&str>,
1737    stage: ProgressStage,
1738    token_usage: Option<&TokenUsage>,
1739    tool_calls: &[ToolUsage],
1740) -> RuntimeEventKind {
1741    if !tool_calls.is_empty() {
1742        RuntimeEventKind::ToolUsage
1743    } else if token_usage.is_some() {
1744        RuntimeEventKind::TokenUsage
1745    } else if provider.is_some() {
1746        RuntimeEventKind::Provider
1747    } else if matches!(stage, ProgressStage::Context) {
1748        RuntimeEventKind::Context
1749    } else {
1750        RuntimeEventKind::Iteration
1751    }
1752}
1753
1754fn command_progress(label: Option<&str>, sink: Option<ProgressSink>) -> Option<CommandProgress> {
1755    command_progress_with_input(label, sink, 0)
1756}
1757
1758fn command_progress_with_input(
1759    label: Option<&str>,
1760    sink: Option<ProgressSink>,
1761    input_tokens: usize,
1762) -> Option<CommandProgress> {
1763    label.map(|label| CommandProgress {
1764        label: label.to_string(),
1765        sink,
1766        input_tokens,
1767    })
1768}
1769
1770fn render_provider_capability_statuses(statuses: &[ProviderCapabilityStatus]) -> String {
1771    let mut lines = vec!["Provider capabilities".to_string()];
1772    for status in statuses {
1773        lines.push(format!(
1774            "- {} MCP: {} ({})",
1775            status.provider, status.mcp.status, status.mcp.command
1776        ));
1777        if !status.mcp.detail.trim().is_empty() {
1778            lines.push(format!("  {}", status.mcp.detail));
1779        }
1780        if let Some(skills) = &status.skills {
1781            lines.push(format!(
1782                "  skills/plugins: {} ({})",
1783                skills.status, skills.command
1784            ));
1785            if !skills.detail.trim().is_empty() {
1786                lines.push(format!("  {}", skills.detail));
1787            }
1788        }
1789        if let Some(tools) = &status.tools {
1790            lines.push(format!(
1791                "  tools/extensions: {} ({})",
1792                tools.status, tools.command
1793            ));
1794            if !tools.detail.trim().is_empty() {
1795                lines.push(format!("  {}", tools.detail));
1796            }
1797        }
1798        lines.push(format!("  {}", status.detail));
1799    }
1800    lines.join("\n")
1801}
1802
1803fn run_social_login(resolved: &ResolvedArgs) -> Result<(), String> {
1804    let providers = if resolved.raw.auth_login_providers.is_empty() {
1805        resolved.members.clone()
1806    } else {
1807        resolved.raw.auth_login_providers.clone()
1808    };
1809
1810    for provider in providers {
1811        validate_engine_name(&provider, false, "--auth-login-providers")?;
1812        let (bin, args, instruction): (String, Vec<String>, &str) =
1813            match provider.as_str() {
1814                "codex" => {
1815                    let mut args = vec!["login".to_string()];
1816                    if resolved.raw.auth_device_code {
1817                        args.push("--device-auth".to_string());
1818                    }
1819                    (
1820                        resolve_binary("codex"),
1821                        args,
1822                        "Complete the Codex browser login. Deeplinks and pasted codes are supported by the provider CLI when prompted.",
1823                    )
1824                }
1825                "claude" => {
1826                    let mut args = vec!["auth".to_string(), "login".to_string()];
1827                    match resolved.raw.claude_login_mode.as_str() {
1828                        "console" => args.push("--console".to_string()),
1829                        "sso" => args.push("--sso".to_string()),
1830                        _ => args.push("--claudeai".to_string()),
1831                    }
1832                    if let Some(email) = &resolved.raw.claude_login_email {
1833                        push_arg(&mut args, "--email", email.clone());
1834                    }
1835                    (
1836                        resolve_binary("claude"),
1837                        args,
1838                        "Complete the Claude browser login. Paste any shown login code into this terminal when prompted; deeplinks are opened when the CLI emits them.",
1839                    )
1840                }
1841                "gemini" => (
1842                    resolve_binary("gemini"),
1843                    vec![],
1844                    "Use the Gemini CLI auth selector, choose browser/social login, and complete local callback or code paste when prompted.",
1845                ),
1846                _ => unreachable!(),
1847            };
1848        eprintln!(
1849            "[auth] launching {provider}: {}",
1850            format_command(&bin, &args)
1851        );
1852        eprintln!("[auth] {provider}: {instruction}");
1853        let result = run_interactive_auth_command(
1854            &bin,
1855            &args,
1856            &resolved.cwd,
1857            resolved.raw.auth_timeout * 1000,
1858            !resolved.raw.no_auth_open_browser,
1859            &provider,
1860        );
1861        if result.code.unwrap_or(1) != 0 {
1862            return Err(format!(
1863                "{provider} social login failed: {}",
1864                compact_failure(&result)
1865            ));
1866        }
1867        let status = provider_auth_status(&provider, resolved);
1868        eprintln!("[auth] {provider}: {} ({})", status.status, status.detail);
1869    }
1870    Ok(())
1871}
1872
1873fn run_interactive_auth_command(
1874    command: &str,
1875    args: &[String],
1876    cwd: &Path,
1877    timeout_ms: u64,
1878    open_browser: bool,
1879    provider: &str,
1880) -> CommandResult {
1881    let started = Instant::now();
1882    let mut process = Command::new(command);
1883    process
1884        .args(args)
1885        .current_dir(cwd)
1886        .stdin(Stdio::inherit())
1887        .stdout(Stdio::piped())
1888        .stderr(Stdio::piped());
1889    configure_process_group(&mut process);
1890    let mut child = match process.spawn() {
1891        Ok(child) => child,
1892        Err(error) => {
1893            return CommandResult {
1894                command: command.to_string(),
1895                args: args.to_vec(),
1896                code: None,
1897                stdout: String::new(),
1898                stderr: String::new(),
1899                timed_out: false,
1900                cancelled: false,
1901                error: Some(error.to_string()),
1902                timeout_ms,
1903                duration_ms: started.elapsed().as_millis(),
1904            }
1905        }
1906    };
1907
1908    let seen_urls = Arc::new(Mutex::new(HashSet::new()));
1909    let stdout = child.stdout.take().map(|pipe| {
1910        read_auth_pipe(
1911            pipe,
1912            true,
1913            provider.to_string(),
1914            open_browser,
1915            Arc::clone(&seen_urls),
1916        )
1917    });
1918    let stderr = child.stderr.take().map(|pipe| {
1919        read_auth_pipe(
1920            pipe,
1921            false,
1922            provider.to_string(),
1923            open_browser,
1924            Arc::clone(&seen_urls),
1925        )
1926    });
1927    let timeout = Duration::from_millis(timeout_ms);
1928    let mut timed_out = false;
1929    let code;
1930    loop {
1931        match child.try_wait() {
1932            Ok(Some(status)) => {
1933                code = status.code();
1934                break;
1935            }
1936            Ok(None) => {
1937                if timeout_ms > 0 && started.elapsed() >= timeout {
1938                    timed_out = true;
1939                    terminate_child_tree(&mut child);
1940                    let status = child.wait().ok();
1941                    code = status.and_then(|status| status.code());
1942                    break;
1943                }
1944                thread::sleep(Duration::from_millis(50));
1945            }
1946            Err(error) => {
1947                return CommandResult {
1948                    command: command.to_string(),
1949                    args: args.to_vec(),
1950                    code: None,
1951                    stdout: String::new(),
1952                    stderr: String::new(),
1953                    timed_out,
1954                    cancelled: false,
1955                    error: Some(error.to_string()),
1956                    timeout_ms,
1957                    duration_ms: started.elapsed().as_millis(),
1958                }
1959            }
1960        }
1961    }
1962
1963    CommandResult {
1964        command: command.to_string(),
1965        args: args.to_vec(),
1966        code,
1967        stdout: stdout
1968            .and_then(|handle| handle.join().ok())
1969            .unwrap_or_default(),
1970        stderr: stderr
1971            .and_then(|handle| handle.join().ok())
1972            .unwrap_or_default(),
1973        timed_out,
1974        cancelled: false,
1975        error: None,
1976        timeout_ms,
1977        duration_ms: started.elapsed().as_millis(),
1978    }
1979}
1980
1981fn read_auth_pipe<R>(
1982    mut pipe: R,
1983    stdout: bool,
1984    provider: String,
1985    open_browser: bool,
1986    seen_urls: Arc<Mutex<HashSet<String>>>,
1987) -> thread::JoinHandle<String>
1988where
1989    R: Read + Send + 'static,
1990{
1991    thread::spawn(move || {
1992        let mut text = String::new();
1993        let mut buffer = [0u8; 4096];
1994        loop {
1995            let read = match pipe.read(&mut buffer) {
1996                Ok(0) => break,
1997                Ok(read) => read,
1998                Err(_) => break,
1999            };
2000            let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
2001            text.push_str(&chunk);
2002            if stdout {
2003                print!("{chunk}");
2004                let _ = io::stdout().flush();
2005            } else {
2006                eprint!("{chunk}");
2007                let _ = io::stderr().flush();
2008            }
2009            if open_browser {
2010                for url in extract_auth_urls(&chunk) {
2011                    let mut seen = seen_urls.lock().ok();
2012                    if seen.as_mut().is_some_and(|seen| !seen.insert(url.clone())) {
2013                        continue;
2014                    }
2015                    eprintln!("[auth] {provider}: opening {url}");
2016                    if let Err(error) = open_browser_url(&url) {
2017                        eprintln!("[auth] {provider}: failed to open {url}: {error}");
2018                    }
2019                }
2020            }
2021        }
2022        text
2023    })
2024}
2025
2026fn extract_auth_urls(text: &str) -> Vec<String> {
2027    text.split_whitespace()
2028        .map(|token| {
2029            token.trim_matches(|ch: char| {
2030                matches!(ch, '<' | '>' | '"' | '\'' | ')' | '(' | ',' | ';')
2031            })
2032        })
2033        .map(|token| token.trim_end_matches(['.', ':', ',', ';']))
2034        .filter(|token| {
2035            token.starts_with("http://")
2036                || token.starts_with("https://")
2037                || token.starts_with("codex://")
2038                || token.starts_with("openai://")
2039                || token.starts_with("claude://")
2040                || token.starts_with("anthropic://")
2041                || token.starts_with("gemini://")
2042                || token.starts_with("google://")
2043        })
2044        .map(ToString::to_string)
2045        .collect()
2046}
2047
2048fn open_browser_url(url: &str) -> Result<(), String> {
2049    let (command, args): (&str, Vec<String>) = if cfg!(target_os = "macos") {
2050        ("open", vec![url.to_string()])
2051    } else if cfg!(target_os = "windows") {
2052        (
2053            "cmd",
2054            vec![
2055                "/c".to_string(),
2056                "start".to_string(),
2057                "".to_string(),
2058                url.to_string(),
2059            ],
2060        )
2061    } else {
2062        ("xdg-open", vec![url.to_string()])
2063    };
2064    Command::new(command)
2065        .args(args)
2066        .stdin(Stdio::null())
2067        .stdout(Stdio::null())
2068        .stderr(Stdio::null())
2069        .spawn()
2070        .map(|mut child| {
2071            let _ = child.try_wait();
2072        })
2073        .map_err(|error| error.to_string())
2074}
2075
2076fn build_prompt_context_with_progress(
2077    resolved: &ResolvedArgs,
2078    progress: Option<ProgressSink>,
2079) -> Result<PromptContext, String> {
2080    let mut prompt = resolved.prompt.trim().to_string();
2081    let mut sections = Vec::new();
2082    let mut commands = Vec::new();
2083
2084    for file in &resolved.raw.files {
2085        let path = if file.is_absolute() {
2086            file.clone()
2087        } else {
2088            resolved.cwd.join(file)
2089        };
2090        let content = fs::read_to_string(&path)
2091            .map_err(|error| format!("Failed to read tagged file {}: {error}", path.display()))?;
2092        sections.push(format!("--- file: {} ---\n{}", file.display(), content));
2093    }
2094
2095    for command in &resolved.raw.commands {
2096        let shell = if cfg!(windows) { "cmd" } else { "sh" };
2097        let args = if cfg!(windows) {
2098            vec!["/C".to_string(), command.clone()]
2099        } else {
2100            vec!["-lc".to_string(), command.clone()]
2101        };
2102        let live_label = (resolved.raw.verbose || progress.is_some())
2103            .then(|| format!("context command `{}`", truncate(command, 80)));
2104        emit_runtime_event(
2105            &progress,
2106            false,
2107            progress_event(
2108                None,
2109                None,
2110                ProgressStage::Context,
2111                None,
2112                format!("[amon-hen] context command `{}`", truncate(command, 80)),
2113            ),
2114        );
2115        let result = run_command(
2116            CommandRequest::new(shell, &args, &resolved.cwd, resolved.raw.timeout * 1000)
2117                .progress(command_progress(live_label.as_deref(), progress.clone())),
2118        );
2119        let telemetry = command_telemetry(&result);
2120        let mut event = progress_event(
2121            None,
2122            None,
2123            ProgressStage::Done,
2124            Some(&telemetry.status),
2125            format!(
2126                "[amon-hen] context command `{}` {} in {:.1}s",
2127                truncate(command, 80),
2128                telemetry.status,
2129                result.duration_ms as f64 / 1000.0
2130            ),
2131        );
2132        event.kind = RuntimeEventKind::Context;
2133        emit_runtime_event(&progress, false, event);
2134        commands.push(telemetry);
2135        sections.push(format!(
2136            "--- command: {} (exit {}) ---\nstdout:\n{}\nstderr:\n{}",
2137            command,
2138            result
2139                .code
2140                .map(|code| code.to_string())
2141                .unwrap_or_else(|| "unknown".to_string()),
2142            result.stdout.trim(),
2143            result.stderr.trim()
2144        ));
2145    }
2146
2147    if !sections.is_empty() {
2148        prompt.push_str("\n\nPrompt context:\n");
2149        prompt.push_str(&sections.join("\n\n"));
2150    }
2151    Ok(PromptContext { prompt, commands })
2152}
2153
2154fn run_amon_hen_with_progress(
2155    resolved: &ResolvedArgs,
2156    query: String,
2157    prompt_commands: Vec<CommandTelemetry>,
2158    progress: Option<ProgressSink>,
2159) -> AmonHenResult {
2160    run_amon_hen_with_progress_and_cancel(resolved, query, prompt_commands, progress, None)
2161}
2162
2163fn run_amon_hen_with_progress_and_cancel(
2164    resolved: &ResolvedArgs,
2165    query: String,
2166    prompt_commands: Vec<CommandTelemetry>,
2167    progress: Option<ProgressSink>,
2168    cancel: Option<Arc<AtomicBool>>,
2169) -> AmonHenResult {
2170    let workflow = build_workflow(resolved);
2171    let mut previous_iteration = Vec::new();
2172    let mut final_members = Vec::new();
2173    let mut iterations = Vec::new();
2174
2175    for iteration in 1..=workflow.iterations {
2176        let iteration_started = Instant::now();
2177        let handoff_context =
2178            iteration_handoff_context(&previous_iteration, resolved.raw.max_member_chars);
2179        emit_runtime_event(
2180            &progress,
2181            resolved.raw.verbose,
2182            progress_event_with_context(
2183                None,
2184                None,
2185                ProgressStage::Start,
2186                None,
2187                Some(iteration),
2188                Some(workflow.iterations),
2189                false,
2190                None,
2191                None,
2192                vec![],
2193                format!(
2194                    "[amon-hen] iteration {iteration}/{} started",
2195                    workflow.iterations
2196                ),
2197            ),
2198        );
2199        final_members = run_iteration(
2200            resolved,
2201            &query,
2202            &workflow,
2203            iteration,
2204            &previous_iteration,
2205            progress.clone(),
2206            cancel.clone(),
2207        );
2208        iterations.push(iteration_record(
2209            iteration,
2210            workflow.iterations,
2211            final_members.clone(),
2212            iteration_started.elapsed().as_millis(),
2213            handoff_context,
2214            None,
2215        ));
2216        previous_iteration = final_members.clone();
2217    }
2218
2219    let successes = final_members
2220        .iter()
2221        .filter(|result| result.status == "ok")
2222        .cloned()
2223        .collect::<Vec<_>>();
2224    let summary = if successes.is_empty() {
2225        EngineResult {
2226            name: resolved.raw.summarizer.clone(),
2227            bin: None,
2228            status: "error".to_string(),
2229            duration_ms: 0,
2230            detail: "No Amon Hen member produced a response.".to_string(),
2231            exit_code: None,
2232            stdout: String::new(),
2233            stderr: String::new(),
2234            output: String::new(),
2235            command: String::new(),
2236            token_usage: token_usage("", ""),
2237            tool_calls: vec![],
2238            sub_agents: vec![],
2239            role: "summary".to_string(),
2240            iteration: workflow.iterations,
2241            total_iterations: workflow.iterations,
2242            team_size: 0,
2243        }
2244    } else {
2245        let summary_prompt =
2246            build_summary_prompt(&query, &successes, &workflow, resolved.raw.max_member_chars);
2247        if let Some(record) = iterations.last_mut() {
2248            record.summary_context = Some(truncate(&summary_prompt, resolved.raw.max_member_chars));
2249        }
2250        let summarizer = pick_summarizer(resolved, &successes);
2251        let mut options = engine_options(
2252            resolved,
2253            EngineOptionsInput {
2254                member: &summarizer,
2255                prompt: summary_prompt,
2256                role: "summary",
2257                iteration: workflow.iterations,
2258                workflow: &workflow,
2259                progress: progress.clone(),
2260                cancel: cancel.clone(),
2261            },
2262        );
2263        options.role = "summary".to_string();
2264        options.team_size = 0;
2265        run_engine(&summarizer, options)
2266    };
2267
2268    AmonHenResult {
2269        query,
2270        cwd: resolved.cwd.display().to_string(),
2271        members_requested: resolved.members.clone(),
2272        summarizer_requested: resolved.raw.summarizer.clone(),
2273        workflow,
2274        prompt_commands,
2275        iterations,
2276        members: final_members,
2277        summary,
2278    }
2279}
2280
2281fn build_workflow(resolved: &ResolvedArgs) -> Workflow {
2282    let mut teams = HashMap::new();
2283    for member in ENGINES {
2284        teams.insert(member.to_string(), resolved.raw.team_work);
2285    }
2286    if let Some(value) = resolved.raw.codex_sub_agents {
2287        teams.insert("codex".to_string(), value);
2288    }
2289    if let Some(value) = resolved.raw.claude_sub_agents {
2290        teams.insert("claude".to_string(), value);
2291    }
2292    if let Some(value) = resolved.raw.gemini_sub_agents {
2293        teams.insert("gemini".to_string(), value);
2294    }
2295    Workflow {
2296        handoff: resolved.raw.handoff || resolved.raw.planner_mode == PLANNER_MODE_REVIEW_CHAIN,
2297        lead: resolved.raw.lead.clone(),
2298        planner: resolved.raw.planner.clone(),
2299        planner_mode: resolved.raw.planner_mode.clone(),
2300        iterations: resolved.raw.iterations.max(1),
2301        team_work: resolved.raw.team_work,
2302        teams,
2303    }
2304}
2305
2306fn effective_team_size(workflow: &Workflow, member: &str) -> usize {
2307    *workflow.teams.get(member).unwrap_or(&workflow.team_work)
2308}
2309
2310fn iteration_record(
2311    iteration: usize,
2312    total_iterations: usize,
2313    members: Vec<EngineResult>,
2314    duration_ms: u128,
2315    handoff_context: Option<String>,
2316    summary_context: Option<String>,
2317) -> IterationRecord {
2318    let status = if members.iter().any(|member| member.status == "ok") {
2319        "ok"
2320    } else if members.iter().any(|member| member.status == "cancelled") {
2321        "cancelled"
2322    } else if members.iter().any(|member| member.status == "timeout") {
2323        "timeout"
2324    } else if members.iter().any(|member| member.status == "missing") {
2325        "missing"
2326    } else {
2327        "error"
2328    };
2329    let sub_agents = members
2330        .iter()
2331        .flat_map(|member| member.sub_agents.iter().cloned())
2332        .collect::<Vec<_>>();
2333    let token_usage = aggregate_results_token_usage(members.iter());
2334    let tool_calls = members
2335        .iter()
2336        .chain(sub_agents.iter())
2337        .flat_map(|member| member.tool_calls.iter().cloned())
2338        .collect::<Vec<_>>();
2339    IterationRecord {
2340        iteration,
2341        total_iterations,
2342        status: status.to_string(),
2343        duration_ms,
2344        members,
2345        sub_agents,
2346        handoff_context,
2347        summary_context,
2348        token_usage,
2349        tool_calls,
2350    }
2351}
2352
2353fn iteration_handoff_context(
2354    previous_iteration: &[EngineResult],
2355    max_member_chars: usize,
2356) -> Option<String> {
2357    let context = previous_iteration
2358        .iter()
2359        .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
2360        .map(|result| {
2361            format!(
2362                "### {} ({})\n{}",
2363                result.name,
2364                result.role,
2365                truncate(result.output.trim(), max_member_chars)
2366            )
2367        })
2368        .collect::<Vec<_>>()
2369        .join("\n\n");
2370    (!context.is_empty()).then_some(context)
2371}
2372
2373fn run_iteration(
2374    resolved: &ResolvedArgs,
2375    query: &str,
2376    workflow: &Workflow,
2377    iteration: usize,
2378    previous_iteration: &[EngineResult],
2379    progress: Option<ProgressSink>,
2380    cancel: Option<Arc<AtomicBool>>,
2381) -> Vec<EngineResult> {
2382    let ordered = execution_order(
2383        &resolved.members,
2384        workflow.planner.as_deref(),
2385        workflow.lead.as_deref(),
2386    );
2387    if workflow.handoff && planner_mode_runs_serial(workflow) {
2388        let mut results = Vec::new();
2389        for member in ordered {
2390            let role = role_for(&member, workflow);
2391            let team_size = effective_team_size(workflow, &member);
2392            let prompt = build_member_prompt(MemberPromptInput {
2393                query,
2394                role: &role,
2395                workflow,
2396                iteration,
2397                team_size,
2398                previous_iteration,
2399                handoff_results: &results,
2400                plan_output: "",
2401            });
2402            let options = engine_options(
2403                resolved,
2404                EngineOptionsInput {
2405                    member: &member,
2406                    prompt,
2407                    role: &role,
2408                    iteration,
2409                    workflow,
2410                    progress: progress.clone(),
2411                    cancel: cancel.clone(),
2412                },
2413            );
2414            results.push(run_engine(&member, options));
2415        }
2416        return results;
2417    }
2418
2419    let planner = workflow
2420        .planner
2421        .as_ref()
2422        .filter(|planner| ordered.contains(planner))
2423        .cloned();
2424    let mut results = Vec::new();
2425    let mut plan_output = String::new();
2426    let blocking_planner = workflow.planner_mode == PLANNER_MODE_BLOCKING;
2427    if let Some(planner_name) = planner.as_ref().filter(|_| blocking_planner) {
2428        let role = role_for(planner_name, workflow);
2429        let team_size = effective_team_size(workflow, planner_name);
2430        let prompt = build_member_prompt(MemberPromptInput {
2431            query,
2432            role: &role,
2433            workflow,
2434            iteration,
2435            team_size,
2436            previous_iteration,
2437            handoff_results: &[],
2438            plan_output: "",
2439        });
2440        let options = engine_options(
2441            resolved,
2442            EngineOptionsInput {
2443                member: planner_name,
2444                prompt,
2445                role: &role,
2446                iteration,
2447                workflow,
2448                progress: progress.clone(),
2449                cancel: cancel.clone(),
2450            },
2451        );
2452        let result = run_engine(planner_name, options);
2453        if result.status == "ok" {
2454            plan_output = result.output.clone();
2455        }
2456        results.push(result);
2457    }
2458
2459    let (tx, rx) = mpsc::channel();
2460    let executors = ordered
2461        .into_iter()
2462        .filter(|member| !(blocking_planner && Some(member) == planner.as_ref()))
2463        .collect::<Vec<_>>();
2464    let resolved = Arc::new(resolved.clone());
2465    let workflow = Arc::new(workflow.clone());
2466    let previous_iteration = Arc::new(previous_iteration.to_vec());
2467    let plan_output = Arc::new(plan_output);
2468    let progress = Arc::new(progress);
2469    let cancel = Arc::new(cancel);
2470    for member in executors.clone() {
2471        let tx = tx.clone();
2472        let resolved = Arc::clone(&resolved);
2473        let workflow = Arc::clone(&workflow);
2474        let query = query.to_string();
2475        let previous_iteration = Arc::clone(&previous_iteration);
2476        let plan_output = Arc::clone(&plan_output);
2477        let progress = Arc::clone(&progress);
2478        let cancel = Arc::clone(&cancel);
2479        thread::spawn(move || {
2480            let role = role_for(&member, &workflow);
2481            let team_size = effective_team_size(&workflow, &member);
2482            let prompt = build_member_prompt(MemberPromptInput {
2483                query: &query,
2484                role: &role,
2485                workflow: &workflow,
2486                iteration,
2487                team_size,
2488                previous_iteration: previous_iteration.as_slice(),
2489                handoff_results: &[],
2490                plan_output: &plan_output,
2491            });
2492            let options = engine_options(
2493                &resolved,
2494                EngineOptionsInput {
2495                    member: &member,
2496                    prompt,
2497                    role: &role,
2498                    iteration,
2499                    workflow: &workflow,
2500                    progress: (*progress).clone(),
2501                    cancel: (*cancel).clone(),
2502                },
2503            );
2504            let _ = tx.send(run_engine(&member, options));
2505        });
2506    }
2507    drop(tx);
2508    let mut executor_results = rx.into_iter().collect::<Vec<_>>();
2509    executor_results.sort_by_key(|result| {
2510        executors
2511            .iter()
2512            .position(|member| member == &result.name)
2513            .unwrap_or(usize::MAX)
2514    });
2515    results.extend(executor_results);
2516    results
2517}
2518
2519fn planner_mode_runs_serial(workflow: &Workflow) -> bool {
2520    matches!(
2521        workflow.planner_mode.as_str(),
2522        PLANNER_MODE_BLOCKING | PLANNER_MODE_REVIEW_CHAIN
2523    )
2524}
2525
2526fn engine_options(resolved: &ResolvedArgs, input: EngineOptionsInput<'_>) -> EngineRunOptions {
2527    EngineRunOptions {
2528        prompt: input.prompt,
2529        cwd: resolved.cwd.clone(),
2530        timeout_ms: resolved.raw.timeout * 1000,
2531        effort: provider_effort(resolved, input.member),
2532        model: provider_model(resolved, input.member),
2533        permission: provider_permission(resolved, input.member),
2534        auth: provider_auth(resolved, input.member),
2535        capability: provider_capability(resolved, input.member),
2536        role: input.role.to_string(),
2537        iteration: input.iteration,
2538        total_iterations: input.workflow.iterations,
2539        team_size: effective_team_size(input.workflow, input.member),
2540        is_sub_agent: false,
2541        live: resolved.raw.verbose || input.progress.is_some(),
2542        progress: input.progress,
2543        cancel: input.cancel,
2544    }
2545}
2546
2547fn provider_effort(resolved: &ResolvedArgs, member: &str) -> Option<String> {
2548    provider_option(
2549        member,
2550        &resolved.raw.codex_effort,
2551        &resolved.raw.claude_effort,
2552        &resolved.raw.gemini_effort,
2553    )
2554    .or_else(|| {
2555        resolved
2556            .raw
2557            .effort
2558            .map(|effort| effort.as_str().to_string())
2559    })
2560}
2561
2562fn provider_model(resolved: &ResolvedArgs, member: &str) -> Option<String> {
2563    provider_option(
2564        member,
2565        &resolved.raw.codex_model,
2566        &resolved.raw.claude_model,
2567        &resolved.raw.gemini_model,
2568    )
2569}
2570
2571fn provider_permission(resolved: &ResolvedArgs, member: &str) -> Option<String> {
2572    match Engine::parse(member) {
2573        Some(Engine::Codex) => Some(resolved.raw.codex_sandbox.clone()),
2574        Some(Engine::Claude) => Some(resolved.raw.claude_permission_mode.clone()),
2575        Some(Engine::Gemini) => Some(resolved.raw.gemini_approval_mode.clone()),
2576        None => None,
2577    }
2578}
2579
2580fn provider_auth(resolved: &ResolvedArgs, member: &str) -> String {
2581    match Engine::parse(member) {
2582        Some(Engine::Codex) => resolved.raw.codex_auth.clone(),
2583        Some(Engine::Claude) => resolved.raw.claude_auth.clone(),
2584        Some(Engine::Gemini) => resolved.raw.gemini_auth.clone(),
2585        None => DEFAULT_AUTH_MODE.to_string(),
2586    }
2587}
2588
2589fn provider_capability(resolved: &ResolvedArgs, member: &str) -> ProviderCapability {
2590    match Engine::parse(member).expect("provider capabilities use validated engines") {
2591        Engine::Codex => ProviderCapability {
2592            mode: inferred_capability_mode(
2593                &resolved.raw.codex_capabilities,
2594                !resolved.raw.codex_config.is_empty() || resolved.raw.codex_mcp_profile.is_some(),
2595            ),
2596            config: resolved.raw.codex_config.clone(),
2597            mcp_profile: resolved.raw.codex_mcp_profile.clone(),
2598            mcp_config: vec![],
2599            allowed_tools: vec![],
2600            disallowed_tools: vec![],
2601            tools: vec![],
2602            agent: None,
2603            agents_json: None,
2604            plugin_dirs: vec![],
2605            strict_mcp_config: false,
2606            disable_slash_commands: false,
2607            settings: None,
2608            tools_profile: vec![],
2609            allowed_mcp_servers: vec![],
2610            policy: vec![],
2611            admin_policy: vec![],
2612        },
2613        Engine::Claude => ProviderCapability {
2614            mode: inferred_capability_mode(
2615                &resolved.raw.claude_capabilities,
2616                !resolved.raw.claude_mcp_config.is_empty()
2617                    || !resolved.raw.claude_allowed_tools.is_empty()
2618                    || !resolved.raw.claude_disallowed_tools.is_empty()
2619                    || !resolved.raw.claude_tools.is_empty()
2620                    || resolved.raw.claude_agent.is_some()
2621                    || resolved.raw.claude_agents_json.is_some()
2622                    || !resolved.raw.claude_plugin_dir.is_empty()
2623                    || resolved.raw.claude_strict_mcp_config
2624                    || resolved.raw.claude_disable_slash_commands,
2625            ),
2626            config: vec![],
2627            mcp_profile: None,
2628            mcp_config: resolved.raw.claude_mcp_config.clone(),
2629            allowed_tools: resolved.raw.claude_allowed_tools.clone(),
2630            disallowed_tools: resolved.raw.claude_disallowed_tools.clone(),
2631            tools: resolved.raw.claude_tools.clone(),
2632            agent: resolved.raw.claude_agent.clone(),
2633            agents_json: resolved.raw.claude_agents_json.clone(),
2634            plugin_dirs: resolved.raw.claude_plugin_dir.clone(),
2635            strict_mcp_config: resolved.raw.claude_strict_mcp_config,
2636            disable_slash_commands: resolved.raw.claude_disable_slash_commands,
2637            settings: None,
2638            tools_profile: vec![],
2639            allowed_mcp_servers: vec![],
2640            policy: vec![],
2641            admin_policy: vec![],
2642        },
2643        Engine::Gemini => ProviderCapability {
2644            mode: inferred_capability_mode(
2645                &resolved.raw.gemini_capabilities,
2646                resolved.raw.gemini_settings.is_some()
2647                    || !resolved.raw.gemini_tools_profile.is_empty()
2648                    || !resolved.raw.gemini_allowed_mcp_servers.is_empty()
2649                    || !resolved.raw.gemini_policy.is_empty()
2650                    || !resolved.raw.gemini_admin_policy.is_empty(),
2651            ),
2652            config: vec![],
2653            mcp_profile: None,
2654            mcp_config: vec![],
2655            allowed_tools: vec![],
2656            disallowed_tools: vec![],
2657            tools: vec![],
2658            agent: None,
2659            agents_json: None,
2660            plugin_dirs: vec![],
2661            strict_mcp_config: false,
2662            disable_slash_commands: false,
2663            settings: resolved.raw.gemini_settings.clone(),
2664            tools_profile: resolved.raw.gemini_tools_profile.clone(),
2665            allowed_mcp_servers: resolved.raw.gemini_allowed_mcp_servers.clone(),
2666            policy: resolved.raw.gemini_policy.clone(),
2667            admin_policy: resolved.raw.gemini_admin_policy.clone(),
2668        },
2669    }
2670}
2671
2672fn inferred_capability_mode(configured: &str, has_override_flags: bool) -> String {
2673    if configured == CAPABILITY_INHERIT && has_override_flags {
2674        CAPABILITY_OVERRIDE.to_string()
2675    } else {
2676        configured.to_string()
2677    }
2678}
2679
2680fn provider_option(
2681    member: &str,
2682    codex: &Option<String>,
2683    claude: &Option<String>,
2684    gemini: &Option<String>,
2685) -> Option<String> {
2686    match Engine::parse(member) {
2687        Some(Engine::Codex) => codex.clone(),
2688        Some(Engine::Claude) => claude.clone(),
2689        Some(Engine::Gemini) => gemini.clone(),
2690        None => None,
2691    }
2692}
2693
2694fn build_sub_agent_prompt(original: &str, role: &str, index: usize, total: usize) -> String {
2695    format!(
2696        "You are sub-agent {index} of {total} for an Amon Hen provider assigned role `{role}`.\n\
2697         Work independently on a useful slice of the task. Inspect, reason, or verify as needed, \
2698         then return concise findings, risks, and concrete recommendations for the provider lead.\n\n\
2699         Original provider prompt:\n{original}"
2700    )
2701}
2702
2703fn build_team_lead_prompt(original: &str, sub_agents: &[EngineResult]) -> String {
2704    let handoff = sub_agents
2705        .iter()
2706        .map(|agent| {
2707            format!(
2708                "### {} [{}]\n{}",
2709                agent.role,
2710                agent.status,
2711                if agent.output.trim().is_empty() {
2712                    agent.detail.trim()
2713                } else {
2714                    agent.output.trim()
2715                }
2716            )
2717        })
2718        .collect::<Vec<_>>()
2719        .join("\n\n");
2720    format!(
2721        "You are the provider lead. Use the sub-agent handoffs below, resolve disagreements, \
2722         and produce the final provider response for the original Amon Hen role.\n\n\
2723         Sub-agent handoffs:\n{handoff}\n\nOriginal provider prompt:\n{original}"
2724    )
2725}
2726
2727fn run_engine(name: &str, options: EngineRunOptions) -> EngineResult {
2728    if let Some(result) = missing_engine_if_unavailable(name, &options) {
2729        return result;
2730    }
2731    if options.team_size > 0 && options.role != "summary" && !options.is_sub_agent {
2732        return run_engine_team(name, options);
2733    }
2734    run_engine_single(name, options, vec![])
2735}
2736
2737fn missing_engine_if_unavailable(name: &str, options: &EngineRunOptions) -> Option<EngineResult> {
2738    let bin = resolve_binary(name);
2739    if command_available(&bin) {
2740        return None;
2741    }
2742    let detail = format!(
2743        "Provider CLI `{bin}` was not found in PATH. Install the {name} CLI on this machine or set {} to the executable path.",
2744        Engine::parse(name)
2745            .map(Engine::binary_env_var)
2746            .unwrap_or("AMON_HEN_PROVIDER_BIN")
2747    );
2748    let message = format!("[amon-hen] {name} missing: {detail}");
2749    emit_runtime_event(
2750        &options.progress,
2751        options.live && options.progress.is_none(),
2752        progress_event_with_context(
2753            Some(name),
2754            Some(&options.role),
2755            ProgressStage::Done,
2756            Some("missing"),
2757            Some(options.iteration),
2758            Some(options.total_iterations),
2759            options.is_sub_agent,
2760            Some(0),
2761            Some(token_usage(&options.prompt, "")),
2762            vec![],
2763            message,
2764        ),
2765    );
2766    Some(EngineResult {
2767        name: name.to_string(),
2768        bin: Some(bin.clone()),
2769        status: "missing".to_string(),
2770        duration_ms: 0,
2771        detail: detail.clone(),
2772        exit_code: None,
2773        stdout: String::new(),
2774        stderr: detail,
2775        output: String::new(),
2776        command: bin,
2777        token_usage: token_usage(&options.prompt, ""),
2778        tool_calls: vec![],
2779        sub_agents: vec![],
2780        role: options.role.clone(),
2781        iteration: options.iteration,
2782        total_iterations: options.total_iterations,
2783        team_size: options.team_size,
2784    })
2785}
2786
2787fn command_available(command: &str) -> bool {
2788    let path = Path::new(command);
2789    if path.components().count() > 1 || path.is_absolute() {
2790        return path.is_file();
2791    }
2792    let Some(paths) = std::env::var_os("PATH") else {
2793        return false;
2794    };
2795    std::env::split_paths(&paths).any(|dir| dir.join(command).is_file())
2796}
2797
2798fn run_engine_team(name: &str, options: EngineRunOptions) -> EngineResult {
2799    let team_size = options.team_size;
2800    let (tx, rx) = mpsc::channel();
2801    for index in 1..=team_size {
2802        let tx = tx.clone();
2803        let name = name.to_string();
2804        let mut sub_options = options.clone();
2805        sub_options.team_size = 0;
2806        sub_options.is_sub_agent = true;
2807        sub_options.role = format!("{}:sub-agent-{index}", options.role);
2808        sub_options.prompt =
2809            build_sub_agent_prompt(&options.prompt, &options.role, index, team_size);
2810        thread::spawn(move || {
2811            let _ = tx.send((index, run_engine_single(&name, sub_options, vec![])));
2812        });
2813    }
2814    drop(tx);
2815
2816    let mut indexed = rx.into_iter().collect::<Vec<_>>();
2817    indexed.sort_by_key(|(index, _)| *index);
2818    let sub_agents = indexed
2819        .into_iter()
2820        .map(|(_, result)| result)
2821        .collect::<Vec<_>>();
2822    let mut lead_options = options.clone();
2823    lead_options.prompt = build_team_lead_prompt(&options.prompt, &sub_agents);
2824    lead_options.is_sub_agent = true;
2825    run_engine_single(name, lead_options, sub_agents)
2826}
2827
2828fn run_engine_single(
2829    name: &str,
2830    options: EngineRunOptions,
2831    sub_agents: Vec<EngineResult>,
2832) -> EngineResult {
2833    let started = Instant::now();
2834    let bin = resolve_binary(name);
2835    let live = options.live;
2836    let role_is_sub_agent = options.role.contains(":sub-agent-");
2837    let start_message = format!(
2838        "[amon-hen] start {} role={} iteration={}/{}{}",
2839        name,
2840        options.role,
2841        options.iteration,
2842        options.total_iterations,
2843        if role_is_sub_agent { " sub-agent" } else { "" }
2844    );
2845    emit_runtime_event(
2846        &options.progress,
2847        live && options.progress.is_none(),
2848        progress_event_with_context(
2849            Some(name),
2850            Some(&options.role),
2851            ProgressStage::Start,
2852            Some("running"),
2853            Some(options.iteration),
2854            Some(options.total_iterations),
2855            role_is_sub_agent,
2856            None,
2857            None,
2858            vec![],
2859            start_message,
2860        ),
2861    );
2862    let result = match Engine::parse(name) {
2863        Some(Engine::Codex) => run_codex(&bin, &options),
2864        Some(Engine::Claude) => run_claude(&bin, &options),
2865        Some(Engine::Gemini) => run_gemini(&bin, &options),
2866        None => CommandResult {
2867            command: name.to_string(),
2868            args: vec![],
2869            code: None,
2870            stdout: String::new(),
2871            stderr: String::new(),
2872            timed_out: false,
2873            cancelled: false,
2874            error: Some(format!("Unknown engine: {name}")),
2875            timeout_ms: options.timeout_ms,
2876            duration_ms: started.elapsed().as_millis(),
2877        },
2878    };
2879    let engine_progress = options.progress.clone();
2880    let engine = finalize_engine(
2881        name,
2882        &bin,
2883        started.elapsed().as_millis(),
2884        result,
2885        options,
2886        sub_agents,
2887    );
2888    if live {
2889        let done_message = format!(
2890            "[amon-hen] done {} role={} status={} elapsed={:.1}s tokens={} tools={} sub-agents={}",
2891            engine.name,
2892            engine.role,
2893            engine.status,
2894            engine.duration_ms as f64 / 1000.0,
2895            engine.token_usage.total,
2896            engine.tool_calls.len(),
2897            engine.sub_agents.len()
2898        );
2899        emit_runtime_event(
2900            &engine_progress,
2901            live && engine_progress.is_none(),
2902            progress_event_with_context(
2903                Some(&engine.name),
2904                Some(&engine.role),
2905                ProgressStage::Done,
2906                Some(&engine.status),
2907                Some(engine.iteration),
2908                Some(engine.total_iterations),
2909                engine.role.contains(":sub-agent-"),
2910                Some(engine.duration_ms),
2911                Some(engine.token_usage.clone()),
2912                engine.tool_calls.clone(),
2913                done_message,
2914            ),
2915        );
2916    }
2917    engine
2918}
2919
2920fn push_arg(args: &mut Vec<String>, flag: &str, value: impl Into<String>) {
2921    args.push(flag.to_string());
2922    args.push(value.into());
2923}
2924
2925fn push_optional_arg(args: &mut Vec<String>, flag: &str, value: &Option<String>) {
2926    if let Some(value) = value {
2927        push_arg(args, flag, value.clone());
2928    }
2929}
2930
2931fn push_repeated_flag(args: &mut Vec<String>, flag: &str, values: &[String]) {
2932    if !values.is_empty() {
2933        args.push(flag.to_string());
2934        args.extend(values.iter().cloned());
2935    }
2936}
2937
2938fn push_each_arg(args: &mut Vec<String>, flag: &str, values: &[String]) {
2939    for value in values {
2940        push_arg(args, flag, value.clone());
2941    }
2942}
2943
2944fn run_codex(bin: &str, options: &EngineRunOptions) -> CommandResult {
2945    let temp = match tempfile::tempdir() {
2946        Ok(temp) => temp,
2947        Err(error) => {
2948            return CommandResult {
2949                command: bin.to_string(),
2950                args: vec![],
2951                code: None,
2952                stdout: String::new(),
2953                stderr: String::new(),
2954                timed_out: false,
2955                cancelled: false,
2956                error: Some(error.to_string()),
2957                timeout_ms: options.timeout_ms,
2958                duration_ms: 0,
2959            }
2960        }
2961    };
2962    let output_path = temp.path().join("last-message.txt");
2963    let mut args = vec!["exec".to_string()];
2964    push_optional_arg(&mut args, "--model", &options.model);
2965    if let Some(effort) = &options.effort {
2966        push_arg(&mut args, "-c", format!("model_reasoning_effort={effort}"));
2967    }
2968    if options.capability.mode == CAPABILITY_OVERRIDE {
2969        for config in &options.capability.config {
2970            push_arg(&mut args, "-c", config.clone());
2971        }
2972        push_optional_arg(&mut args, "--profile", &options.capability.mcp_profile);
2973    }
2974    args.extend([
2975        "--skip-git-repo-check".to_string(),
2976        "--sandbox".to_string(),
2977        options
2978            .permission
2979            .clone()
2980            .unwrap_or_else(|| "read-only".to_string()),
2981        "--ephemeral".to_string(),
2982        "--json".to_string(),
2983        "-o".to_string(),
2984        output_path.display().to_string(),
2985        "-".to_string(),
2986    ]);
2987    let mut result = run_command(
2988        CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
2989            .stdin_text(Some(&options.prompt))
2990            .progress(command_progress_with_input(
2991                live_label("codex", options).as_deref(),
2992                options.progress.clone(),
2993                estimate_tokens(&options.prompt),
2994            ))
2995            .cancel(options.cancel.clone()),
2996    );
2997    if let Ok(output) = fs::read_to_string(output_path) {
2998        if !output.trim().is_empty() {
2999            result.stdout = output;
3000        }
3001    }
3002    result
3003}
3004
3005fn run_claude(bin: &str, options: &EngineRunOptions) -> CommandResult {
3006    let mut args = Vec::new();
3007    if should_use_claude_bare_mode(&options.auth) {
3008        args.push("--bare".to_string());
3009    }
3010    args.extend([
3011        "-p".to_string(),
3012        "--permission-mode".to_string(),
3013        options
3014            .permission
3015            .clone()
3016            .unwrap_or_else(|| "plan".to_string()),
3017        "--verbose".to_string(),
3018        "--output-format".to_string(),
3019        "stream-json".to_string(),
3020        "--include-partial-messages".to_string(),
3021        "--no-session-persistence".to_string(),
3022    ]);
3023    push_optional_arg(&mut args, "--model", &options.model);
3024    if let Some(effort) = options
3025        .effort
3026        .clone()
3027        .or_else(|| std::env::var("CLAUDE_CODE_EFFORT_LEVEL").ok())
3028    {
3029        push_arg(&mut args, "--effort", effort);
3030    }
3031    if options.capability.mode == CAPABILITY_OVERRIDE {
3032        push_repeated_flag(&mut args, "--mcp-config", &options.capability.mcp_config);
3033        push_repeated_flag(
3034            &mut args,
3035            "--allowedTools",
3036            &options.capability.allowed_tools,
3037        );
3038        push_repeated_flag(
3039            &mut args,
3040            "--disallowedTools",
3041            &options.capability.disallowed_tools,
3042        );
3043        push_repeated_flag(&mut args, "--tools", &options.capability.tools);
3044        push_optional_arg(&mut args, "--agent", &options.capability.agent);
3045        push_optional_arg(&mut args, "--agents", &options.capability.agents_json);
3046        push_each_arg(&mut args, "--plugin-dir", &options.capability.plugin_dirs);
3047        if options.capability.strict_mcp_config {
3048            args.push("--strict-mcp-config".to_string());
3049        }
3050        if options.capability.disable_slash_commands {
3051            args.push("--disable-slash-commands".to_string());
3052        }
3053    }
3054    run_command(
3055        CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
3056            .stdin_text(Some(&options.prompt))
3057            .progress(command_progress_with_input(
3058                live_label("claude", options).as_deref(),
3059                options.progress.clone(),
3060                estimate_tokens(&options.prompt),
3061            ))
3062            .cancel(options.cancel.clone()),
3063    )
3064}
3065
3066fn run_gemini(bin: &str, options: &EngineRunOptions) -> CommandResult {
3067    let mut args = Vec::new();
3068    push_optional_arg(&mut args, "--model", &options.model);
3069    if options.capability.mode == CAPABILITY_OVERRIDE {
3070        push_repeated_flag(&mut args, "--extensions", &options.capability.tools_profile);
3071        push_repeated_flag(
3072            &mut args,
3073            "--allowed-mcp-server-names",
3074            &options.capability.allowed_mcp_servers,
3075        );
3076        push_repeated_flag(&mut args, "--policy", &options.capability.policy);
3077        push_repeated_flag(
3078            &mut args,
3079            "--admin-policy",
3080            &options.capability.admin_policy,
3081        );
3082    }
3083    args.extend([
3084        "-p".to_string(),
3085        String::new(),
3086        "--skip-trust".to_string(),
3087        "--approval-mode".to_string(),
3088        options
3089            .permission
3090            .clone()
3091            .unwrap_or_else(|| "plan".to_string()),
3092        "--output-format".to_string(),
3093        "json".to_string(),
3094    ]);
3095    let mut envs = HashMap::new();
3096    let effort_settings = prepare_gemini_settings(options);
3097    if let Some(path) = effort_settings.as_ref() {
3098        envs.insert(
3099            "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
3100            path.path.display().to_string(),
3101        );
3102    } else if options.capability.mode == CAPABILITY_OVERRIDE {
3103        if let Some(settings) = &options.capability.settings {
3104            envs.insert(
3105                "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
3106                settings.clone(),
3107            );
3108        }
3109    }
3110    run_command(
3111        CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
3112            .envs(envs)
3113            .stdin_text(Some(&options.prompt))
3114            .progress(command_progress_with_input(
3115                live_label("gemini", options).as_deref(),
3116                options.progress.clone(),
3117                estimate_tokens(&options.prompt),
3118            ))
3119            .cancel(options.cancel.clone()),
3120    )
3121}
3122
3123fn live_label(name: &str, options: &EngineRunOptions) -> Option<String> {
3124    options.live.then(|| {
3125        format!(
3126            "{} {} iteration {}/{}",
3127            name, options.role, options.iteration, options.total_iterations
3128        )
3129    })
3130}
3131
3132fn provider_from_live_label(label: &str) -> Option<&str> {
3133    let provider = label.split_whitespace().next()?;
3134    ENGINES.contains(&provider).then_some(provider)
3135}
3136
3137fn role_from_live_label(label: &str) -> Option<&str> {
3138    if provider_from_live_label(label).is_some() {
3139        return label.split_whitespace().nth(1);
3140    }
3141    None
3142}
3143
3144fn iteration_from_live_label(label: &str) -> Option<usize> {
3145    iteration_pair_from_live_label(label).map(|(iteration, _)| iteration)
3146}
3147
3148fn total_iterations_from_live_label(label: &str) -> Option<usize> {
3149    iteration_pair_from_live_label(label).map(|(_, total)| total)
3150}
3151
3152fn iteration_pair_from_live_label(label: &str) -> Option<(usize, usize)> {
3153    let pair = label.split_whitespace().last()?;
3154    let (iteration, total) = pair.split_once('/')?;
3155    Some((iteration.parse().ok()?, total.parse().ok()?))
3156}
3157
3158fn prepare_gemini_settings(options: &EngineRunOptions) -> Option<TempSettings> {
3159    let effort = options.effort.as_deref()?;
3160    let budget = match effort {
3161        "low" => 1024,
3162        "medium" => 8192,
3163        "high" => 24576,
3164        _ => return None,
3165    };
3166    let dir = tempfile::tempdir().ok()?;
3167    let path = dir.path().join("settings.json");
3168    let mut settings = if options.capability.mode == CAPABILITY_OVERRIDE {
3169        options
3170            .capability
3171            .settings
3172            .as_ref()
3173            .and_then(|path| fs::read_to_string(path).ok())
3174            .and_then(|text| serde_json::from_str::<Value>(&text).ok())
3175            .unwrap_or_else(|| Value::Object(Default::default()))
3176    } else {
3177        Value::Object(Default::default())
3178    };
3179    if let Value::Object(object) = &mut settings {
3180        object.insert("thinkingBudget".to_string(), Value::from(budget));
3181    }
3182    let _ = fs::write(&path, serde_json::to_vec(&settings).ok()?);
3183    Some(TempSettings { _dir: dir, path })
3184}
3185
3186fn should_use_claude_bare_mode(auth: &str) -> bool {
3187    if auth == "api-key" {
3188        return true;
3189    }
3190    if matches!(auth, "social-login" | "oauth" | "keychain") {
3191        return false;
3192    }
3193    if std::env::var("CLAUDE_CODE_OAUTH_TOKEN")
3194        .ok()
3195        .filter(|value| !value.trim().is_empty())
3196        .is_some()
3197    {
3198        return false;
3199    }
3200    std::env::var("ANTHROPIC_API_KEY")
3201        .ok()
3202        .filter(|value| !value.trim().is_empty())
3203        .is_some()
3204}
3205
3206fn configure_process_group(command: &mut Command) {
3207    #[cfg(unix)]
3208    {
3209        use std::os::unix::process::CommandExt;
3210        command.process_group(0);
3211    }
3212}
3213
3214fn terminate_child_tree(child: &mut Child) {
3215    #[cfg(unix)]
3216    {
3217        let pgid = -(child.id() as i32);
3218        unsafe {
3219            libc::kill(pgid, libc::SIGTERM);
3220        }
3221        for _ in 0..20 {
3222            if child.try_wait().ok().flatten().is_some() {
3223                return;
3224            }
3225            thread::sleep(Duration::from_millis(25));
3226        }
3227        unsafe {
3228            libc::kill(pgid, libc::SIGKILL);
3229        }
3230    }
3231    let _ = child.kill();
3232}
3233
3234fn run_command(request: CommandRequest<'_>) -> CommandResult {
3235    let CommandRequest {
3236        command,
3237        args,
3238        cwd,
3239        stdin_text,
3240        timeout_ms,
3241        envs,
3242        progress,
3243        cancel,
3244    } = request;
3245    let started = Instant::now();
3246    if let Some(progress) = &progress {
3247        emit_runtime_event(
3248            &progress.sink,
3249            progress.sink.is_none(),
3250            progress_event_with_context(
3251                provider_from_live_label(&progress.label),
3252                role_from_live_label(&progress.label),
3253                ProgressStage::Spawn,
3254                Some("running"),
3255                iteration_from_live_label(&progress.label),
3256                total_iterations_from_live_label(&progress.label),
3257                role_from_live_label(&progress.label)
3258                    .is_some_and(|role| role.contains(":sub-agent-")),
3259                None,
3260                None,
3261                vec![],
3262                format!("[amon-hen] spawn {}", progress.label),
3263            ),
3264        );
3265    }
3266    let mut process = Command::new(command);
3267    process
3268        .args(args)
3269        .current_dir(cwd)
3270        .envs(envs)
3271        .stdin(Stdio::piped())
3272        .stdout(Stdio::piped())
3273        .stderr(Stdio::piped());
3274    configure_process_group(&mut process);
3275    let mut child = match process.spawn() {
3276        Ok(child) => child,
3277        Err(error) => {
3278            let status = spawn_error_status(&error);
3279            if let Some(progress) = &progress {
3280                emit_runtime_event(
3281                    &progress.sink,
3282                    progress.sink.is_none(),
3283                    progress_event_with_context(
3284                        provider_from_live_label(&progress.label),
3285                        role_from_live_label(&progress.label),
3286                        ProgressStage::Done,
3287                        Some(status),
3288                        iteration_from_live_label(&progress.label),
3289                        total_iterations_from_live_label(&progress.label),
3290                        role_from_live_label(&progress.label)
3291                            .is_some_and(|role| role.contains(":sub-agent-")),
3292                        Some(started.elapsed().as_millis()),
3293                        None,
3294                        vec![],
3295                        format!("[amon-hen] spawn failed {}: {error}", progress.label),
3296                    ),
3297                );
3298            }
3299            return CommandResult {
3300                command: command.to_string(),
3301                args: args.to_vec(),
3302                code: None,
3303                stdout: String::new(),
3304                stderr: String::new(),
3305                timed_out: false,
3306                cancelled: false,
3307                error: Some(error.to_string()),
3308                timeout_ms,
3309                duration_ms: started.elapsed().as_millis(),
3310            };
3311        }
3312    };
3313
3314    let stdout = child
3315        .stdout
3316        .take()
3317        .map(|pipe| read_pipe(pipe, progress.clone(), "stdout"));
3318    let stderr = child
3319        .stderr
3320        .take()
3321        .map(|pipe| read_pipe(pipe, progress.clone(), "stderr"));
3322    let mut stdin_writer = child.stdin.take().and_then(|mut stdin| {
3323        stdin_text.map(|text| {
3324            let text = text.to_string();
3325            thread::spawn(move || {
3326                let _ = stdin.write_all(text.as_bytes());
3327            })
3328        })
3329    });
3330    let timeout = Duration::from_millis(timeout_ms);
3331    let mut next_live_tick = Duration::from_secs(10);
3332    let mut timed_out = false;
3333    let mut cancelled = false;
3334    let code;
3335    loop {
3336        match child.try_wait() {
3337            Ok(Some(status)) => {
3338                code = status.code();
3339                break;
3340            }
3341            Ok(None) => {
3342                if cancel
3343                    .as_ref()
3344                    .is_some_and(|cancel| cancel.load(Ordering::Relaxed))
3345                {
3346                    cancelled = true;
3347                    terminate_child_tree(&mut child);
3348                    let status = child.wait().ok();
3349                    code = status.and_then(|status| status.code());
3350                    break;
3351                }
3352                if timeout_ms > 0 && started.elapsed() >= timeout {
3353                    timed_out = true;
3354                    terminate_child_tree(&mut child);
3355                    let status = child.wait().ok();
3356                    code = status.and_then(|status| status.code());
3357                    break;
3358                }
3359                if let Some(progress) = &progress {
3360                    let elapsed = started.elapsed();
3361                    if elapsed >= next_live_tick {
3362                        let timeout_detail = if timeout_ms > 0 {
3363                            format!("/{}s", timeout_ms / 1000)
3364                        } else {
3365                            String::new()
3366                        };
3367                        emit_runtime_event(
3368                            &progress.sink,
3369                            progress.sink.is_none(),
3370                            progress_event_with_context(
3371                                provider_from_live_label(&progress.label),
3372                                role_from_live_label(&progress.label),
3373                                ProgressStage::Heartbeat,
3374                                Some("running"),
3375                                iteration_from_live_label(&progress.label),
3376                                total_iterations_from_live_label(&progress.label),
3377                                role_from_live_label(&progress.label)
3378                                    .is_some_and(|role| role.contains(":sub-agent-")),
3379                                Some(elapsed.as_millis()),
3380                                None,
3381                                vec![],
3382                                format!(
3383                                    "[amon-hen] running {} for {}s{}",
3384                                    progress.label,
3385                                    elapsed.as_secs(),
3386                                    timeout_detail
3387                                ),
3388                            ),
3389                        );
3390                        next_live_tick += Duration::from_secs(10);
3391                    }
3392                }
3393                thread::sleep(Duration::from_millis(25));
3394            }
3395            Err(error) => {
3396                if let Some(handle) = stdin_writer.take() {
3397                    let _ = handle.join();
3398                }
3399                return CommandResult {
3400                    command: command.to_string(),
3401                    args: args.to_vec(),
3402                    code: None,
3403                    stdout: String::new(),
3404                    stderr: String::new(),
3405                    timed_out,
3406                    cancelled,
3407                    error: Some(error.to_string()),
3408                    timeout_ms,
3409                    duration_ms: started.elapsed().as_millis(),
3410                };
3411            }
3412        }
3413    }
3414
3415    if let Some(handle) = stdin_writer {
3416        let _ = handle.join();
3417    }
3418
3419    CommandResult {
3420        command: command.to_string(),
3421        args: args.to_vec(),
3422        code,
3423        stdout: stdout
3424            .and_then(|handle| handle.join().ok())
3425            .unwrap_or_default(),
3426        stderr: stderr
3427            .and_then(|handle| handle.join().ok())
3428            .unwrap_or_default(),
3429        timed_out,
3430        cancelled,
3431        error: cancelled.then(|| "cancelled".to_string()),
3432        timeout_ms,
3433        duration_ms: started.elapsed().as_millis(),
3434    }
3435}
3436
3437fn read_pipe<R>(
3438    mut pipe: R,
3439    progress: Option<CommandProgress>,
3440    stream: &'static str,
3441) -> thread::JoinHandle<String>
3442where
3443    R: Read + Send + 'static,
3444{
3445    thread::spawn(move || {
3446        let mut text = String::new();
3447        let mut pending = String::new();
3448        let mut buffer = [0u8; 4096];
3449        let mut last_stream_event = Instant::now() - PROVIDER_STREAM_EVENT_MIN_INTERVAL;
3450        let mut stream_state = ProviderStreamState::new();
3451        loop {
3452            let read = match pipe.read(&mut buffer) {
3453                Ok(0) => break,
3454                Ok(read) => read,
3455                Err(_) => break,
3456            };
3457            let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
3458            text.push_str(&chunk);
3459            pending.push_str(&chunk);
3460            while let Some(newline) = pending.find('\n') {
3461                let line = pending[..newline].trim_end_matches('\r').to_string();
3462                pending = pending[newline + 1..].to_string();
3463                if stream_state.maybe_emit_assistant_delta(
3464                    &progress,
3465                    stream,
3466                    &line,
3467                    &text,
3468                    &mut last_stream_event,
3469                ) {
3470                    continue;
3471                }
3472                maybe_emit_provider_stream_line(
3473                    &progress,
3474                    stream,
3475                    &line,
3476                    &text,
3477                    &mut last_stream_event,
3478                );
3479            }
3480            if !chunk.contains('\n') {
3481                maybe_emit_provider_stream_chunk(
3482                    &progress,
3483                    stream,
3484                    &chunk,
3485                    &text,
3486                    &mut last_stream_event,
3487                );
3488            }
3489        }
3490        stream_state.flush(&progress, stream, &text);
3491        if !pending.trim().is_empty() {
3492            emit_provider_stream_line(&progress, stream, &pending, &text);
3493        }
3494        text
3495    })
3496}
3497
3498struct ProviderStreamState {
3499    assistant_text: String,
3500    emitted_assistant_chars: usize,
3501}
3502
3503impl ProviderStreamState {
3504    fn new() -> Self {
3505        Self {
3506            assistant_text: String::new(),
3507            emitted_assistant_chars: 0,
3508        }
3509    }
3510
3511    fn maybe_emit_assistant_delta(
3512        &mut self,
3513        progress: &Option<CommandProgress>,
3514        stream: &str,
3515        line: &str,
3516        accumulated: &str,
3517        last_stream_event: &mut Instant,
3518    ) -> bool {
3519        let Some(progress) = progress else {
3520            return false;
3521        };
3522        let Some(provider) = provider_from_live_label(&progress.label) else {
3523            return false;
3524        };
3525        let Some(delta) = provider_assistant_text_delta(provider, line) else {
3526            return false;
3527        };
3528        if delta.is_empty() {
3529            return true;
3530        }
3531        self.assistant_text.push_str(&delta);
3532        let now = Instant::now();
3533        let chars_since_emit = self
3534            .assistant_text
3535            .chars()
3536            .count()
3537            .saturating_sub(self.emitted_assistant_chars);
3538        if chars_since_emit >= 96
3539            || now.duration_since(*last_stream_event) >= PROVIDER_STREAM_EVENT_MIN_INTERVAL
3540        {
3541            self.emit_snapshot(progress, stream, accumulated);
3542            *last_stream_event = now;
3543        }
3544        true
3545    }
3546
3547    fn flush(&mut self, progress: &Option<CommandProgress>, stream: &str, accumulated: &str) {
3548        let Some(progress) = progress else {
3549            return;
3550        };
3551        if self.assistant_text.chars().count() > self.emitted_assistant_chars {
3552            self.emit_snapshot(progress, stream, accumulated);
3553        }
3554    }
3555
3556    fn emit_snapshot(&mut self, progress: &CommandProgress, stream: &str, accumulated: &str) {
3557        let visible = format!(
3558            "assistant live: {}",
3559            sanitize_status_detail(&tail_chars(&self.assistant_text, 900))
3560        );
3561        emit_provider_stream_visible(progress, stream, accumulated, &visible, Vec::new());
3562        self.emitted_assistant_chars = self.assistant_text.chars().count();
3563    }
3564}
3565
3566fn maybe_emit_provider_stream_chunk(
3567    progress: &Option<CommandProgress>,
3568    stream: &str,
3569    chunk: &str,
3570    accumulated: &str,
3571    last_stream_event: &mut Instant,
3572) {
3573    let clipped = chunk.trim();
3574    if clipped.len() >= 24 {
3575        maybe_emit_provider_stream_line(progress, stream, clipped, accumulated, last_stream_event);
3576    }
3577}
3578
3579fn maybe_emit_provider_stream_line(
3580    progress: &Option<CommandProgress>,
3581    stream: &str,
3582    line: &str,
3583    accumulated: &str,
3584    last_stream_event: &mut Instant,
3585) {
3586    let now = Instant::now();
3587    let important = provider_stream_line_is_important(line);
3588    if important || now.duration_since(*last_stream_event) >= PROVIDER_STREAM_EVENT_MIN_INTERVAL {
3589        emit_provider_stream_line(progress, stream, line, accumulated);
3590        *last_stream_event = now;
3591    }
3592}
3593
3594fn provider_stream_line_is_important(line: &str) -> bool {
3595    let line = line.to_ascii_lowercase();
3596    [
3597        "tool_use",
3598        "tool_call",
3599        "functioncall",
3600        "function_call",
3601        "command_execution",
3602        "item.completed",
3603        "\"result\"",
3604        "\"error\"",
3605        "error",
3606        "failed",
3607        "permission",
3608    ]
3609    .iter()
3610    .any(|needle| line.contains(needle))
3611}
3612
3613fn emit_provider_stream_line(
3614    progress: &Option<CommandProgress>,
3615    stream: &str,
3616    line: &str,
3617    accumulated: &str,
3618) {
3619    let Some(progress) = progress else {
3620        return;
3621    };
3622    let line = line.trim();
3623    if line.is_empty() {
3624        return;
3625    }
3626    let provider = provider_from_live_label(&progress.label);
3627    let tool_calls = provider
3628        .map(|provider| extract_tool_usage(provider, line, ""))
3629        .unwrap_or_default();
3630    let visible = match provider_visible_stream(provider, line, &tool_calls) {
3631        StreamDisplay::Visible(visible) => visible,
3632        StreamDisplay::Suppress => return,
3633    };
3634    emit_provider_stream_visible(progress, stream, accumulated, &visible, tool_calls);
3635}
3636
3637fn emit_provider_stream_visible(
3638    progress: &CommandProgress,
3639    stream: &str,
3640    accumulated: &str,
3641    visible: &str,
3642    tool_calls: Vec<ToolUsage>,
3643) {
3644    let provider = provider_from_live_label(&progress.label);
3645    let role = role_from_live_label(&progress.label);
3646    let output_tokens = estimate_tokens(accumulated);
3647    let token_usage = provider.map(|_| TokenUsage {
3648        input: progress.input_tokens,
3649        output: output_tokens,
3650        total: progress.input_tokens + output_tokens,
3651        estimated: true,
3652        source: "live-stream-estimate".to_string(),
3653    });
3654    emit_runtime_event(
3655        &progress.sink,
3656        progress.sink.is_none(),
3657        progress_event_with_context(
3658            provider,
3659            role,
3660            ProgressStage::Heartbeat,
3661            Some("streaming"),
3662            iteration_from_live_label(&progress.label),
3663            total_iterations_from_live_label(&progress.label),
3664            role.is_some_and(|role| role.contains(":sub-agent-")),
3665            None,
3666            token_usage,
3667            tool_calls,
3668            format!(
3669                "[amon-hen] stream {} {stream}: {}",
3670                progress.label,
3671                truncate(visible, 220)
3672            ),
3673        ),
3674    );
3675}
3676
3677#[derive(Debug, Eq, PartialEq)]
3678enum StreamDisplay {
3679    Visible(String),
3680    Suppress,
3681}
3682
3683fn provider_visible_stream(
3684    provider: Option<&str>,
3685    line: &str,
3686    tool_calls: &[ToolUsage],
3687) -> StreamDisplay {
3688    let Some(provider) = provider else {
3689        return StreamDisplay::Visible(sanitize_status_detail(line));
3690    };
3691    let Ok(value) = serde_json::from_str::<Value>(line) else {
3692        if looks_like_json_line(line) {
3693            return StreamDisplay::Suppress;
3694        }
3695        return StreamDisplay::Visible(sanitize_status_detail(line));
3696    };
3697    let visible = match provider {
3698        "claude" => claude_visible_stream(&value),
3699        "gemini" => gemini_visible_stream(&value),
3700        "codex" => codex_visible_stream(&value),
3701        _ => None,
3702    }
3703    .or_else(|| tool_calls.first().map(visible_tool_summary));
3704    visible
3705        .filter(|text| !text.trim().is_empty())
3706        .map(StreamDisplay::Visible)
3707        .unwrap_or(StreamDisplay::Suppress)
3708}
3709
3710fn provider_assistant_text_delta(provider: &str, line: &str) -> Option<String> {
3711    let value = serde_json::from_str::<Value>(line).ok()?;
3712    match provider {
3713        "claude" => claude_assistant_text_delta(&value),
3714        _ => None,
3715    }
3716}
3717
3718fn claude_assistant_text_delta(value: &Value) -> Option<String> {
3719    if value.get("type").and_then(Value::as_str) == Some("stream_event") {
3720        return value.get("event").and_then(claude_assistant_text_delta);
3721    }
3722    match value.get("type").and_then(Value::as_str) {
3723        Some("content_block_delta") => {
3724            let delta = value.get("delta")?;
3725            if delta.get("type").and_then(Value::as_str) == Some("text_delta") {
3726                return delta
3727                    .get("text")
3728                    .and_then(Value::as_str)
3729                    .map(str::to_string);
3730            }
3731        }
3732        Some("message_delta" | "message_start" | "message_stop" | "content_block_stop") => {}
3733        _ => {
3734            if let Some(text) = value.pointer("/delta/text").and_then(Value::as_str) {
3735                return Some(text.to_string());
3736            }
3737        }
3738    }
3739    None
3740}
3741
3742fn tail_chars(text: &str, max_chars: usize) -> String {
3743    let total = text.chars().count();
3744    if total <= max_chars {
3745        return text.to_string();
3746    }
3747    let tail = text
3748        .chars()
3749        .skip(total.saturating_sub(max_chars))
3750        .collect::<String>();
3751    format!("...{tail}")
3752}
3753
3754fn visible_tool_summary(tool: &ToolUsage) -> String {
3755    let detail = sanitize_status_detail(&tool.detail);
3756    if detail.is_empty() {
3757        format!("tool {}: {}", tool.status, tool.name)
3758    } else {
3759        format!("tool {}: {} {}", tool.status, tool.name, detail)
3760    }
3761}
3762
3763fn claude_visible_stream(value: &Value) -> Option<String> {
3764    if value.get("type").and_then(Value::as_str) == Some("stream_event") {
3765        return value.get("event").and_then(claude_visible_stream);
3766    }
3767    match value.get("type").and_then(Value::as_str) {
3768        Some("content_block_delta") => return claude_visible_delta(value.get("delta")?),
3769        Some("content_block_start") => {
3770            return value
3771                .get("content_block")
3772                .and_then(visible_content_block)
3773                .map(|text| sanitize_status_detail(&text));
3774        }
3775        Some("content_block_stop" | "message_start" | "message_delta" | "message_stop") => {
3776            return None;
3777        }
3778        _ => {}
3779    }
3780    if let Some(result) = non_empty_str(value.get("result")) {
3781        return Some(format!("result: {}", sanitize_status_detail(result)));
3782    }
3783    if let Some(content) = value.pointer("/message/content").and_then(Value::as_array) {
3784        if let Some(text) = visible_content_blocks(content) {
3785            return Some(text);
3786        }
3787    }
3788    if let Some(delta) = value.pointer("/delta/text").and_then(Value::as_str) {
3789        return Some(format!("assistant: {}", sanitize_status_detail(delta)));
3790    }
3791    if let Some(block) = value.pointer("/content_block") {
3792        if let Some(text) = visible_content_block(block) {
3793            return Some(text);
3794        }
3795    }
3796    if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
3797        return Some(format!("error: {}", sanitize_status_detail(error)));
3798    }
3799    None
3800}
3801
3802fn claude_visible_delta(delta: &Value) -> Option<String> {
3803    match delta.get("type").and_then(Value::as_str) {
3804        Some("text_delta") => delta
3805            .get("text")
3806            .and_then(Value::as_str)
3807            .filter(|text| !text.trim().is_empty())
3808            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
3809        Some("input_json_delta" | "thinking_delta" | "signature_delta") => None,
3810        _ => delta
3811            .get("text")
3812            .and_then(Value::as_str)
3813            .filter(|text| !text.trim().is_empty())
3814            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
3815    }
3816}
3817
3818fn claude_output_text(value: &Value) -> Option<String> {
3819    if value.get("type").and_then(Value::as_str) == Some("stream_event") {
3820        return value.get("event").and_then(claude_output_text);
3821    }
3822    match value.get("type").and_then(Value::as_str) {
3823        Some("content_block_delta") => {
3824            let delta = value.get("delta")?;
3825            return match delta.get("type").and_then(Value::as_str) {
3826                Some("text_delta") => non_empty_str(delta.get("text")).map(str::to_string),
3827                _ => None,
3828            };
3829        }
3830        Some("content_block_start") => {
3831            let block = value.get("content_block")?;
3832            if matches!(
3833                block.get("type").and_then(Value::as_str),
3834                Some("text" | "output_text")
3835            ) {
3836                return non_empty_str(block.get("text")).map(str::to_string);
3837            }
3838        }
3839        _ => {}
3840    }
3841    if let Some(result) = non_empty_str(value.get("result")) {
3842        return Some(result.to_string());
3843    }
3844    value
3845        .pointer("/message/content")
3846        .and_then(Value::as_array)
3847        .map(|content| {
3848            content
3849                .iter()
3850                .filter_map(|block| {
3851                    matches!(
3852                        block.get("type").and_then(Value::as_str),
3853                        Some("text" | "output_text")
3854                    )
3855                    .then(|| non_empty_str(block.get("text")))
3856                    .flatten()
3857                })
3858                .collect::<String>()
3859        })
3860        .filter(|text| !text.trim().is_empty())
3861}
3862
3863fn gemini_visible_stream(value: &Value) -> Option<String> {
3864    if let Some(response) = non_empty_str(value.get("response")) {
3865        return Some(format!("assistant: {}", sanitize_status_detail(response)));
3866    }
3867    if let Some(text) = non_empty_str(value.get("text")) {
3868        return Some(format!("assistant: {}", sanitize_status_detail(text)));
3869    }
3870    if let Some(text) = value
3871        .pointer("/candidates/0/content/parts/0/text")
3872        .and_then(Value::as_str)
3873        .filter(|text| !text.trim().is_empty())
3874    {
3875        return Some(format!("assistant: {}", sanitize_status_detail(text)));
3876    }
3877    if let Some(call) = value
3878        .pointer("/functionCall")
3879        .or_else(|| value.pointer("/candidates/0/content/parts/0/functionCall"))
3880    {
3881        return Some(format!("tool: {}", function_call_summary(call)));
3882    }
3883    if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
3884        return Some(format!("error: {}", sanitize_status_detail(error)));
3885    }
3886    None
3887}
3888
3889fn codex_visible_stream(value: &Value) -> Option<String> {
3890    if let Some(message) = non_empty_str(value.get("message")) {
3891        return Some(format!("assistant: {}", sanitize_status_detail(message)));
3892    }
3893    if let Some(text) = non_empty_str(value.get("text")) {
3894        return Some(format!("assistant: {}", sanitize_status_detail(text)));
3895    }
3896    if let Some(item) = value.get("item") {
3897        return codex_visible_item(item);
3898    }
3899    if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
3900        return Some(format!("error: {}", sanitize_status_detail(error)));
3901    }
3902    None
3903}
3904
3905fn codex_visible_item(item: &Value) -> Option<String> {
3906    let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
3907    match item_type {
3908        "message" => codex_message_text(item),
3909        "command_execution" => {
3910            let command = item
3911                .get("command")
3912                .and_then(Value::as_str)
3913                .map(sanitize_status_detail)
3914                .unwrap_or_else(|| "command".to_string());
3915            let output = item
3916                .get("aggregated_output")
3917                .or_else(|| item.get("output"))
3918                .and_then(Value::as_str)
3919                .map(sanitize_status_detail)
3920                .unwrap_or_default();
3921            if output.trim().is_empty() {
3922                Some(format!("tool: shell {command}"))
3923            } else {
3924                Some(format!(
3925                    "tool: shell {command} -> {}",
3926                    truncate(&output, 140)
3927                ))
3928            }
3929        }
3930        "function_call" | "tool_call" => Some(format!("tool: {}", function_call_summary(item))),
3931        "error" => item
3932            .get("message")
3933            .and_then(Value::as_str)
3934            .map(|message| format!("error: {}", sanitize_status_detail(message))),
3935        "reasoning" => item
3936            .get("summary")
3937            .and_then(Value::as_str)
3938            .filter(|summary| !summary.trim().is_empty())
3939            .map(|summary| format!("reasoning summary: {}", sanitize_status_detail(summary))),
3940        _ => None,
3941    }
3942}
3943
3944fn codex_message_text(item: &Value) -> Option<String> {
3945    if let Some(text) = non_empty_str(item.get("text")) {
3946        return Some(sanitize_status_detail(text));
3947    }
3948    item.get("content")
3949        .and_then(Value::as_array)
3950        .and_then(|blocks| visible_content_blocks(blocks))
3951}
3952
3953fn visible_content_blocks(blocks: &[Value]) -> Option<String> {
3954    let parts = blocks
3955        .iter()
3956        .filter_map(visible_content_block)
3957        .collect::<Vec<_>>();
3958    (!parts.is_empty()).then(|| parts.join(" | "))
3959}
3960
3961fn visible_content_block(block: &Value) -> Option<String> {
3962    match block.get("type").and_then(Value::as_str) {
3963        Some("text") | Some("output_text") => non_empty_str(block.get("text"))
3964            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
3965        Some("tool_use") => {
3966            let name = block.get("name").and_then(Value::as_str).unwrap_or("tool");
3967            let input = block
3968                .get("input")
3969                .map(short_json_detail)
3970                .unwrap_or_default();
3971            if input.is_empty() {
3972                Some(format!("tool: {name}"))
3973            } else {
3974                Some(format!("tool: {name} {}", truncate(&input, 140)))
3975            }
3976        }
3977        Some("function_call") | Some("tool_call") => {
3978            Some(format!("tool: {}", function_call_summary(block)))
3979        }
3980        _ => non_empty_str(block.get("text"))
3981            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
3982    }
3983}
3984
3985fn function_call_summary(value: &Value) -> String {
3986    let name = value
3987        .get("name")
3988        .or_else(|| value.get("tool"))
3989        .or_else(|| value.get("function"))
3990        .and_then(Value::as_str)
3991        .unwrap_or("tool");
3992    let args = value
3993        .get("args")
3994        .or_else(|| value.get("arguments"))
3995        .or_else(|| value.get("input"))
3996        .map(short_json_detail)
3997        .unwrap_or_default();
3998    if args.is_empty() {
3999        name.to_string()
4000    } else {
4001        format!("{name} {}", truncate(&args, 140))
4002    }
4003}
4004
4005fn non_empty_str(value: Option<&Value>) -> Option<&str> {
4006    value
4007        .and_then(Value::as_str)
4008        .map(str::trim)
4009        .filter(|text| !text.is_empty())
4010}
4011
4012fn finalize_engine(
4013    name: &str,
4014    bin: &str,
4015    duration_ms: u128,
4016    command_result: CommandResult,
4017    options: EngineRunOptions,
4018    sub_agents: Vec<EngineResult>,
4019) -> EngineResult {
4020    let output = match Engine::parse(name) {
4021        Some(Engine::Claude) => parse_claude_output(&command_result.stdout),
4022        Some(Engine::Gemini) => parse_gemini_output(&command_result.stdout),
4023        Some(Engine::Codex) => parse_codex_output(&command_result.stdout),
4024        None => command_result.stdout.trim().to_string(),
4025    };
4026    let status = if command_result.cancelled {
4027        "cancelled"
4028    } else if let Some(error) = command_result.error.as_deref() {
4029        spawn_failure_status(error)
4030    } else if command_result.timed_out {
4031        "timeout"
4032    } else if command_result.code != Some(0) || output.trim().is_empty() {
4033        "error"
4034    } else {
4035        "ok"
4036    };
4037    let detail = if status == "ok" {
4038        String::new()
4039    } else if command_result.cancelled {
4040        "Cancelled by Studio/user request.".to_string()
4041    } else if command_result.timed_out {
4042        format!("Timed out after {}s.", command_result.timeout_ms / 1000)
4043    } else {
4044        compact_failure(&command_result)
4045    };
4046    let usage = aggregate_token_usage(
4047        extract_token_usage(&command_result.stdout, &options.prompt, &output)
4048            .unwrap_or_else(|| token_usage(&options.prompt, &output)),
4049        &sub_agents,
4050    );
4051    let tool_calls = extract_tool_usage(name, &command_result.stdout, &command_result.stderr);
4052    EngineResult {
4053        name: name.to_string(),
4054        bin: Some(bin.to_string()),
4055        status: status.to_string(),
4056        duration_ms,
4057        detail,
4058        exit_code: command_result.code,
4059        stdout: command_result.stdout,
4060        stderr: command_result.stderr,
4061        output,
4062        command: format_command(&command_result.command, &command_result.args),
4063        token_usage: usage,
4064        tool_calls,
4065        sub_agents,
4066        role: options.role,
4067        iteration: options.iteration,
4068        total_iterations: options.total_iterations,
4069        team_size: options.team_size,
4070    }
4071}
4072
4073fn compact_failure(result: &CommandResult) -> String {
4074    if let Some(error) = &result.error {
4075        return error.clone();
4076    }
4077    if !result.stderr.trim().is_empty() {
4078        return result.stderr.trim().to_string();
4079    }
4080    if !result.stdout.trim().is_empty() {
4081        return result.stdout.trim().to_string();
4082    }
4083    result
4084        .code
4085        .map(|code| format!("Exited with code {code}."))
4086        .unwrap_or_else(|| "Command failed.".to_string())
4087}
4088
4089fn command_telemetry(result: &CommandResult) -> CommandTelemetry {
4090    let status = if result.cancelled {
4091        "cancelled"
4092    } else if let Some(error) = result.error.as_deref() {
4093        spawn_failure_status(error)
4094    } else if result.timed_out {
4095        "timeout"
4096    } else if result.code == Some(0) {
4097        "ok"
4098    } else {
4099        "error"
4100    };
4101    CommandTelemetry {
4102        command: format_command(&result.command, &result.args),
4103        status: status.to_string(),
4104        detail: truncate(&sanitize_status_detail(&compact_failure(result)), 600),
4105        exit_code: result.code,
4106        duration_ms: result.duration_ms,
4107        stdout_chars: result.stdout.len(),
4108        stderr_chars: result.stderr.len(),
4109        timed_out: result.timed_out,
4110    }
4111}
4112
4113fn spawn_error_status(error: &io::Error) -> &'static str {
4114    if error.kind() == io::ErrorKind::NotFound {
4115        "missing"
4116    } else {
4117        "error"
4118    }
4119}
4120
4121fn spawn_failure_status(error: &str) -> &'static str {
4122    let lowered = error.to_ascii_lowercase();
4123    if lowered.contains("no such file")
4124        || lowered.contains("not found")
4125        || lowered.contains("cannot find")
4126    {
4127        "missing"
4128    } else {
4129        "error"
4130    }
4131}
4132
4133fn parse_codex_output(stdout: &str) -> String {
4134    stdout.trim().to_string()
4135}
4136
4137fn parse_claude_output(stdout: &str) -> String {
4138    let trimmed = stdout.trim();
4139    if trimmed.is_empty() {
4140        return String::new();
4141    }
4142    if let Some(value) = extract_json(trimmed) {
4143        if let Some(result) = value.get("result").and_then(Value::as_str) {
4144            return result.trim().to_string();
4145        }
4146    }
4147    let mut latest = String::new();
4148    let mut streamed_text = String::new();
4149    for line in trimmed.lines() {
4150        let Ok(value) = serde_json::from_str::<Value>(line) else {
4151            continue;
4152        };
4153        if value.get("type").and_then(Value::as_str) == Some("result") {
4154            if let Some(result) = value.get("result").and_then(Value::as_str) {
4155                latest = result.trim().to_string();
4156            }
4157            continue;
4158        }
4159        if let Some(text) = claude_output_text(&value) {
4160            if value.get("type").and_then(Value::as_str) == Some("stream_event") {
4161                streamed_text.push_str(&text);
4162                latest = streamed_text.trim().to_string();
4163            } else if !text.trim().is_empty() {
4164                latest = text.trim().to_string();
4165            }
4166        }
4167    }
4168    if latest.is_empty() {
4169        trimmed.to_string()
4170    } else {
4171        latest
4172    }
4173}
4174
4175fn parse_gemini_output(stdout: &str) -> String {
4176    let trimmed = stdout.trim();
4177    if trimmed.is_empty() {
4178        return String::new();
4179    }
4180    if let Some(value) = extract_json(trimmed) {
4181        if let Some(response) = value.get("response").and_then(Value::as_str) {
4182            return response.trim().to_string();
4183        }
4184    }
4185    trimmed.to_string()
4186}
4187
4188fn extract_json(text: &str) -> Option<Value> {
4189    serde_json::from_str::<Value>(text).ok().or_else(|| {
4190        let start = text.find('{')?;
4191        let end = text.rfind('}')?;
4192        serde_json::from_str::<Value>(&text[start..=end]).ok()
4193    })
4194}
4195
4196fn build_member_prompt(input: MemberPromptInput<'_>) -> String {
4197    let mut sections = vec![
4198        "You are one member of a multi-model Amon Hen run.".to_string(),
4199        format!(
4200            "Amon Hen workflow: iteration {iteration} of {}.",
4201            input.workflow.iterations,
4202            iteration = input.iteration
4203        ),
4204        input
4205            .workflow
4206            .lead
4207            .as_ref()
4208            .map(|lead| format!("Lead model: {lead}."))
4209            .unwrap_or_else(|| "Lead model: auto.".to_string()),
4210        input
4211            .workflow
4212            .planner
4213            .as_ref()
4214            .map(|planner| format!("Planner model: {planner}."))
4215            .unwrap_or_else(|| "Planner model: none.".to_string()),
4216        format!("Your assigned role: {}.", input.role),
4217        role_instruction(input.role).to_string(),
4218        "Answer the user query directly.".to_string(),
4219        "Do not introduce yourself.".to_string(),
4220        "Do not describe your tools, environment, or capabilities unless the user explicitly asks."
4221            .to_string(),
4222        "Be concise unless the user asks for depth.".to_string(),
4223    ];
4224    if input.team_size > 0 {
4225        sections.push(format!(
4226            "Team work: you may coordinate up to {} internal sub-agents or subtasks inside your own CLI if that helps.",
4227            input.team_size
4228        ));
4229    }
4230    if input.workflow.handoff {
4231        sections.push(
4232            "Handoff mode is enabled. Treat earlier Amon Hen outputs as working context."
4233                .to_string(),
4234        );
4235    }
4236    if input.workflow.planner_mode == PLANNER_MODE_REVIEW_CHAIN {
4237        sections.push(
4238            "Review-chain mode is enabled. Each Amon Hen member runs after the previous member. \
4239             Review prior agent output and the current repo state before changing anything, preserve accepted work, \
4240             call out conflicts, and only make deliberate deltas that survive that review."
4241                .to_string(),
4242        );
4243    }
4244    if !input.plan_output.trim().is_empty() {
4245        sections.push(format!("Planner handoff:\n{}", input.plan_output.trim()));
4246    }
4247    let context = input
4248        .previous_iteration
4249        .iter()
4250        .chain(input.handoff_results.iter())
4251        .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
4252        .map(|result| {
4253            format!(
4254                "### {} ({})\n{}",
4255                result.name,
4256                result.role,
4257                result.output.trim()
4258            )
4259        })
4260        .collect::<Vec<_>>()
4261        .join("\n\n");
4262    if !context.is_empty() {
4263        sections.push(format!("Earlier Amon Hen handoffs:\n{context}"));
4264    }
4265    sections.push(format!("Current user query:\n{}", input.query.trim()));
4266    sections.join("\n\n")
4267}
4268
4269fn build_summary_prompt(
4270    query: &str,
4271    responses: &[EngineResult],
4272    workflow: &Workflow,
4273    max_member_chars: usize,
4274) -> String {
4275    let blocks = responses
4276        .iter()
4277        .map(|response| {
4278            let output = truncate(&response.output, max_member_chars);
4279            format!("### {}\n{}", response.name, output.trim())
4280        })
4281        .collect::<Vec<_>>()
4282        .join("\n\n");
4283    format!(
4284        "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{}",
4285        workflow.iterations,
4286        if workflow.iterations == 1 { "" } else { "s" },
4287        if workflow.handoff { "handoff enabled" } else { "parallel consultation" },
4288        workflow.lead.as_deref().unwrap_or("auto"),
4289        workflow.planner.as_deref().unwrap_or("none"),
4290        query.trim(),
4291        blocks
4292    )
4293}
4294
4295fn role_instruction(role: &str) -> &'static str {
4296    match role {
4297        "planner" => "Plan the work: identify the approach, risks, checkpoints, and useful handoffs for the executors.",
4298        "lead" => "Lead the work: make the strongest direct attempt while watching for conflicts you may need to resolve in synthesis.",
4299        "lead+planner" => "Plan and lead the work: produce a practical plan, then make the strongest direct attempt from that plan.",
4300        _ => "Execute the work: use any plan or handoff context, then produce your independent best answer.",
4301    }
4302}
4303
4304fn execution_order(members: &[String], planner: Option<&str>, lead: Option<&str>) -> Vec<String> {
4305    let mut ordered = Vec::new();
4306    if let Some(planner) = planner {
4307        if members.iter().any(|member| member == planner) {
4308            ordered.push(planner.to_string());
4309        }
4310    }
4311    ordered.extend(
4312        members
4313            .iter()
4314            .filter(|member| Some(member.as_str()) != planner && Some(member.as_str()) != lead)
4315            .cloned(),
4316    );
4317    if lead != planner {
4318        if let Some(lead) = lead {
4319            if members.iter().any(|member| member == lead) {
4320                ordered.push(lead.to_string());
4321            }
4322        }
4323    }
4324    ordered
4325}
4326
4327fn role_for(member: &str, workflow: &Workflow) -> String {
4328    let is_lead = workflow.lead.as_deref() == Some(member);
4329    let is_planner = workflow.planner.as_deref() == Some(member);
4330    match (is_lead, is_planner) {
4331        (true, true) => "lead+planner",
4332        (true, false) => "lead",
4333        (false, true) => "planner",
4334        (false, false) => "executor",
4335    }
4336    .to_string()
4337}
4338
4339fn pick_summarizer(resolved: &ResolvedArgs, successes: &[EngineResult]) -> String {
4340    if resolved.raw.summarizer != "auto" {
4341        return resolved.raw.summarizer.clone();
4342    }
4343    if let Some(lead) = &resolved.raw.lead {
4344        if successes.iter().any(|result| &result.name == lead) {
4345            return lead.clone();
4346        }
4347    }
4348    DEFAULT_SUMMARIZER_ORDER
4349        .iter()
4350        .find(|name| successes.iter().any(|result| result.name == **name))
4351        .unwrap_or(&successes[0].name.as_str())
4352        .to_string()
4353}
4354
4355fn resolve_binary(name: &str) -> String {
4356    let Some(engine) = Engine::parse(name) else {
4357        return name.to_string();
4358    };
4359    std::env::var(engine.binary_env_var())
4360        .ok()
4361        .filter(|value| !value.trim().is_empty())
4362        .unwrap_or_else(|| engine.as_str().to_string())
4363}
4364
4365fn token_usage(prompt: &str, output: &str) -> TokenUsage {
4366    let input = estimate_tokens(prompt);
4367    let output = estimate_tokens(output);
4368    TokenUsage {
4369        input,
4370        output,
4371        total: input + output,
4372        estimated: true,
4373        source: "estimate".to_string(),
4374    }
4375}
4376
4377fn aggregate_token_usage(mut usage: TokenUsage, sub_agents: &[EngineResult]) -> TokenUsage {
4378    if sub_agents.is_empty() {
4379        return usage;
4380    }
4381    for agent in sub_agents {
4382        usage.input += agent.token_usage.input;
4383        usage.output += agent.token_usage.output;
4384        usage.total += agent.token_usage.total;
4385        usage.estimated |= agent.token_usage.estimated;
4386    }
4387    usage.source = format!("{}+sub-agents", usage.source);
4388    usage
4389}
4390
4391fn aggregate_results_token_usage<'a>(
4392    results: impl IntoIterator<Item = &'a EngineResult>,
4393) -> TokenUsage {
4394    let mut usage = TokenUsage {
4395        input: 0,
4396        output: 0,
4397        total: 0,
4398        estimated: false,
4399        source: "aggregate".to_string(),
4400    };
4401    for result in results {
4402        usage.input += result.token_usage.input;
4403        usage.output += result.token_usage.output;
4404        usage.total += result.token_usage.total;
4405        usage.estimated |= result.token_usage.estimated;
4406    }
4407    usage
4408}
4409
4410fn extract_token_usage(stdout: &str, prompt: &str, output: &str) -> Option<TokenUsage> {
4411    let mut usage = TokenAccumulator::default();
4412    for value in json_values(stdout) {
4413        collect_token_counts(&value, &mut usage);
4414    }
4415    if usage.input.is_none() && usage.output.is_none() && usage.total.is_none() {
4416        return None;
4417    }
4418    let input = usage.input.unwrap_or_else(|| estimate_tokens(prompt));
4419    let output = usage.output.unwrap_or_else(|| estimate_tokens(output));
4420    let total = usage.total.unwrap_or(input + output);
4421    Some(TokenUsage {
4422        input,
4423        output,
4424        total,
4425        estimated: usage.input.is_none() || usage.output.is_none(),
4426        source: "provider".to_string(),
4427    })
4428}
4429
4430#[derive(Default)]
4431struct TokenAccumulator {
4432    input: Option<usize>,
4433    output: Option<usize>,
4434    total: Option<usize>,
4435}
4436
4437fn collect_token_counts(value: &Value, usage: &mut TokenAccumulator) {
4438    match value {
4439        Value::Object(map) => {
4440            for (key, value) in map {
4441                let normalized = key.replace(['_', '-'], "").to_ascii_lowercase();
4442                if let Some(count) = value.as_u64().map(|value| value as usize) {
4443                    match normalized.as_str() {
4444                        "inputtokens" | "inputtokencount" | "prompttokens" | "prompttokencount" => {
4445                            usage.input = Some(count)
4446                        }
4447                        "outputtokens"
4448                        | "outputtokencount"
4449                        | "completiontokens"
4450                        | "candidatestokencount" => usage.output = Some(count),
4451                        "totaltokens" | "totaltokencount" => usage.total = Some(count),
4452                        _ => {}
4453                    }
4454                }
4455                collect_token_counts(value, usage);
4456            }
4457        }
4458        Value::Array(values) => {
4459            for value in values {
4460                collect_token_counts(value, usage);
4461            }
4462        }
4463        _ => {}
4464    }
4465}
4466
4467fn extract_tool_usage(provider: &str, stdout: &str, stderr: &str) -> Vec<ToolUsage> {
4468    let mut tools = Vec::new();
4469    for value in json_values(stdout) {
4470        collect_tool_usage(&value, &mut tools);
4471    }
4472    for line in stdout.lines().chain(stderr.lines()) {
4473        if let Some(tool) = line_tool_usage(provider, line) {
4474            tools.push(tool);
4475        }
4476    }
4477    dedupe_tools(tools)
4478}
4479
4480fn collect_tool_usage(value: &Value, tools: &mut Vec<ToolUsage>) {
4481    match value {
4482        Value::Object(map) => {
4483            let type_name = map.get("type").and_then(Value::as_str).unwrap_or_default();
4484            let name = map
4485                .get("name")
4486                .or_else(|| map.get("tool"))
4487                .or_else(|| map.get("tool_name"))
4488                .or_else(|| map.get("toolName"))
4489                .and_then(Value::as_str);
4490            if let Some(name) = name.filter(|_| {
4491                type_name.contains("tool")
4492                    || map.contains_key("input")
4493                    || map.contains_key("arguments")
4494                    || map.contains_key("toolCall")
4495            }) {
4496                tools.push(ToolUsage {
4497                    name: name.to_string(),
4498                    kind: if type_name.is_empty() {
4499                        "tool"
4500                    } else {
4501                        type_name
4502                    }
4503                    .to_string(),
4504                    status: "observed".to_string(),
4505                    detail: short_json_detail(value),
4506                });
4507            }
4508            for value in map.values() {
4509                collect_tool_usage(value, tools);
4510            }
4511        }
4512        Value::Array(values) => {
4513            for value in values {
4514                collect_tool_usage(value, tools);
4515            }
4516        }
4517        _ => {}
4518    }
4519}
4520
4521fn line_tool_usage(provider: &str, line: &str) -> Option<ToolUsage> {
4522    let trimmed = line.trim();
4523    if looks_like_json_line(trimmed) {
4524        return None;
4525    }
4526    let lowered = trimmed.to_ascii_lowercase();
4527    let marker = ["running shell:", "tool:", "tool_use", "mcp:", "command:"]
4528        .iter()
4529        .find(|marker| lowered.contains(**marker))?;
4530    Some(ToolUsage {
4531        name: marker.trim_end_matches(':').to_string(),
4532        kind: provider.to_string(),
4533        status: "observed".to_string(),
4534        detail: truncate(trimmed, 240),
4535    })
4536}
4537
4538fn short_json_detail(value: &Value) -> String {
4539    serde_json::to_string(value)
4540        .map(|text| truncate(&text, 240))
4541        .unwrap_or_default()
4542}
4543
4544fn dedupe_tools(tools: Vec<ToolUsage>) -> Vec<ToolUsage> {
4545    let mut seen = HashSet::new();
4546    tools
4547        .into_iter()
4548        .filter(|tool| seen.insert(format!("{}:{}:{}", tool.kind, tool.name, tool.detail)))
4549        .collect()
4550}
4551
4552fn json_values(text: &str) -> Vec<Value> {
4553    let trimmed = text.trim();
4554    if trimmed.is_empty() {
4555        return vec![];
4556    }
4557    let mut values = Vec::new();
4558    if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
4559        values.push(value);
4560        return values;
4561    }
4562    for line in trimmed.lines() {
4563        if let Ok(value) = serde_json::from_str::<Value>(line.trim()) {
4564            values.push(value);
4565        }
4566    }
4567    values
4568}
4569
4570fn looks_like_json_line(line: &str) -> bool {
4571    let trimmed = line.trim_start();
4572    trimmed.starts_with('{') || trimmed.starts_with('[')
4573}
4574
4575fn estimate_tokens(text: &str) -> usize {
4576    if text.trim().is_empty() {
4577        0
4578    } else {
4579        text.len().div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN)
4580    }
4581}
4582
4583fn format_command(command: &str, args: &[String]) -> String {
4584    std::iter::once(command.to_string())
4585        .chain(args.iter().map(|arg| shell_quote(arg)))
4586        .collect::<Vec<_>>()
4587        .join(" ")
4588}
4589
4590fn shell_quote(value: &str) -> String {
4591    if value
4592        .chars()
4593        .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
4594    {
4595        value.to_string()
4596    } else {
4597        serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
4598    }
4599}
4600
4601fn truncate(text: &str, max_chars: usize) -> String {
4602    if text.chars().count() <= max_chars {
4603        text.to_string()
4604    } else {
4605        let mut truncated = text.chars().take(max_chars).collect::<String>();
4606        truncated.push_str("\n...[truncated]");
4607        truncated
4608    }
4609}
4610
4611fn should_show_banner(raw: &CliArgs) -> bool {
4612    !raw.no_banner && !raw.headless && !raw.json && !raw.json_stream && !raw.plain
4613}
4614
4615fn linear_delivery_requested(raw: &CliArgs) -> bool {
4616    raw.deliver_linear
4617        || raw.linear_until_complete
4618        || raw.linear_watch
4619        || !raw.linear_issue.is_empty()
4620        || raw
4621            .linear_query
4622            .as_ref()
4623            .is_some_and(|value| !value.trim().is_empty())
4624        || !raw.linear_project.is_empty()
4625        || !raw.linear_epic.is_empty()
4626}
4627
4628fn render_banner() -> &'static str {
4629    "    _                         _   _            \n   / \\   _ __ ___   ___  _ __ | | | | ___ _ __  \n  / _ \\ | '_ ` _ \\ / _ \\| '_ \\| |_| |/ _ \\ '_ \\ \n / ___ \\| | | | | | (_) | | | |  _  |  __/ | | |\n/_/   \\_\\_| |_| |_|\\___/|_| |_|_| |_|\\___|_| |_|"
4630}
4631
4632fn render_human_result(result: &AmonHenResult, verbose: bool) -> String {
4633    let mut lines = Vec::new();
4634    if verbose {
4635        lines.push(format!(
4636            "Amon Hen consulted: {}",
4637            result.members_requested.join(", ")
4638        ));
4639        for command in &result.prompt_commands {
4640            lines.push(format!(
4641                "cmd [{}] {} ({:.1}s)",
4642                command.status,
4643                command.command,
4644                command.duration_ms as f64 / 1000.0
4645            ));
4646        }
4647        for (index, member) in result.members.iter().enumerate() {
4648            lines.push(format!(
4649                "{}. [{}] {} ({:.1}s, tokens:{}, tools:{}, sub-agents:{}){}",
4650                index + 1,
4651                member.status,
4652                member.name,
4653                member.duration_ms as f64 / 1000.0,
4654                member.token_usage.total,
4655                member.tool_calls.len(),
4656                member.sub_agents.len(),
4657                if member.detail.is_empty() { "" } else { ": " }
4658            ));
4659            if !member.detail.is_empty() {
4660                lines.push(format!("   {}", member.detail));
4661            }
4662            for sub_agent in &member.sub_agents {
4663                lines.push(format!(
4664                    "   - {} [{}] tokens:{} tools:{}",
4665                    sub_agent.role,
4666                    sub_agent.status,
4667                    sub_agent.token_usage.total,
4668                    sub_agent.tool_calls.len()
4669                ));
4670            }
4671            if member.status == "ok" {
4672                lines.push(indent(&member.output, "   "));
4673            }
4674        }
4675        lines.push("----------- synthesis -----------".to_string());
4676    }
4677    if result.summary.status == "ok" {
4678        lines.push(result.summary.output.trim().to_string());
4679    } else {
4680        lines.push(format!("Synthesis failed: {}", result.summary.detail));
4681    }
4682    lines.join("\n")
4683}
4684
4685fn indent(text: &str, prefix: &str) -> String {
4686    text.lines()
4687        .map(|line| format!("{prefix}{line}"))
4688        .collect::<Vec<_>>()
4689        .join("\n")
4690}
4691
4692fn is_success(result: &AmonHenResult) -> bool {
4693    result.members.iter().any(|member| member.status == "ok") && result.summary.status == "ok"
4694}
4695
4696#[cfg(test)]
4697mod tests {
4698    use super::*;
4699    use std::sync::Mutex as TestMutex;
4700
4701    static ENV_LOCK: TestMutex<()> = TestMutex::new(());
4702
4703    struct ProviderEnvGuard {
4704        saved: Vec<(&'static str, Option<OsString>)>,
4705    }
4706
4707    impl ProviderEnvGuard {
4708        fn install_echo_bins() -> Option<Self> {
4709            let echo = Path::new("/bin/echo");
4710            if !echo.is_file() {
4711                return None;
4712            }
4713            let vars = [
4714                "AMON_HEN_CODEX_BIN",
4715                "AMON_HEN_CLAUDE_BIN",
4716                "AMON_HEN_GEMINI_BIN",
4717            ];
4718            let saved = vars
4719                .into_iter()
4720                .map(|name| (name, std::env::var_os(name)))
4721                .collect::<Vec<_>>();
4722            for (name, _) in &saved {
4723                unsafe {
4724                    std::env::set_var(name, echo);
4725                }
4726            }
4727            Some(Self { saved })
4728        }
4729    }
4730
4731    impl Drop for ProviderEnvGuard {
4732        fn drop(&mut self) {
4733            for (name, value) in &self.saved {
4734                unsafe {
4735                    if let Some(value) = value {
4736                        std::env::set_var(name, value);
4737                    } else {
4738                        std::env::remove_var(name);
4739                    }
4740                }
4741            }
4742        }
4743    }
4744
4745    #[test]
4746    fn help_flags_use_success_exit_code() {
4747        let long_help = CliArgs::try_parse_from(["amon-hen", "--help"]).unwrap_err();
4748        let short_help = CliArgs::try_parse_from(["amon-hen", "-h"]).unwrap_err();
4749
4750        assert_eq!(long_help.kind(), ErrorKind::DisplayHelp);
4751        assert_eq!(short_help.kind(), ErrorKind::DisplayHelp);
4752        assert_eq!(parse_error_exit_code(long_help.kind()), 0);
4753        assert_eq!(parse_error_exit_code(short_help.kind()), 0);
4754    }
4755
4756    #[test]
4757    fn rendered_help_is_grouped_and_compact() {
4758        let help = render_cli_help();
4759
4760        assert!(help.contains("Core run:"));
4761        assert!(help.contains("Models, effort, and permissions:"));
4762        assert!(help.contains("Auth and provider capabilities:"));
4763        assert!(help.contains("Linear delivery:"));
4764        assert!(help.contains("--members codex,claude,gemini"));
4765        assert!(help.contains("--claude-permission-mode MODE"));
4766        assert!(help.contains("--gemini-approval-mode MODE"));
4767        assert!(help.contains("blocking|parallel|review-chain"));
4768        assert!(!help.contains("\n\n\n"));
4769    }
4770
4771    #[test]
4772    fn parses_members_and_provider_flags() {
4773        let args = CliArgs::try_parse_from([
4774            "amon-hen",
4775            "--members",
4776            "codex,claude",
4777            "--planner",
4778            "codex",
4779            "--planner-mode",
4780            "parallel",
4781            "--handoff",
4782            "--lead",
4783            "claude",
4784            "--codex-effort",
4785            "xhigh",
4786            "--claude-capabilities",
4787            "override",
4788            "--claude-allowed-tools",
4789            "Read,Bash(git:*)",
4790            "--claude-tools",
4791            "Read,Edit",
4792            "--claude-agent",
4793            "reviewer",
4794            "--gemini-allowed-mcp-servers",
4795            "linear,github",
4796            "ship it",
4797        ])
4798        .unwrap();
4799        let resolved = resolve_args(args).unwrap();
4800        assert_eq!(resolved.members, vec!["codex", "claude"]);
4801        assert_eq!(resolved.raw.planner_mode, PLANNER_MODE_PARALLEL);
4802        assert_eq!(resolved.raw.codex_effort.as_deref(), Some("xhigh"));
4803        assert_eq!(
4804            resolved.raw.claude_allowed_tools,
4805            vec!["Read", "Bash(git:*)"]
4806        );
4807        assert_eq!(resolved.raw.claude_tools, vec!["Read", "Edit"]);
4808        assert_eq!(resolved.raw.claude_agent.as_deref(), Some("reviewer"));
4809        assert_eq!(
4810            resolved.raw.gemini_allowed_mcp_servers,
4811            vec!["linear", "github"]
4812        );
4813    }
4814
4815    #[test]
4816    fn planner_parallel_mode_keeps_executors_unblocked() {
4817        let args = CliArgs::try_parse_from([
4818            "amon-hen",
4819            "--members",
4820            "codex,claude,gemini",
4821            "--planner",
4822            "claude",
4823            "--planner-mode",
4824            "parallel",
4825            "--handoff",
4826            "--lead",
4827            "claude",
4828            "ship it",
4829        ])
4830        .unwrap();
4831        let resolved = resolve_args(args).unwrap();
4832        let workflow = build_workflow(&resolved);
4833        let ordered = execution_order(
4834            &resolved.members,
4835            workflow.planner.as_deref(),
4836            workflow.lead.as_deref(),
4837        );
4838        let planner = workflow
4839            .planner
4840            .as_ref()
4841            .filter(|planner| ordered.contains(planner))
4842            .cloned();
4843        let blocking_planner = workflow.planner_mode == PLANNER_MODE_BLOCKING;
4844        let executors = ordered
4845            .into_iter()
4846            .filter(|member| !(blocking_planner && Some(member) == planner.as_ref()))
4847            .collect::<Vec<_>>();
4848
4849        assert_eq!(workflow.planner_mode, PLANNER_MODE_PARALLEL);
4850        assert!(workflow.handoff);
4851        assert_eq!(executors, vec!["claude", "codex", "gemini"]);
4852        assert_eq!(role_for("claude", &workflow), "lead+planner");
4853        assert_eq!(role_for("codex", &workflow), "executor");
4854        assert_eq!(role_for("gemini", &workflow), "executor");
4855    }
4856
4857    #[test]
4858    fn planner_review_chain_mode_serializes_agent_review() {
4859        let args = CliArgs::try_parse_from([
4860            "amon-hen",
4861            "--members",
4862            "codex,claude,gemini",
4863            "--planner",
4864            "claude",
4865            "--planner-mode",
4866            "review-chain",
4867            "--lead",
4868            "claude",
4869            "ship it",
4870        ])
4871        .unwrap();
4872        let resolved = resolve_args(args).unwrap();
4873        let workflow = build_workflow(&resolved);
4874        let ordered = execution_order(
4875            &resolved.members,
4876            workflow.planner.as_deref(),
4877            workflow.lead.as_deref(),
4878        );
4879        let prior = vec![EngineResult {
4880            name: "codex".to_string(),
4881            bin: Some("codex".to_string()),
4882            status: "ok".to_string(),
4883            duration_ms: 1,
4884            detail: String::new(),
4885            exit_code: Some(0),
4886            stdout: String::new(),
4887            stderr: String::new(),
4888            output: "codex patch notes".to_string(),
4889            command: "codex".to_string(),
4890            token_usage: token_usage("a", "b"),
4891            tool_calls: vec![],
4892            sub_agents: vec![],
4893            role: "executor".to_string(),
4894            iteration: 1,
4895            total_iterations: 1,
4896            team_size: 0,
4897        }];
4898        let prompt = build_member_prompt(MemberPromptInput {
4899            query: "ship it",
4900            role: "executor",
4901            workflow: &workflow,
4902            iteration: 1,
4903            team_size: 0,
4904            previous_iteration: &[],
4905            handoff_results: &prior,
4906            plan_output: "",
4907        });
4908
4909        assert_eq!(workflow.planner_mode, PLANNER_MODE_REVIEW_CHAIN);
4910        assert!(
4911            workflow.handoff,
4912            "review-chain should imply handoff context"
4913        );
4914        assert!(planner_mode_runs_serial(&workflow));
4915        assert_eq!(ordered, vec!["claude", "codex", "gemini"]);
4916        assert!(prompt.contains("Review-chain mode is enabled"));
4917        assert!(prompt.contains("### codex (executor)"));
4918        assert!(prompt.contains("codex patch notes"));
4919    }
4920
4921    #[test]
4922    fn parses_historical_linear_flags_in_rust() {
4923        let args = CliArgs::try_parse_from([
4924            "amon-hen",
4925            "--banner",
4926            "--auth-open-browser",
4927            "--members",
4928            "codex,claude,gemini",
4929            "--linear-project",
4930            "ENG",
4931            "--linear-endpoint",
4932            "https://linear.example/graphql",
4933            "--linear-auth",
4934            "oauth",
4935            "--linear-max-polls",
4936            "2",
4937            "--linear-max-concurrency",
4938            "3",
4939            "--linear-workflow-file",
4940            "docs/linear-workflow.md",
4941            "--linear-workspace-strategy",
4942            "copy",
4943            "--linear-completion-gate",
4944            "review-or-ci",
4945            "--delivery-phases",
4946            "plan,implement,verify,ship",
4947            "deliver it",
4948        ])
4949        .unwrap();
4950        let resolved = resolve_args(args).unwrap();
4951
4952        assert!(linear_delivery_requested(&resolved.raw));
4953        assert_eq!(
4954            resolved.raw.linear_endpoint.as_deref(),
4955            Some("https://linear.example/graphql")
4956        );
4957        assert_eq!(resolved.raw.linear_auth, "oauth");
4958        assert_eq!(resolved.raw.linear_max_polls, Some(2));
4959        assert_eq!(resolved.raw.linear_max_concurrency, 3);
4960        assert_eq!(
4961            resolved.raw.linear_workflow_file.as_deref(),
4962            Some(Path::new("docs/linear-workflow.md"))
4963        );
4964        assert_eq!(resolved.raw.linear_workspace_strategy, "copy");
4965        assert_eq!(resolved.raw.linear_completion_gate, "review-or-ci");
4966        assert_eq!(
4967            resolved.raw.delivery_phases,
4968            vec!["plan", "implement", "verify", "ship"]
4969        );
4970    }
4971
4972    #[test]
4973    fn cli_role_flag_matrix_reaches_provider_options_and_prompts() {
4974        let args = CliArgs::try_parse_from([
4975            "amon-hen",
4976            "--members",
4977            "codex,claude,gemini",
4978            "--planner",
4979            "codex",
4980            "--lead",
4981            "claude",
4982            "--handoff",
4983            "--iterations",
4984            "3",
4985            "--team-work",
4986            "2",
4987            "--codex-sub-agents",
4988            "4",
4989            "--claude-sub-agents",
4990            "1",
4991            "--gemini-sub-agents",
4992            "0",
4993            "--codex-model",
4994            "gpt-5.2",
4995            "--codex-effort",
4996            "xhigh",
4997            "--codex-sandbox",
4998            "danger-full-access",
4999            "--codex-config",
5000            "sandbox_workspace_write.network_access=true",
5001            "--codex-mcp-profile",
5002            "repo",
5003            "--claude-model",
5004            "sonnet",
5005            "--claude-effort",
5006            "max",
5007            "--claude-permission-mode",
5008            "bypassPermissions",
5009            "--claude-allowed-tools",
5010            "Read,Edit",
5011            "--claude-tools",
5012            "Bash",
5013            "--claude-agent",
5014            "lead-reviewer",
5015            "--gemini-model",
5016            "gemini-2.5-pro",
5017            "--gemini-effort",
5018            "high",
5019            "--gemini-approval-mode",
5020            "auto_edit",
5021            "--gemini-settings",
5022            "gemini-settings.json",
5023            "--gemini-tools-profile",
5024            "repo,ci",
5025            "--gemini-allowed-mcp-servers",
5026            "linear",
5027            "Fix the regression",
5028        ])
5029        .unwrap();
5030        let resolved = resolve_args(args).unwrap();
5031        let workflow = build_workflow(&resolved);
5032        let previous = vec![EngineResult {
5033            name: "gemini".to_string(),
5034            bin: Some("gemini".to_string()),
5035            status: "ok".to_string(),
5036            duration_ms: 1,
5037            detail: String::new(),
5038            exit_code: Some(0),
5039            stdout: String::new(),
5040            stderr: String::new(),
5041            output: "previous iteration context".to_string(),
5042            command: "gemini".to_string(),
5043            token_usage: token_usage("a", "b"),
5044            tool_calls: vec![],
5045            sub_agents: vec![],
5046            role: "executor".to_string(),
5047            iteration: 1,
5048            total_iterations: 3,
5049            team_size: 0,
5050        }];
5051        let handoff = vec![EngineResult {
5052            name: "codex".to_string(),
5053            bin: Some("codex".to_string()),
5054            status: "ok".to_string(),
5055            duration_ms: 1,
5056            detail: String::new(),
5057            exit_code: Some(0),
5058            stdout: String::new(),
5059            stderr: String::new(),
5060            output: "planner handoff context".to_string(),
5061            command: "codex".to_string(),
5062            token_usage: token_usage("a", "b"),
5063            tool_calls: vec![],
5064            sub_agents: vec![],
5065            role: "planner".to_string(),
5066            iteration: 2,
5067            total_iterations: 3,
5068            team_size: 4,
5069        }];
5070
5071        assert_eq!(workflow.iterations, 3);
5072        assert!(workflow.handoff);
5073        assert_eq!(workflow.team_work, 2);
5074        assert_eq!(workflow.teams.get("codex"), Some(&4));
5075        assert_eq!(workflow.teams.get("claude"), Some(&1));
5076        assert_eq!(workflow.teams.get("gemini"), Some(&0));
5077        assert_eq!(role_for("codex", &workflow), "planner");
5078        assert_eq!(role_for("claude", &workflow), "lead");
5079        assert_eq!(role_for("gemini", &workflow), "executor");
5080
5081        let codex_prompt = build_member_prompt(MemberPromptInput {
5082            query: "Fix the regression",
5083            role: "planner",
5084            workflow: &workflow,
5085            iteration: 2,
5086            team_size: effective_team_size(&workflow, "codex"),
5087            previous_iteration: &previous,
5088            handoff_results: &handoff,
5089            plan_output: "plan output",
5090        });
5091        assert!(codex_prompt.contains("Amon Hen workflow: iteration 2 of 3."));
5092        assert!(codex_prompt.contains("Lead model: claude."));
5093        assert!(codex_prompt.contains("Planner model: codex."));
5094        assert!(codex_prompt.contains("Your assigned role: planner."));
5095        assert!(codex_prompt.contains("Handoff mode is enabled."));
5096        assert!(codex_prompt.contains("Team work: you may coordinate up to 4 internal sub-agents"));
5097        assert!(codex_prompt.contains("Planner handoff:\nplan output"));
5098        assert!(codex_prompt.contains("previous iteration context"));
5099        assert!(codex_prompt.contains("planner handoff context"));
5100
5101        let codex_options = engine_options(
5102            &resolved,
5103            EngineOptionsInput {
5104                member: "codex",
5105                prompt: codex_prompt,
5106                role: "planner",
5107                iteration: 2,
5108                workflow: &workflow,
5109                progress: None,
5110                cancel: None,
5111            },
5112        );
5113        assert_eq!(codex_options.model.as_deref(), Some("gpt-5.2"));
5114        assert_eq!(codex_options.effort.as_deref(), Some("xhigh"));
5115        assert_eq!(
5116            codex_options.permission.as_deref(),
5117            Some("danger-full-access")
5118        );
5119        assert_eq!(codex_options.role, "planner");
5120        assert_eq!(codex_options.iteration, 2);
5121        assert_eq!(codex_options.total_iterations, 3);
5122        assert_eq!(codex_options.team_size, 4);
5123        assert_eq!(codex_options.capability.mode, CAPABILITY_OVERRIDE);
5124        assert_eq!(
5125            codex_options.capability.config,
5126            vec!["sandbox_workspace_write.network_access=true"]
5127        );
5128        assert_eq!(
5129            codex_options.capability.mcp_profile.as_deref(),
5130            Some("repo")
5131        );
5132
5133        let claude_options = engine_options(
5134            &resolved,
5135            EngineOptionsInput {
5136                member: "claude",
5137                prompt: build_member_prompt(MemberPromptInput {
5138                    query: "Fix the regression",
5139                    role: "lead",
5140                    workflow: &workflow,
5141                    iteration: 2,
5142                    team_size: effective_team_size(&workflow, "claude"),
5143                    previous_iteration: &[],
5144                    handoff_results: &[],
5145                    plan_output: "",
5146                }),
5147                role: "lead",
5148                iteration: 2,
5149                workflow: &workflow,
5150                progress: None,
5151                cancel: None,
5152            },
5153        );
5154        assert_eq!(claude_options.model.as_deref(), Some("sonnet"));
5155        assert_eq!(claude_options.effort.as_deref(), Some("max"));
5156        assert_eq!(
5157            claude_options.permission.as_deref(),
5158            Some("bypassPermissions")
5159        );
5160        assert_eq!(claude_options.role, "lead");
5161        assert_eq!(claude_options.team_size, 1);
5162        assert_eq!(claude_options.capability.mode, CAPABILITY_OVERRIDE);
5163        assert_eq!(
5164            claude_options.capability.allowed_tools,
5165            vec!["Read", "Edit"]
5166        );
5167        assert_eq!(claude_options.capability.tools, vec!["Bash"]);
5168        assert_eq!(
5169            claude_options.capability.agent.as_deref(),
5170            Some("lead-reviewer")
5171        );
5172
5173        let gemini_options = engine_options(
5174            &resolved,
5175            EngineOptionsInput {
5176                member: "gemini",
5177                prompt: build_member_prompt(MemberPromptInput {
5178                    query: "Fix the regression",
5179                    role: "executor",
5180                    workflow: &workflow,
5181                    iteration: 2,
5182                    team_size: effective_team_size(&workflow, "gemini"),
5183                    previous_iteration: &[],
5184                    handoff_results: &[],
5185                    plan_output: "",
5186                }),
5187                role: "executor",
5188                iteration: 2,
5189                workflow: &workflow,
5190                progress: None,
5191                cancel: None,
5192            },
5193        );
5194        assert_eq!(gemini_options.model.as_deref(), Some("gemini-2.5-pro"));
5195        assert_eq!(gemini_options.effort.as_deref(), Some("high"));
5196        assert_eq!(gemini_options.permission.as_deref(), Some("auto_edit"));
5197        assert_eq!(gemini_options.role, "executor");
5198        assert_eq!(gemini_options.team_size, 0);
5199        assert_eq!(gemini_options.capability.mode, CAPABILITY_OVERRIDE);
5200        assert_eq!(
5201            gemini_options.capability.settings.as_deref(),
5202            Some("gemini-settings.json")
5203        );
5204        assert_eq!(gemini_options.capability.tools_profile, vec!["repo", "ci"]);
5205        assert_eq!(
5206            gemini_options.capability.allowed_mcp_servers,
5207            vec!["linear"]
5208        );
5209    }
5210
5211    #[test]
5212    fn provider_command_args_include_role_matrix_settings() {
5213        let cwd = std::env::current_dir().unwrap();
5214        let codex_options = EngineRunOptions {
5215            prompt: "codex prompt".to_string(),
5216            cwd: cwd.clone(),
5217            timeout_ms: 1,
5218            effort: Some("high".to_string()),
5219            model: Some("gpt-5.2".to_string()),
5220            permission: Some("workspace-write".to_string()),
5221            auth: DEFAULT_AUTH_MODE.to_string(),
5222            capability: ProviderCapability {
5223                mode: CAPABILITY_OVERRIDE.to_string(),
5224                config: vec!["tools.web_search=true".to_string()],
5225                mcp_profile: Some("repo".to_string()),
5226                mcp_config: vec![],
5227                allowed_tools: vec![],
5228                disallowed_tools: vec![],
5229                tools: vec![],
5230                agent: None,
5231                agents_json: None,
5232                plugin_dirs: vec![],
5233                strict_mcp_config: false,
5234                disable_slash_commands: false,
5235                settings: None,
5236                tools_profile: vec![],
5237                allowed_mcp_servers: vec![],
5238                policy: vec![],
5239                admin_policy: vec![],
5240            },
5241            role: "planner".to_string(),
5242            iteration: 2,
5243            total_iterations: 3,
5244            team_size: 4,
5245            is_sub_agent: false,
5246            live: false,
5247            progress: None,
5248            cancel: None,
5249        };
5250        let codex = run_codex("definitely-missing-codex-test-bin", &codex_options);
5251        assert_eq!(codex.args[0], "exec");
5252        assert!(codex
5253            .args
5254            .windows(2)
5255            .any(|pair| pair == ["--model", "gpt-5.2"]));
5256        assert!(codex
5257            .args
5258            .windows(2)
5259            .any(|pair| pair == ["-c", "model_reasoning_effort=high"]));
5260        assert!(codex
5261            .args
5262            .windows(2)
5263            .any(|pair| pair == ["-c", "tools.web_search=true"]));
5264        assert!(codex
5265            .args
5266            .windows(2)
5267            .any(|pair| pair == ["--profile", "repo"]));
5268        assert!(codex
5269            .args
5270            .windows(2)
5271            .any(|pair| pair == ["--sandbox", "workspace-write"]));
5272
5273        let claude_options = EngineRunOptions {
5274            prompt: "claude prompt".to_string(),
5275            cwd: cwd.clone(),
5276            timeout_ms: 1,
5277            effort: Some("max".to_string()),
5278            model: Some("sonnet".to_string()),
5279            permission: Some("bypassPermissions".to_string()),
5280            auth: "social-login".to_string(),
5281            capability: ProviderCapability {
5282                mode: CAPABILITY_OVERRIDE.to_string(),
5283                config: vec![],
5284                mcp_profile: None,
5285                mcp_config: vec!["mcp.json".to_string()],
5286                allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
5287                disallowed_tools: vec!["WebFetch".to_string()],
5288                tools: vec!["Bash".to_string()],
5289                agent: Some("lead-reviewer".to_string()),
5290                agents_json: Some("agents.json".to_string()),
5291                plugin_dirs: vec!["plugins/a".to_string(), "plugins/b".to_string()],
5292                strict_mcp_config: true,
5293                disable_slash_commands: true,
5294                settings: None,
5295                tools_profile: vec![],
5296                allowed_mcp_servers: vec![],
5297                policy: vec![],
5298                admin_policy: vec![],
5299            },
5300            role: "lead".to_string(),
5301            iteration: 2,
5302            total_iterations: 3,
5303            team_size: 1,
5304            is_sub_agent: false,
5305            live: false,
5306            progress: None,
5307            cancel: None,
5308        };
5309        let claude = run_claude("definitely-missing-claude-test-bin", &claude_options);
5310        assert!(!claude.args.iter().any(|arg| arg == "--bare"));
5311        assert!(claude
5312            .args
5313            .windows(2)
5314            .any(|pair| pair == ["--permission-mode", "bypassPermissions"]));
5315        assert!(claude
5316            .args
5317            .windows(2)
5318            .any(|pair| pair == ["--model", "sonnet"]));
5319        assert!(claude
5320            .args
5321            .windows(2)
5322            .any(|pair| pair == ["--effort", "max"]));
5323        assert!(claude
5324            .args
5325            .windows(3)
5326            .any(|triple| triple == ["--allowedTools", "Read", "Edit"]));
5327        assert!(claude
5328            .args
5329            .windows(2)
5330            .any(|pair| pair == ["--agent", "lead-reviewer"]));
5331        assert!(claude.args.iter().any(|arg| arg == "--strict-mcp-config"));
5332        assert!(claude
5333            .args
5334            .iter()
5335            .any(|arg| arg == "--disable-slash-commands"));
5336
5337        let gemini_options = EngineRunOptions {
5338            prompt: "gemini prompt".to_string(),
5339            cwd,
5340            timeout_ms: 1,
5341            effort: Some("high".to_string()),
5342            model: Some("gemini-2.5-pro".to_string()),
5343            permission: Some("auto_edit".to_string()),
5344            auth: DEFAULT_AUTH_MODE.to_string(),
5345            capability: ProviderCapability {
5346                mode: CAPABILITY_OVERRIDE.to_string(),
5347                config: vec![],
5348                mcp_profile: None,
5349                mcp_config: vec![],
5350                allowed_tools: vec![],
5351                disallowed_tools: vec![],
5352                tools: vec![],
5353                agent: None,
5354                agents_json: None,
5355                plugin_dirs: vec![],
5356                strict_mcp_config: false,
5357                disable_slash_commands: false,
5358                settings: None,
5359                tools_profile: vec!["repo".to_string(), "ci".to_string()],
5360                allowed_mcp_servers: vec!["linear".to_string()],
5361                policy: vec!["default".to_string()],
5362                admin_policy: vec!["locked".to_string()],
5363            },
5364            role: "executor".to_string(),
5365            iteration: 2,
5366            total_iterations: 3,
5367            team_size: 0,
5368            is_sub_agent: false,
5369            live: false,
5370            progress: None,
5371            cancel: None,
5372        };
5373        let gemini = run_gemini("definitely-missing-gemini-test-bin", &gemini_options);
5374        assert!(gemini
5375            .args
5376            .windows(2)
5377            .any(|pair| pair == ["--model", "gemini-2.5-pro"]));
5378        assert!(gemini
5379            .args
5380            .windows(3)
5381            .any(|triple| triple == ["--extensions", "repo", "ci"]));
5382        assert!(gemini
5383            .args
5384            .windows(2)
5385            .any(|pair| pair == ["--allowed-mcp-server-names", "linear"]));
5386        assert!(gemini
5387            .args
5388            .windows(2)
5389            .any(|pair| pair == ["--policy", "default"]));
5390        assert!(gemini
5391            .args
5392            .windows(2)
5393            .any(|pair| pair == ["--admin-policy", "locked"]));
5394        assert!(gemini.args.windows(2).any(|pair| pair == ["-p", ""]));
5395        assert!(gemini
5396            .args
5397            .windows(2)
5398            .any(|pair| pair == ["--approval-mode", "auto_edit"]));
5399    }
5400
5401    #[test]
5402    #[cfg(unix)]
5403    fn gemini_prompt_is_sent_on_stdin_to_avoid_arg_limit() {
5404        use std::os::unix::fs::PermissionsExt;
5405
5406        let temp = tempfile::tempdir().unwrap();
5407        let fake_gemini = temp.path().join("gemini-fake");
5408        fs::write(
5409            &fake_gemini,
5410            "#!/bin/sh\nprintf 'ARGS:'\nfor arg in \"$@\"; do printf '<%s>' \"$arg\"; done\nprintf '\\nSTDIN:'\ncat\n",
5411        )
5412        .unwrap();
5413        let mut permissions = fs::metadata(&fake_gemini).unwrap().permissions();
5414        permissions.set_mode(0o755);
5415        fs::set_permissions(&fake_gemini, permissions).unwrap();
5416
5417        let prompt = "x".repeat(300_000);
5418        let options = EngineRunOptions {
5419            prompt: prompt.clone(),
5420            cwd: std::env::current_dir().unwrap(),
5421            timeout_ms: 5_000,
5422            effort: None,
5423            model: Some("gemini-test".to_string()),
5424            permission: None,
5425            auth: DEFAULT_AUTH_MODE.to_string(),
5426            capability: ProviderCapability {
5427                mode: CAPABILITY_INHERIT.to_string(),
5428                config: vec![],
5429                mcp_profile: None,
5430                mcp_config: vec![],
5431                allowed_tools: vec![],
5432                disallowed_tools: vec![],
5433                tools: vec![],
5434                agent: None,
5435                agents_json: None,
5436                plugin_dirs: vec![],
5437                strict_mcp_config: false,
5438                disable_slash_commands: false,
5439                settings: None,
5440                tools_profile: vec![],
5441                allowed_mcp_servers: vec![],
5442                policy: vec![],
5443                admin_policy: vec![],
5444            },
5445            role: "executor".to_string(),
5446            iteration: 1,
5447            total_iterations: 1,
5448            team_size: 0,
5449            is_sub_agent: false,
5450            live: false,
5451            progress: None,
5452            cancel: None,
5453        };
5454        let result = run_gemini(fake_gemini.to_str().unwrap(), &options);
5455
5456        assert_eq!(result.code, Some(0));
5457        assert!(result.args.iter().any(|arg| arg == "-p"));
5458        assert!(!result.args.iter().any(|arg| arg == &prompt));
5459        assert!(result.stdout.contains("ARGS:<--model><gemini-test><-p><>"));
5460        assert!(result.stdout.contains("<--approval-mode><plan>"));
5461        assert!(result.stdout.ends_with(&prompt));
5462    }
5463
5464    #[test]
5465    fn spawn_failure_status_distinguishes_missing_from_other_spawn_errors() {
5466        assert_eq!(spawn_failure_status("No such file or directory"), "missing");
5467        assert_eq!(
5468            spawn_failure_status("Argument list too long (os error 7)"),
5469            "error"
5470        );
5471    }
5472
5473    #[test]
5474    fn builds_member_prompt_with_roles_and_handoff() {
5475        let workflow = Workflow {
5476            handoff: true,
5477            lead: Some("claude".to_string()),
5478            planner: Some("codex".to_string()),
5479            planner_mode: PLANNER_MODE_BLOCKING.to_string(),
5480            iterations: 2,
5481            team_work: 1,
5482            teams: HashMap::new(),
5483        };
5484        let prompt = build_member_prompt(MemberPromptInput {
5485            query: "Fix the bug",
5486            role: "planner",
5487            workflow: &workflow,
5488            iteration: 1,
5489            team_size: 1,
5490            previous_iteration: &[],
5491            handoff_results: &[],
5492            plan_output: "",
5493        });
5494        assert!(prompt.contains("Amon Hen workflow: iteration 1 of 2."));
5495        assert!(prompt.contains("Lead model: claude."));
5496        assert!(prompt.contains("Planner model: codex."));
5497        assert!(prompt.contains("Your assigned role: planner."));
5498        assert!(prompt.contains("Current user query:"));
5499    }
5500
5501    #[test]
5502    fn parses_claude_stream_json_result() {
5503        let text = r#"{"type":"system","subtype":"status","status":"requesting"}
5504{"type":"result","result":"done"}"#;
5505        assert_eq!(parse_claude_output(text), "done");
5506    }
5507
5508    #[test]
5509    fn parses_gemini_json_response() {
5510        assert_eq!(parse_gemini_output(r#"{"response":"hello"}"#), "hello");
5511    }
5512
5513    #[test]
5514    fn estimates_tokens_with_ceiling_chunks() {
5515        assert_eq!(estimate_tokens(""), 0);
5516        assert_eq!(estimate_tokens("abc"), 1);
5517        assert_eq!(estimate_tokens("abcd"), 1);
5518        assert_eq!(estimate_tokens("abcde"), 2);
5519    }
5520
5521    #[test]
5522    fn status_detail_strips_real_and_escaped_terminal_sequences() {
5523        let escaped =
5524            sanitize_status_detail(r"\u001b[26;107Hstatus \u001b[38;2;246;196;83mready\u001b[0m");
5525        let actual = sanitize_status_detail("\x1b[31mfailed\x1b[0m after check");
5526
5527        assert_eq!(escaped, "status ready");
5528        assert_eq!(actual, "failed after check");
5529        assert!(!escaped.contains(r"\u001b"));
5530        assert!(!actual.contains('\x1b'));
5531    }
5532
5533    #[test]
5534    fn extracts_social_login_urls() {
5535        assert_eq!(
5536            extract_auth_urls("Open https://example.com/callback?code=123, then continue"),
5537            vec!["https://example.com/callback?code=123"]
5538        );
5539        assert_eq!(
5540            extract_auth_urls("deeplink: claude://login/complete."),
5541            vec!["claude://login/complete"]
5542        );
5543    }
5544
5545    #[test]
5546    fn parses_provider_token_usage() {
5547        let stdout = r#"{"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}"#;
5548        let usage = extract_token_usage(stdout, "hello", "world").unwrap();
5549        assert_eq!(usage.input, 12);
5550        assert_eq!(usage.output, 8);
5551        assert_eq!(usage.total, 20);
5552        assert!(!usage.estimated);
5553    }
5554
5555    #[test]
5556    fn extracts_tool_usage_from_provider_streams() {
5557        let stdout = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}"#;
5558        let tools = extract_tool_usage("claude", stdout, "");
5559        assert!(tools.iter().any(|tool| tool.name == "Bash"));
5560    }
5561
5562    #[test]
5563    fn provider_stream_json_renders_readable_messages() {
5564        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"}}]}}"#;
5565        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"}}"#;
5566        let gemini = r#"{"candidates":[{"content":{"parts":[{"text":"Gate waterfall evidence is still thin."}]}}]}"#;
5567
5568        assert_eq!(
5569            provider_visible_stream(Some("claude"), claude, &[]),
5570            StreamDisplay::Visible(
5571                "assistant: Inspecting the truth predicates now. | tool: Bash {\"command\":\"git status -sb\"}"
5572                    .to_string()
5573            )
5574        );
5575        let codex_visible = match provider_visible_stream(Some("codex"), codex, &[]) {
5576            StreamDisplay::Visible(text) => text,
5577            StreamDisplay::Suppress => panic!("codex command event should be visible"),
5578        };
5579        assert!(codex_visible.contains("tool: shell"));
5580        assert!(codex_visible.contains("execution/live_router.py"));
5581        assert!(!codex_visible.contains("\"type\":\"item.completed\""));
5582        assert_eq!(
5583            provider_visible_stream(Some("gemini"), gemini, &[]),
5584            StreamDisplay::Visible("assistant: Gate waterfall evidence is still thin.".to_string())
5585        );
5586    }
5587
5588    #[test]
5589    fn provider_stream_strips_escaped_terminal_sequences() {
5590        let raw = r"\u001b[26;107Hstatus \u001b[38;2;246;196;83mready\u001b[0m";
5591        assert_eq!(
5592            provider_visible_stream(Some("codex"), raw, &[]),
5593            StreamDisplay::Visible("status ready".to_string())
5594        );
5595
5596        let codex = r#"{"type":"item.completed","item":{"id":"item_8","type":"command_execution","command":"/bin/bash -lc 'free -h'","aggregated_output":"\\u001b[26;107Htotal used free\\u001b[0m"}}"#;
5597        let visible = match provider_visible_stream(Some("codex"), codex, &[]) {
5598            StreamDisplay::Visible(text) => text,
5599            StreamDisplay::Suppress => panic!("codex command output should be visible"),
5600        };
5601
5602        assert!(visible.contains("tool: shell"));
5603        assert!(visible.contains("total used free"));
5604        assert!(!visible.contains(r"\u001b"));
5605        assert!(!visible.contains("[26;107H"));
5606    }
5607
5608    #[test]
5609    fn claude_stream_event_json_suppresses_input_shards() {
5610        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"}}"#;
5611        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"}}"#;
5612        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"}}"#;
5613
5614        assert_eq!(
5615            provider_visible_stream(Some("claude"), input_delta, &[]),
5616            StreamDisplay::Suppress
5617        );
5618        assert!(extract_tool_usage("claude", input_delta, "").is_empty());
5619
5620        let visible_tool = match provider_visible_stream(Some("claude"), tool_start, &[]) {
5621            StreamDisplay::Visible(text) => text,
5622            StreamDisplay::Suppress => panic!("tool start should be visible"),
5623        };
5624        assert!(visible_tool.contains("tool: Agent"));
5625        assert!(visible_tool.contains("CLOB/WS Timing"));
5626        assert!(!visible_tool.contains("stream_event"));
5627        assert!(!visible_tool.contains("partial_json"));
5628
5629        assert_eq!(
5630            provider_visible_stream(Some("claude"), text_delta, &[]),
5631            StreamDisplay::Visible("assistant: I found the first timing issue.".to_string())
5632        );
5633        assert_eq!(
5634            parse_claude_output(&format!("{text_delta}\n{text_delta}\n")),
5635            "I found the first timing issue.I found the first timing issue."
5636        );
5637    }
5638
5639    #[test]
5640    fn provider_stream_json_suppresses_session_plumbing() {
5641        let claude_hook = r#"{"type":"system","subtype":"hook_response","hook_name":"SessionStart:startup","output":"noise"}"#;
5642        let codex_session = r#"{"type":"session.started","session_id":"abc"}"#;
5643
5644        assert_eq!(
5645            provider_visible_stream(Some("claude"), claude_hook, &[]),
5646            StreamDisplay::Suppress
5647        );
5648        assert_eq!(
5649            provider_visible_stream(Some("codex"), codex_session, &[]),
5650            StreamDisplay::Suppress
5651        );
5652    }
5653
5654    #[test]
5655    fn builds_real_sub_agent_handoff_prompt() {
5656        let agent = EngineResult {
5657            name: "codex".to_string(),
5658            bin: Some("codex".to_string()),
5659            status: "ok".to_string(),
5660            duration_ms: 1,
5661            detail: String::new(),
5662            exit_code: Some(0),
5663            stdout: String::new(),
5664            stderr: String::new(),
5665            output: "inspect parser".to_string(),
5666            command: "codex exec".to_string(),
5667            token_usage: token_usage("a", "b"),
5668            tool_calls: vec![],
5669            sub_agents: vec![],
5670            role: "executor:sub-agent-1".to_string(),
5671            iteration: 1,
5672            total_iterations: 1,
5673            team_size: 0,
5674        };
5675        let prompt = build_team_lead_prompt("ship it", &[agent]);
5676        assert!(prompt.contains("Sub-agent handoffs"));
5677        assert!(prompt.contains("inspect parser"));
5678        assert!(prompt.contains("Original provider prompt"));
5679    }
5680
5681    #[test]
5682    fn json_stream_events_emit_progress_before_final_result() {
5683        let bus = RuntimeEventBus::new();
5684        let events = Arc::new(Mutex::new(Vec::new()));
5685        let captured = Arc::clone(&events);
5686        bus.add_sink(Arc::new(move |event| {
5687            captured.lock().unwrap().push(event);
5688        }));
5689        let progress = bus.progress_sink();
5690
5691        progress(progress_event_with_context(
5692            None,
5693            None,
5694            ProgressStage::Start,
5695            None,
5696            Some(1),
5697            Some(1),
5698            false,
5699            None,
5700            None,
5701            vec![],
5702            "[amon-hen] iteration 1/1 started",
5703        ));
5704        progress(progress_event_with_context(
5705            Some("codex"),
5706            Some("planner"),
5707            ProgressStage::Done,
5708            Some("ok"),
5709            Some(1),
5710            Some(1),
5711            false,
5712            Some(25),
5713            Some(token_usage("prompt", "answer")),
5714            vec![],
5715            "[amon-hen] done codex role=planner status=ok",
5716        ));
5717        let result = test_amon_hen_result(vec![test_engine_result("codex", "planner", 1)], 1);
5718        bus.emit_result(&result);
5719
5720        let events = events.lock().unwrap();
5721        assert!(events.len() >= 3);
5722        assert!(events[..events.len() - 1]
5723            .iter()
5724            .any(|event| event.progress.stage != ProgressStage::Done));
5725        assert!(events
5726            .iter()
5727            .any(|event| event.progress.kind == RuntimeEventKind::TokenUsage));
5728        assert_eq!(
5729            events.last().unwrap().progress.kind,
5730            RuntimeEventKind::Result
5731        );
5732        assert!(events.last().unwrap().result.is_some());
5733        for event in events.iter() {
5734            serde_json::to_string(event).expect("json-stream event should serialize");
5735        }
5736    }
5737
5738    #[test]
5739    fn runtime_event_bus_preserves_stream_sequence_order_under_fanout() {
5740        let bus = RuntimeEventBus::new();
5741        let events = Arc::new(Mutex::new(Vec::new()));
5742        let captured = Arc::clone(&events);
5743        bus.add_sink(Arc::new(move |event| {
5744            captured.lock().unwrap().push(event.sequence);
5745        }));
5746        let barrier = Arc::new(std::sync::Barrier::new(16));
5747        let mut handles = Vec::new();
5748
5749        for index in 0..16 {
5750            let progress = bus.progress_sink();
5751            let barrier = Arc::clone(&barrier);
5752            handles.push(thread::spawn(move || {
5753                barrier.wait();
5754                progress(progress_event(
5755                    Some("codex"),
5756                    Some("planner"),
5757                    ProgressStage::Heartbeat,
5758                    Some("running"),
5759                    format!("[amon-hen] fanout event {index}"),
5760                ));
5761            }));
5762        }
5763        for handle in handles {
5764            handle.join().unwrap();
5765        }
5766
5767        let sequences = events.lock().unwrap().clone();
5768        assert_eq!(sequences, (1..=16).collect::<Vec<_>>());
5769    }
5770
5771    #[test]
5772    fn multiple_iterations_preserve_timeline_entries() {
5773        let first = vec![test_engine_result("codex", "planner", 1)];
5774        let second = vec![test_engine_result("claude", "lead", 2)];
5775        let timeline = vec![
5776            iteration_record(1, 2, first.clone(), 10, None, None),
5777            iteration_record(
5778                2,
5779                2,
5780                second.clone(),
5781                15,
5782                iteration_handoff_context(&first, DEFAULT_MAX_MEMBER_CHARS),
5783                Some("summary context".to_string()),
5784            ),
5785        ];
5786        let result = test_amon_hen_result_with_iterations(second, timeline);
5787
5788        assert_eq!(result.iterations.len(), 2);
5789        assert_eq!(result.iterations[0].iteration, 1);
5790        assert_eq!(result.iterations[1].iteration, 2);
5791        assert!(result.iterations[1].handoff_context.is_some());
5792        assert_eq!(result.members[0].name, "claude");
5793    }
5794
5795    #[test]
5796    fn sub_agent_roles_remain_correct_in_timeline() {
5797        let mut member = test_engine_result("gemini", "lead", 1);
5798        member.sub_agents = vec![
5799            test_engine_result("gemini", "lead:sub-agent-1", 1),
5800            test_engine_result("gemini", "lead:sub-agent-2", 1),
5801        ];
5802        let timeline = vec![iteration_record(1, 1, vec![member], 20, None, None)];
5803
5804        assert_eq!(
5805            timeline[0]
5806                .sub_agents
5807                .iter()
5808                .map(|agent| agent.role.as_str())
5809                .collect::<Vec<_>>(),
5810            vec!["lead:sub-agent-1", "lead:sub-agent-2"]
5811        );
5812        assert_eq!(timeline[0].members[0].role, "lead");
5813    }
5814
5815    #[test]
5816    fn runtime_role_matrix_reaches_json_result_and_stream_events() {
5817        let _lock = ENV_LOCK.lock().unwrap();
5818        let Some(_env) = ProviderEnvGuard::install_echo_bins() else {
5819            return;
5820        };
5821        let args = CliArgs::try_parse_from([
5822            "amon-hen",
5823            "--members",
5824            "codex,claude,gemini",
5825            "--planner",
5826            "codex",
5827            "--lead",
5828            "claude",
5829            "--handoff",
5830            "--iterations",
5831            "2",
5832            "--team-work",
5833            "1",
5834            "--codex-sub-agents",
5835            "2",
5836            "--claude-sub-agents",
5837            "1",
5838            "--gemini-sub-agents",
5839            "0",
5840            "--timeout",
5841            "5",
5842            "role matrix smoke",
5843        ])
5844        .unwrap();
5845        let resolved = resolve_args(args).unwrap();
5846        let bus = RuntimeEventBus::new();
5847        let events = Arc::new(Mutex::new(Vec::new()));
5848        let captured = Arc::clone(&events);
5849        bus.add_sink(Arc::new(move |event| {
5850            captured.lock().unwrap().push(event);
5851        }));
5852        let prompt_context =
5853            build_prompt_context_with_progress(&resolved, Some(bus.progress_sink())).unwrap();
5854        let result = run_amon_hen_with_progress_and_cancel(
5855            &resolved,
5856            prompt_context.prompt,
5857            prompt_context.commands,
5858            Some(bus.progress_sink()),
5859            None,
5860        );
5861        bus.emit_result(&result);
5862
5863        assert_eq!(result.workflow.iterations, 2);
5864        assert_eq!(result.workflow.teams.get("codex"), Some(&2));
5865        assert_eq!(result.workflow.teams.get("claude"), Some(&1));
5866        assert_eq!(result.workflow.teams.get("gemini"), Some(&0));
5867        assert_eq!(result.iterations.len(), 2);
5868        for iteration in &result.iterations {
5869            assert_eq!(
5870                iteration
5871                    .members
5872                    .iter()
5873                    .map(|member| (member.name.as_str(), member.role.as_str()))
5874                    .collect::<Vec<_>>(),
5875                vec![
5876                    ("codex", "planner"),
5877                    ("gemini", "executor"),
5878                    ("claude", "lead")
5879                ]
5880            );
5881            assert_eq!(
5882                iteration
5883                    .sub_agents
5884                    .iter()
5885                    .map(|agent| (agent.name.as_str(), agent.role.as_str()))
5886                    .collect::<Vec<_>>(),
5887                vec![
5888                    ("codex", "planner:sub-agent-1"),
5889                    ("codex", "planner:sub-agent-2"),
5890                    ("claude", "lead:sub-agent-1")
5891                ]
5892            );
5893        }
5894
5895        let events = events.lock().unwrap();
5896        assert!(events.iter().any(|event| {
5897            event.progress.provider.as_deref() == Some("codex")
5898                && event.progress.role.as_deref() == Some("planner")
5899                && !event.progress.is_sub_agent
5900        }));
5901        assert!(events.iter().any(|event| {
5902            event.progress.provider.as_deref() == Some("claude")
5903                && event.progress.role.as_deref() == Some("lead:sub-agent-1")
5904                && event.progress.is_sub_agent
5905        }));
5906        let final_event = events.last().expect("final result event should exist");
5907        assert_eq!(final_event.progress.kind, RuntimeEventKind::Result);
5908        assert!(final_event.result.is_some());
5909    }
5910
5911    #[test]
5912    fn per_provider_sub_agent_overrides_are_reflected_in_prompts() {
5913        let args = CliArgs::try_parse_from([
5914            "amon-hen",
5915            "--members",
5916            "codex,gemini",
5917            "--team-work",
5918            "2",
5919            "--codex-sub-agents",
5920            "0",
5921            "--gemini-sub-agents",
5922            "3",
5923            "ship",
5924        ])
5925        .unwrap();
5926        let resolved = resolve_args(args).unwrap();
5927        let workflow = build_workflow(&resolved);
5928        let codex_prompt = build_member_prompt(MemberPromptInput {
5929            query: "ship",
5930            role: "executor",
5931            workflow: &workflow,
5932            iteration: 1,
5933            team_size: effective_team_size(&workflow, "codex"),
5934            previous_iteration: &[],
5935            handoff_results: &[],
5936            plan_output: "",
5937        });
5938        let gemini_prompt = build_member_prompt(MemberPromptInput {
5939            query: "ship",
5940            role: "executor",
5941            workflow: &workflow,
5942            iteration: 1,
5943            team_size: effective_team_size(&workflow, "gemini"),
5944            previous_iteration: &[],
5945            handoff_results: &[],
5946            plan_output: "",
5947        });
5948
5949        assert!(!codex_prompt.contains("Team work:"));
5950        assert!(gemini_prompt.contains("up to 3 internal sub-agents"));
5951    }
5952
5953    #[test]
5954    fn duplicate_members_are_deduped_in_input_order() {
5955        let args = CliArgs::try_parse_from([
5956            "amon-hen",
5957            "--members",
5958            "codex,claude,codex,gemini,claude",
5959            "hello",
5960        ])
5961        .unwrap();
5962        let resolved = resolve_args(args).unwrap();
5963        assert_eq!(resolved.members, vec!["codex", "claude", "gemini"]);
5964    }
5965
5966    #[test]
5967    fn command_cancel_stops_child_process_promptly() {
5968        if !command_available("sh") {
5969            return;
5970        }
5971        let cwd = std::env::current_dir().unwrap();
5972        let cancel = Arc::new(AtomicBool::new(true));
5973        let started = Instant::now();
5974        let args = ["-c".to_string(), "sleep 30".to_string()];
5975        let result =
5976            run_command(CommandRequest::new("sh", &args, &cwd, 30_000).cancel(Some(cancel)));
5977
5978        assert!(result.cancelled);
5979        assert_eq!(result.error.as_deref(), Some("cancelled"));
5980        assert!(
5981            started.elapsed() < Duration::from_secs(3),
5982            "cancelled command took {:?}",
5983            started.elapsed()
5984        );
5985    }
5986
5987    #[test]
5988    fn command_streams_visible_output_and_live_token_usage() {
5989        if !command_available("sh") {
5990            return;
5991        }
5992        let cwd = std::env::current_dir().unwrap();
5993        let events = Arc::new(Mutex::new(Vec::new()));
5994        let captured = Arc::clone(&events);
5995        let progress: ProgressSink = Arc::new(move |event| {
5996            captured.lock().unwrap().push(event);
5997        });
5998        let args = [
5999            "-c".to_string(),
6000            "printf 'visible-work\\n'; printf '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"name\":\"Bash\"}]}}\\n'"
6001                .to_string(),
6002        ];
6003
6004        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
6005            CommandProgress {
6006                label: "codex planner:sub-agent-1 iteration 1/1".to_string(),
6007                sink: Some(progress),
6008                input_tokens: 10,
6009            },
6010        )));
6011
6012        assert_eq!(result.code, Some(0));
6013        let events = events.lock().unwrap();
6014        assert!(events.iter().any(|event| {
6015            event.message.contains("visible-work")
6016                && event
6017                    .token_usage
6018                    .as_ref()
6019                    .is_some_and(|usage| usage.total > 10)
6020        }));
6021        assert!(events.iter().any(|event| {
6022            event.kind == RuntimeEventKind::ToolUsage
6023                && event.tool_calls.iter().any(|tool| tool.name == "Bash")
6024        }));
6025    }
6026
6027    #[test]
6028    fn command_stream_sampling_preserves_important_tool_events() {
6029        if !command_available("sh") {
6030            return;
6031        }
6032        let cwd = std::env::current_dir().unwrap();
6033        let events = Arc::new(Mutex::new(Vec::new()));
6034        let captured = Arc::clone(&events);
6035        let progress: ProgressSink = Arc::new(move |event| {
6036            captured.lock().unwrap().push(event);
6037        });
6038        let args = [
6039            "-c".to_string(),
6040            "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'"
6041                .to_string(),
6042        ];
6043
6044        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
6045            CommandProgress {
6046                label: "codex planner iteration 1/1".to_string(),
6047                sink: Some(progress),
6048                input_tokens: 10,
6049            },
6050        )));
6051
6052        assert_eq!(result.code, Some(0));
6053        let events = events.lock().unwrap();
6054        let streaming_events = events
6055            .iter()
6056            .filter(|event| event.status.as_deref() == Some("streaming"))
6057            .count();
6058        assert!(
6059            streaming_events < 20,
6060            "expected sampled streaming events, got {streaming_events}"
6061        );
6062        assert!(events.iter().any(|event| {
6063            event.kind == RuntimeEventKind::ToolUsage
6064                && event.tool_calls.iter().any(|tool| tool.name == "Bash")
6065        }));
6066    }
6067
6068    #[test]
6069    fn command_stream_decodes_provider_json_before_dashboard_events() {
6070        if !command_available("sh") {
6071            return;
6072        }
6073        let cwd = std::env::current_dir().unwrap();
6074        let events = Arc::new(Mutex::new(Vec::new()));
6075        let captured = Arc::clone(&events);
6076        let progress: ProgressSink = Arc::new(move |event| {
6077            captured.lock().unwrap().push(event);
6078        });
6079        let args = [
6080            "-c".to_string(),
6081            "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\"}}'"
6082                .to_string(),
6083        ];
6084
6085        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
6086            CommandProgress {
6087                label: "codex executor iteration 1/1".to_string(),
6088                sink: Some(progress),
6089                input_tokens: 10,
6090            },
6091        )));
6092
6093        assert_eq!(result.code, Some(0));
6094        let events = events.lock().unwrap();
6095        assert!(!events
6096            .iter()
6097            .any(|event| event.message.contains("session.started")));
6098        assert!(events.iter().any(|event| {
6099            event.message.contains("tool: shell")
6100                && event.message.contains("line one")
6101                && !event.message.contains("\"type\":\"item.completed\"")
6102        }));
6103    }
6104
6105    #[test]
6106    fn claude_text_deltas_stream_as_coalesced_live_answer() {
6107        if !command_available("sh") {
6108            return;
6109        }
6110        let cwd = std::env::current_dir().unwrap();
6111        let events = Arc::new(Mutex::new(Vec::new()));
6112        let captured = Arc::clone(&events);
6113        let progress: ProgressSink = Arc::new(move |event| {
6114            captured.lock().unwrap().push(event);
6115        });
6116        let script = concat!(
6117            "printf '%s\\n' '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Reviewing \"}}}';",
6118            "printf '%s\\n' '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"gates now.\"}}}'"
6119        );
6120        let args = ["-c".to_string(), script.to_string()];
6121
6122        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
6123            CommandProgress {
6124                label: "claude lead+planner iteration 1/1".to_string(),
6125                sink: Some(progress),
6126                input_tokens: 10,
6127            },
6128        )));
6129
6130        assert_eq!(result.code, Some(0));
6131        let events = events.lock().unwrap();
6132        assert!(events.iter().any(|event| {
6133            event
6134                .message
6135                .contains("assistant live: Reviewing gates now.")
6136                && event.provider.as_deref() == Some("claude")
6137        }));
6138        assert!(!events.iter().any(|event| {
6139            event.message.contains("stdout: assistant: Reviewing ")
6140                || event.message.contains("stdout: assistant: gates now.")
6141        }));
6142    }
6143
6144    #[test]
6145    fn missing_provider_preflight_avoids_team_fanout() {
6146        let args = CliArgs::try_parse_from([
6147            "amon-hen",
6148            "--members",
6149            "codex",
6150            "--team-work",
6151            "5",
6152            "hello",
6153        ])
6154        .unwrap();
6155        let resolved = resolve_args(args).unwrap();
6156        let options = EngineRunOptions {
6157            prompt: "hello".to_string(),
6158            cwd: resolved.cwd.clone(),
6159            timeout_ms: 1_000,
6160            effort: None,
6161            model: None,
6162            permission: None,
6163            auth: DEFAULT_AUTH_MODE.to_string(),
6164            capability: provider_capability(&resolved, "codex"),
6165            role: "planner".to_string(),
6166            iteration: 1,
6167            total_iterations: 1,
6168            team_size: 5,
6169            is_sub_agent: false,
6170            live: false,
6171            progress: None,
6172            cancel: None,
6173        };
6174
6175        let result = run_engine("definitely-missing-amon-hen-test-bin", options);
6176
6177        assert_eq!(result.status, "missing");
6178        assert!(result.sub_agents.is_empty());
6179        assert!(result.detail.contains("not found in PATH"));
6180    }
6181
6182    #[test]
6183    fn system_time_is_available_for_test_environment() {
6184        assert!(std::time::SystemTime::now()
6185            .duration_since(std::time::UNIX_EPOCH)
6186            .is_ok());
6187    }
6188
6189    #[test]
6190    fn cli_hygiene_has_no_legacy_npm_cli_or_council_binary_references() {
6191        let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
6192        let repo_root = crate_dir
6193            .parent()
6194            .and_then(Path::parent)
6195            .expect("crate lives under crates/amon-hen");
6196        assert!(!repo_root.join("package.json").exists());
6197        assert!(!repo_root.join("package-lock.json").exists());
6198        assert!(!repo_root.join("npm").exists());
6199        assert!(!repo_root.join("bin").join("council").exists());
6200
6201        let docs = [
6202            repo_root.join("README.md"),
6203            crate_dir.join("README.md"),
6204            crate_dir.join("CHANGELOG.md"),
6205        ];
6206        for path in docs {
6207            let text = fs::read_to_string(&path).unwrap();
6208            assert!(
6209                !text.contains("`council`") && !text.contains(" council "),
6210                "legacy binary reference remains in {}",
6211                path.display()
6212            );
6213        }
6214
6215        let help = render_cli_help();
6216        assert!(!help.contains("council"));
6217        assert!(help.contains("amon-hen"));
6218    }
6219
6220    fn test_amon_hen_result(members: Vec<EngineResult>, total_iterations: usize) -> AmonHenResult {
6221        let timeline = vec![iteration_record(
6222            total_iterations,
6223            total_iterations,
6224            members.clone(),
6225            25,
6226            None,
6227            Some("summary context".to_string()),
6228        )];
6229        test_amon_hen_result_with_iterations(members, timeline)
6230    }
6231
6232    fn test_amon_hen_result_with_iterations(
6233        members: Vec<EngineResult>,
6234        iterations: Vec<IterationRecord>,
6235    ) -> AmonHenResult {
6236        let mut teams = HashMap::new();
6237        teams.insert("codex".to_string(), 0);
6238        teams.insert("claude".to_string(), 0);
6239        teams.insert("gemini".to_string(), 0);
6240        let workflow = Workflow {
6241            handoff: true,
6242            lead: Some("claude".to_string()),
6243            planner: Some("codex".to_string()),
6244            planner_mode: PLANNER_MODE_BLOCKING.to_string(),
6245            iterations: iterations.len().max(1),
6246            team_work: 0,
6247            teams,
6248        };
6249        AmonHenResult {
6250            query: "ship it".to_string(),
6251            cwd: ".".to_string(),
6252            members_requested: members.iter().map(|member| member.name.clone()).collect(),
6253            summarizer_requested: "auto".to_string(),
6254            workflow,
6255            prompt_commands: vec![],
6256            iterations,
6257            members,
6258            summary: test_engine_result("codex", "summary", 1),
6259        }
6260    }
6261
6262    fn test_engine_result(name: &str, role: &str, iteration: usize) -> EngineResult {
6263        EngineResult {
6264            name: name.to_string(),
6265            bin: Some(name.to_string()),
6266            status: "ok".to_string(),
6267            duration_ms: 10,
6268            detail: String::new(),
6269            exit_code: Some(0),
6270            stdout: String::new(),
6271            stderr: String::new(),
6272            output: format!("{name} output"),
6273            command: format!("{name} exec"),
6274            token_usage: token_usage("prompt", "output"),
6275            tool_calls: vec![ToolUsage {
6276                name: "tool".to_string(),
6277                kind: name.to_string(),
6278                status: "observed".to_string(),
6279                detail: "detail".to_string(),
6280            }],
6281            sub_agents: vec![],
6282            role: role.to_string(),
6283            iteration,
6284            total_iterations: iteration.max(1),
6285            team_size: 0,
6286        }
6287    }
6288}