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