Skip to main content

amon_hen/
lib.rs

1use clap::{error::ErrorKind, ArgAction, CommandFactory, 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::{Command, Stdio};
10use std::sync::{mpsc, Arc, Mutex};
11use std::thread;
12use std::time::{Duration, Instant};
13
14mod linear_delivery;
15mod studio;
16
17const VERSION: &str = env!("CARGO_PKG_VERSION");
18const DEFAULT_TIMEOUT_MS: u64 = 600_000;
19const DEFAULT_MAX_MEMBER_CHARS: usize = 12_000;
20const DEFAULT_ITERATIONS: usize = 1;
21const DEFAULT_TEAM_SIZE: usize = 0;
22const TOKEN_ESTIMATE_CHARS_PER_TOKEN: usize = 4;
23
24const ENGINES: [&str; 3] = ["codex", "claude", "gemini"];
25const DEFAULT_SUMMARIZER_ORDER: [&str; 3] = ["codex", "claude", "gemini"];
26const DEFAULT_AUTH_MODE: &str = "auto";
27const CAPABILITY_INHERIT: &str = "inherit";
28const CAPABILITY_OVERRIDE: &str = "override";
29
30#[derive(Debug, Copy, Clone, Eq, PartialEq)]
31enum Engine {
32    Codex,
33    Claude,
34    Gemini,
35}
36
37impl Engine {
38    fn parse(name: &str) -> Option<Self> {
39        match name {
40            "codex" => Some(Self::Codex),
41            "claude" => Some(Self::Claude),
42            "gemini" => Some(Self::Gemini),
43            _ => None,
44        }
45    }
46
47    fn as_str(self) -> &'static str {
48        match self {
49            Self::Codex => "codex",
50            Self::Claude => "claude",
51            Self::Gemini => "gemini",
52        }
53    }
54
55    fn binary_env_var(self) -> &'static str {
56        match self {
57            Self::Codex => "AMON_HEN_CODEX_BIN",
58            Self::Claude => "AMON_HEN_CLAUDE_BIN",
59            Self::Gemini => "AMON_HEN_GEMINI_BIN",
60        }
61    }
62
63    fn allowed_efforts(self) -> &'static [&'static str] {
64        match self {
65            Self::Codex => &["low", "medium", "high", "xhigh", ""],
66            Self::Claude => &["low", "medium", "high", "xhigh", "max"],
67            Self::Gemini => &["low", "medium", "high", ""],
68        }
69    }
70}
71
72#[derive(Parser, Debug, Clone)]
73#[command(
74    name = "amon-hen",
75    version,
76    about = "Ask multiple AI CLIs the same question, then synthesize their answers.",
77    trailing_var_arg = true,
78    disable_version_flag = true
79)]
80pub struct CliArgs {
81    #[arg(short = 'v', long = "version", action = ArgAction::SetTrue)]
82    version: bool,
83
84    #[arg(long, action = ArgAction::SetTrue)]
85    json: bool,
86
87    #[arg(long = "json-stream", alias = "ndjson", action = ArgAction::SetTrue)]
88    json_stream: bool,
89
90    #[arg(long, action = ArgAction::SetTrue)]
91    headless: bool,
92
93    #[arg(long, action = ArgAction::SetTrue)]
94    studio: bool,
95
96    #[arg(long, action = ArgAction::SetTrue)]
97    plain: bool,
98
99    #[arg(long = "no-banner", action = ArgAction::SetTrue)]
100    no_banner: bool,
101    #[arg(long = "banner", hide = true, action = ArgAction::SetTrue)]
102    banner: bool,
103
104    #[arg(short = 'q', long = "quiet", alias = "summary-only", action = ArgAction::SetTrue)]
105    summary_only: bool,
106
107    #[arg(short = 'd', long, action = ArgAction::SetTrue)]
108    verbose: bool,
109
110    #[arg(long, action = ArgAction::SetTrue)]
111    all: bool,
112
113    #[arg(long, action = ArgAction::SetTrue)]
114    codex: bool,
115    #[arg(long = "no-codex", action = ArgAction::SetTrue)]
116    no_codex: bool,
117    #[arg(long, action = ArgAction::SetTrue)]
118    claude: bool,
119    #[arg(long = "no-claude", action = ArgAction::SetTrue)]
120    no_claude: bool,
121    #[arg(long, action = ArgAction::SetTrue)]
122    gemini: bool,
123    #[arg(long = "no-gemini", action = ArgAction::SetTrue)]
124    no_gemini: bool,
125
126    #[arg(long, value_delimiter = ',')]
127    members: Vec<String>,
128
129    #[arg(long, default_value = "auto")]
130    summarizer: String,
131
132    #[arg(long, value_enum)]
133    effort: Option<Effort>,
134
135    #[arg(long = "codex-model")]
136    codex_model: Option<String>,
137    #[arg(long = "claude-model")]
138    claude_model: Option<String>,
139    #[arg(long = "gemini-model")]
140    gemini_model: Option<String>,
141
142    #[arg(long = "codex-effort")]
143    codex_effort: Option<String>,
144    #[arg(long = "claude-effort")]
145    claude_effort: Option<String>,
146    #[arg(long = "gemini-effort")]
147    gemini_effort: Option<String>,
148
149    #[arg(long = "codex-sandbox", default_value = "read-only")]
150    codex_sandbox: String,
151    #[arg(long = "claude-permission-mode", default_value = "plan")]
152    claude_permission_mode: String,
153
154    #[arg(long = "codex-auth", default_value = "auto")]
155    codex_auth: String,
156    #[arg(long = "claude-auth", default_value = "auto")]
157    claude_auth: String,
158    #[arg(long = "gemini-auth", default_value = "auto")]
159    gemini_auth: String,
160
161    #[arg(long = "codex-capabilities", default_value = "inherit")]
162    codex_capabilities: String,
163    #[arg(long = "claude-capabilities", default_value = "inherit")]
164    claude_capabilities: String,
165    #[arg(long = "gemini-capabilities", default_value = "inherit")]
166    gemini_capabilities: String,
167
168    #[arg(long = "codex-config", action = ArgAction::Append)]
169    codex_config: Vec<String>,
170    #[arg(long = "codex-mcp-profile")]
171    codex_mcp_profile: Option<String>,
172    #[arg(long = "claude-mcp-config", action = ArgAction::Append)]
173    claude_mcp_config: Vec<String>,
174    #[arg(long = "claude-allowed-tools", value_delimiter = ',', action = ArgAction::Append)]
175    claude_allowed_tools: Vec<String>,
176    #[arg(long = "claude-disallowed-tools", value_delimiter = ',', action = ArgAction::Append)]
177    claude_disallowed_tools: Vec<String>,
178    #[arg(long = "claude-tools", value_delimiter = ',', action = ArgAction::Append)]
179    claude_tools: Vec<String>,
180    #[arg(long = "claude-agent")]
181    claude_agent: Option<String>,
182    #[arg(long = "claude-agents-json")]
183    claude_agents_json: Option<String>,
184    #[arg(long = "claude-plugin-dir", action = ArgAction::Append)]
185    claude_plugin_dir: Vec<String>,
186    #[arg(long = "claude-strict-mcp-config", action = ArgAction::SetTrue)]
187    claude_strict_mcp_config: bool,
188    #[arg(long = "claude-disable-slash-commands", action = ArgAction::SetTrue)]
189    claude_disable_slash_commands: bool,
190    #[arg(long = "gemini-settings")]
191    gemini_settings: Option<String>,
192    #[arg(long = "gemini-tools-profile", value_delimiter = ',', action = ArgAction::Append)]
193    gemini_tools_profile: Vec<String>,
194    #[arg(long = "gemini-allowed-mcp-servers", value_delimiter = ',', action = ArgAction::Append)]
195    gemini_allowed_mcp_servers: Vec<String>,
196    #[arg(long = "gemini-policy", value_delimiter = ',', action = ArgAction::Append)]
197    gemini_policy: Vec<String>,
198    #[arg(long = "gemini-admin-policy", value_delimiter = ',', action = ArgAction::Append)]
199    gemini_admin_policy: Vec<String>,
200    #[arg(long = "capabilities-status", action = ArgAction::SetTrue)]
201    capabilities_status: bool,
202
203    #[arg(long = "auth-login", action = ArgAction::SetTrue)]
204    auth_login: bool,
205    #[arg(long = "auth-status", action = ArgAction::SetTrue)]
206    auth_status: bool,
207    #[arg(long = "auth-login-providers", value_delimiter = ',')]
208    auth_login_providers: Vec<String>,
209    #[arg(long = "auth-device-code", action = ArgAction::SetTrue)]
210    auth_device_code: bool,
211    #[arg(long = "no-auth-open-browser", action = ArgAction::SetTrue)]
212    no_auth_open_browser: bool,
213    #[arg(long = "auth-open-browser", hide = true, action = ArgAction::SetTrue)]
214    auth_open_browser: bool,
215    #[arg(long = "auth-timeout", default_value_t = 300)]
216    auth_timeout: u64,
217    #[arg(long = "claude-login-mode", default_value = "claudeai")]
218    claude_login_mode: String,
219    #[arg(long = "claude-login-email")]
220    claude_login_email: Option<String>,
221
222    #[arg(long = "file", alias = "tag-file", action = ArgAction::Append)]
223    files: Vec<PathBuf>,
224    #[arg(long = "cmd", alias = "prompt-command", action = ArgAction::Append)]
225    commands: Vec<String>,
226
227    #[arg(long, action = ArgAction::SetTrue)]
228    handoff: bool,
229    #[arg(long)]
230    lead: Option<String>,
231    #[arg(long)]
232    planner: Option<String>,
233    #[arg(long, default_value_t = DEFAULT_ITERATIONS)]
234    iterations: usize,
235    #[arg(long = "team-work", alias = "teamwork", alias = "sub-agents", default_value_t = DEFAULT_TEAM_SIZE)]
236    team_work: usize,
237    #[arg(long = "codex-sub-agents")]
238    codex_sub_agents: Option<usize>,
239    #[arg(long = "claude-sub-agents")]
240    claude_sub_agents: Option<usize>,
241    #[arg(long = "gemini-sub-agents")]
242    gemini_sub_agents: Option<usize>,
243
244    #[arg(long = "deliver-linear", alias = "linear", action = ArgAction::SetTrue)]
245    deliver_linear: bool,
246    #[arg(long = "linear-setup", action = ArgAction::SetTrue)]
247    linear_setup: bool,
248    #[arg(long = "linear-status", action = ArgAction::SetTrue)]
249    linear_status: bool,
250    #[arg(long = "linear-watch", action = ArgAction::SetTrue)]
251    linear_watch: bool,
252    #[arg(long = "linear-until-complete", action = ArgAction::SetTrue)]
253    linear_until_complete: bool,
254    #[arg(long = "linear-issue", value_delimiter = ',', action = ArgAction::Append)]
255    linear_issue: Vec<String>,
256    #[arg(long = "linear-query")]
257    linear_query: Option<String>,
258    #[arg(long = "linear-project", value_delimiter = ',', action = ArgAction::Append)]
259    linear_project: Vec<String>,
260    #[arg(long = "linear-epic", value_delimiter = ',', action = ArgAction::Append)]
261    linear_epic: Vec<String>,
262    #[arg(long = "linear-team")]
263    linear_team: Option<String>,
264    #[arg(long = "linear-state")]
265    linear_state: Option<String>,
266    #[arg(long = "linear-assignee")]
267    linear_assignee: Option<String>,
268    #[arg(long = "linear-limit", default_value_t = 3)]
269    linear_limit: usize,
270    #[arg(long = "linear-endpoint")]
271    linear_endpoint: Option<String>,
272    #[arg(long = "linear-auth", default_value = "api-key")]
273    linear_auth: String,
274    #[arg(long = "linear-api-key-env", default_value = "LINEAR_API_KEY")]
275    linear_api_key_env: String,
276    #[arg(long = "linear-oauth-token-env", default_value = "LINEAR_OAUTH_TOKEN")]
277    linear_oauth_token_env: String,
278    #[arg(long = "linear-completion-gate", default_value = "delivered")]
279    linear_completion_gate: String,
280    #[arg(long = "linear-review-state")]
281    linear_review_state: Option<String>,
282    #[arg(long = "linear-ci-timeout", default_value_t = 900)]
283    linear_ci_timeout: u64,
284    #[arg(long = "linear-ci-poll-interval", default_value_t = 30)]
285    linear_ci_poll_interval: u64,
286    #[arg(long = "linear-poll-interval", default_value_t = 60)]
287    linear_poll_interval: u64,
288    #[arg(long = "linear-max-polls")]
289    linear_max_polls: Option<usize>,
290    #[arg(long = "linear-max-concurrency", default_value_t = 1)]
291    linear_max_concurrency: usize,
292    #[arg(long = "linear-max-attempts", default_value_t = 3)]
293    linear_max_attempts: usize,
294    #[arg(long = "linear-retry-base", default_value_t = 60)]
295    linear_retry_base: u64,
296    #[arg(long = "linear-workspace-strategy", default_value = "worktree")]
297    linear_workspace_strategy: String,
298    #[arg(long = "linear-state-file")]
299    linear_state_file: Option<PathBuf>,
300    #[arg(long = "linear-workspace-root")]
301    linear_workspace_root: Option<PathBuf>,
302    #[arg(long = "linear-observability-dir")]
303    linear_observability_dir: Option<PathBuf>,
304    #[arg(long = "linear-workflow-file")]
305    linear_workflow_file: Option<PathBuf>,
306    #[arg(long = "linear-attach-media", value_delimiter = ',', action = ArgAction::Append)]
307    linear_attach_media: Vec<String>,
308    #[arg(long = "linear-attachment-title")]
309    linear_attachment_title: Option<String>,
310    #[arg(long = "no-linear-comments", action = ArgAction::SetTrue)]
311    no_linear_comments: bool,
312    #[arg(long = "linear-update-review-state", action = ArgAction::SetTrue)]
313    linear_update_review_state: bool,
314    #[arg(long = "delivery-phases", value_delimiter = ',')]
315    delivery_phases: Vec<String>,
316
317    #[arg(long, default_value_t = DEFAULT_TIMEOUT_MS / 1000)]
318    timeout: u64,
319    #[arg(long = "max-member-chars", default_value_t = DEFAULT_MAX_MEMBER_CHARS)]
320    max_member_chars: usize,
321    #[arg(long, default_value = ".")]
322    cwd: PathBuf,
323    #[arg(long, default_value = "auto")]
324    color: String,
325    #[arg(long = "no-color", action = ArgAction::SetTrue)]
326    no_color: bool,
327
328    #[arg(value_name = "QUERY")]
329    prompt: Vec<String>,
330}
331
332#[derive(Copy, Clone, Debug, ValueEnum)]
333enum Effort {
334    Low,
335    Medium,
336    High,
337}
338
339impl Effort {
340    fn as_str(self) -> &'static str {
341        match self {
342            Effort::Low => "low",
343            Effort::Medium => "medium",
344            Effort::High => "high",
345        }
346    }
347}
348
349#[derive(Debug, Clone)]
350struct ResolvedArgs {
351    raw: CliArgs,
352    members: Vec<String>,
353    prompt: String,
354    cwd: PathBuf,
355}
356
357#[derive(Debug, Clone, Serialize)]
358pub struct AmonHenResult {
359    query: String,
360    cwd: String,
361    members_requested: Vec<String>,
362    summarizer_requested: String,
363    workflow: Workflow,
364    prompt_commands: Vec<CommandTelemetry>,
365    members: Vec<EngineResult>,
366    summary: EngineResult,
367}
368
369#[derive(Debug, Clone, Serialize)]
370struct Workflow {
371    handoff: bool,
372    lead: Option<String>,
373    planner: Option<String>,
374    iterations: usize,
375    team_work: usize,
376    teams: HashMap<String, usize>,
377}
378
379#[derive(Debug, Clone, Serialize)]
380pub struct EngineResult {
381    name: String,
382    bin: Option<String>,
383    status: String,
384    duration_ms: u128,
385    detail: String,
386    exit_code: Option<i32>,
387    stdout: String,
388    stderr: String,
389    output: String,
390    command: String,
391    token_usage: TokenUsage,
392    tool_calls: Vec<ToolUsage>,
393    sub_agents: Vec<EngineResult>,
394    role: String,
395    iteration: usize,
396    total_iterations: usize,
397    team_size: usize,
398}
399
400#[derive(Debug, Clone, Serialize)]
401struct TokenUsage {
402    input: usize,
403    output: usize,
404    total: usize,
405    estimated: bool,
406    source: String,
407}
408
409#[derive(Debug, Clone, Serialize)]
410struct ToolUsage {
411    name: String,
412    kind: String,
413    status: String,
414    detail: String,
415}
416
417#[derive(Debug, Clone, Serialize)]
418struct CommandTelemetry {
419    command: String,
420    status: String,
421    detail: String,
422    exit_code: Option<i32>,
423    duration_ms: u128,
424    stdout_chars: usize,
425    stderr_chars: usize,
426    timed_out: bool,
427}
428
429#[derive(Debug)]
430struct PromptContext {
431    prompt: String,
432    commands: Vec<CommandTelemetry>,
433}
434
435#[derive(Debug)]
436struct CommandResult {
437    command: String,
438    args: Vec<String>,
439    code: Option<i32>,
440    stdout: String,
441    stderr: String,
442    timed_out: bool,
443    error: Option<String>,
444    timeout_ms: u64,
445    duration_ms: u128,
446}
447
448#[derive(Debug, Clone)]
449struct EngineRunOptions {
450    prompt: String,
451    cwd: PathBuf,
452    timeout_ms: u64,
453    effort: Option<String>,
454    model: Option<String>,
455    permission: Option<String>,
456    auth: String,
457    capability: ProviderCapability,
458    role: String,
459    iteration: usize,
460    total_iterations: usize,
461    team_size: usize,
462    is_sub_agent: bool,
463}
464
465#[derive(Debug, Clone)]
466struct ProviderCapability {
467    mode: String,
468    config: Vec<String>,
469    mcp_profile: Option<String>,
470    mcp_config: Vec<String>,
471    allowed_tools: Vec<String>,
472    disallowed_tools: Vec<String>,
473    tools: Vec<String>,
474    agent: Option<String>,
475    agents_json: Option<String>,
476    plugin_dirs: Vec<String>,
477    strict_mcp_config: bool,
478    disable_slash_commands: bool,
479    settings: Option<String>,
480    tools_profile: Vec<String>,
481    allowed_mcp_servers: Vec<String>,
482    policy: Vec<String>,
483    admin_policy: Vec<String>,
484}
485
486#[derive(Debug, Clone, Serialize)]
487struct ProviderAuthStatus {
488    provider: String,
489    configured: bool,
490    status: String,
491    source: String,
492    detail: String,
493}
494
495#[derive(Debug, Clone, Serialize)]
496struct ProviderCapabilityStatus {
497    provider: String,
498    mcp: CommandTelemetry,
499    skills: Option<CommandTelemetry>,
500    tools: Option<CommandTelemetry>,
501    detail: String,
502}
503
504struct TempSettings {
505    _dir: tempfile::TempDir,
506    path: PathBuf,
507}
508
509pub fn run_from_env() -> i32 {
510    let args: Vec<OsString> = std::env::args_os().skip(1).collect();
511    run_with_args(args)
512}
513
514pub fn run_with_args<I, T>(args: I) -> i32
515where
516    I: IntoIterator<Item = T>,
517    T: Into<OsString>,
518{
519    let raw_args: Vec<OsString> = args.into_iter().map(Into::into).collect();
520    if raw_args.first().and_then(|value| value.to_str()) == Some("help") {
521        let mut command = CliArgs::command();
522        let _ = command.print_long_help();
523        println!();
524        return 0;
525    }
526
527    let mut parse_args = vec![OsString::from("amon-hen")];
528    parse_args.extend(raw_args);
529    let parsed = match CliArgs::try_parse_from(parse_args) {
530        Ok(parsed) => parsed,
531        Err(error) => {
532            let exit_code = parse_error_exit_code(error.kind());
533            let _ = error.print();
534            return exit_code;
535        }
536    };
537
538    if parsed.version {
539        println!("{VERSION}");
540        return 0;
541    }
542
543    let resolved = match resolve_args(parsed) {
544        Ok(resolved) => resolved,
545        Err(error) => {
546            eprintln!("{error}");
547            return 64;
548        }
549    };
550
551    if resolved.raw.studio {
552        return studio::run_studio(&resolved);
553    }
554
555    if resolved.raw.auth_status {
556        println!(
557            "{}",
558            render_auth_statuses(&collect_auth_statuses(&resolved))
559        );
560        if resolved.prompt.trim().is_empty()
561            && !resolved.raw.auth_login
562            && !resolved.raw.deliver_linear
563            && !resolved.raw.capabilities_status
564        {
565            return 0;
566        }
567    }
568
569    if resolved.raw.capabilities_status {
570        println!(
571            "{}",
572            render_provider_capability_statuses(&collect_provider_capability_statuses(&resolved))
573        );
574        if resolved.prompt.trim().is_empty()
575            && !resolved.raw.auth_login
576            && !resolved.raw.deliver_linear
577        {
578            return 0;
579        }
580    }
581
582    if resolved.raw.auth_login {
583        if let Err(error) = run_social_login(&resolved) {
584            eprintln!("{error}");
585            return 1;
586        }
587        if resolved.prompt.trim().is_empty() && !resolved.raw.deliver_linear {
588            return 0;
589        }
590    }
591
592    if resolved.raw.linear_setup || resolved.raw.linear_status {
593        match linear_delivery::get_linear_status(&resolved) {
594            Ok(status) => {
595                println!("{}", linear_delivery::render_linear_status(&status));
596                return 0;
597            }
598            Err(error) => {
599                eprintln!("{error}");
600                return 1;
601            }
602        }
603    }
604
605    if linear_delivery_requested(&resolved.raw) {
606        match linear_delivery::run_linear_delivery(&resolved) {
607            Ok(result) => {
608                println!(
609                    "{}",
610                    linear_delivery::render_linear_delivery_result(&result)
611                );
612                return if result.success { 0 } else { 1 };
613            }
614            Err(error) => {
615                eprintln!("{error}");
616                return 1;
617            }
618        }
619    }
620
621    if resolved.prompt.trim().is_empty() {
622        eprintln!(
623            "No query provided.\n\n{}",
624            CliArgs::command().render_long_help()
625        );
626        return 64;
627    }
628
629    let prompt_context = match build_prompt_context(&resolved) {
630        Ok(context) => context,
631        Err(error) => {
632            eprintln!("{error}");
633            return 1;
634        }
635    };
636
637    if should_show_banner(&resolved.raw) {
638        eprintln!("{}", render_banner());
639    }
640
641    let result = run_amon_hen(&resolved, prompt_context.prompt, prompt_context.commands);
642    if resolved.raw.json || resolved.raw.json_stream {
643        let serialized = if resolved.raw.json_stream {
644            serde_json::to_string(&result)
645        } else {
646            serde_json::to_string_pretty(&result)
647        };
648        println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
649    } else {
650        println!("{}", render_human_result(&result, resolved.raw.verbose));
651    }
652
653    if is_success(&result) {
654        0
655    } else {
656        1
657    }
658}
659
660fn parse_error_exit_code(kind: ErrorKind) -> i32 {
661    match kind {
662        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
663        _ => 64,
664    }
665}
666
667fn resolve_args(raw: CliArgs) -> Result<ResolvedArgs, String> {
668    let members = resolve_members(&raw)?;
669    validate_engine_name(&raw.summarizer, true, "--summarizer")?;
670    if let Some(lead) = &raw.lead {
671        validate_engine_name(lead, false, "--lead")?;
672        if !members.contains(lead) {
673            return Err(format!(
674                "--lead must be one of the enabled members: {}",
675                members.join(", ")
676            ));
677        }
678    }
679    if let Some(planner) = &raw.planner {
680        validate_engine_name(planner, false, "--planner")?;
681        if !members.contains(planner) {
682            return Err(format!(
683                "--planner must be one of the enabled members: {}",
684                members.join(", ")
685            ));
686        }
687    }
688    validate_provider_effort("codex", raw.codex_effort.as_deref())?;
689    validate_provider_effort("claude", raw.claude_effort.as_deref())?;
690    validate_provider_effort("gemini", raw.gemini_effort.as_deref())?;
691    validate_choice(
692        "--claude-login-mode",
693        &raw.claude_login_mode,
694        &["claudeai", "console", "sso"],
695    )?;
696    validate_choice(
697        "--codex-capabilities",
698        &raw.codex_capabilities,
699        &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
700    )?;
701    validate_choice(
702        "--claude-capabilities",
703        &raw.claude_capabilities,
704        &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
705    )?;
706    validate_choice(
707        "--gemini-capabilities",
708        &raw.gemini_capabilities,
709        &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
710    )?;
711    validate_choice("--linear-auth", &raw.linear_auth, &["api-key", "oauth"])?;
712    validate_choice(
713        "--linear-completion-gate",
714        &raw.linear_completion_gate,
715        &["delivered", "human-review", "ci-success", "review-or-ci"],
716    )?;
717    validate_choice(
718        "--linear-workspace-strategy",
719        &raw.linear_workspace_strategy,
720        &["worktree", "copy", "none"],
721    )?;
722    if raw.linear_limit == 0 {
723        return Err("--linear-limit requires a positive integer.".to_string());
724    }
725    if raw.linear_max_concurrency == 0 {
726        return Err("--linear-max-concurrency requires a positive integer.".to_string());
727    }
728    if raw.linear_max_polls == Some(0) {
729        return Err("--linear-max-polls requires a positive integer.".to_string());
730    }
731    if raw
732        .linear_endpoint
733        .as_ref()
734        .is_some_and(|value| value.trim().is_empty())
735    {
736        return Err("--linear-endpoint requires a non-empty value.".to_string());
737    }
738    for phase in raw
739        .delivery_phases
740        .iter()
741        .map(|phase| phase.trim())
742        .filter(|phase| !phase.is_empty())
743    {
744        validate_choice(
745            "--delivery-phases",
746            phase,
747            &["plan", "implement", "verify", "ship"],
748        )?;
749    }
750
751    let mut prompt = raw.prompt.join(" ");
752    if prompt.trim().is_empty() && !io::stdin().is_terminal() {
753        let mut stdin = String::new();
754        io::stdin()
755            .read_to_string(&mut stdin)
756            .map_err(|error| format!("Failed to read stdin: {error}"))?;
757        prompt = stdin;
758    }
759    let cwd = raw.cwd.canonicalize().unwrap_or_else(|_| raw.cwd.clone());
760
761    Ok(ResolvedArgs {
762        raw,
763        members,
764        prompt,
765        cwd,
766    })
767}
768
769fn resolve_members(raw: &CliArgs) -> Result<Vec<String>, String> {
770    let mut members = if raw.members.is_empty() {
771        ENGINES
772            .iter()
773            .map(|name| (*name).to_string())
774            .collect::<Vec<_>>()
775    } else {
776        raw.members
777            .iter()
778            .map(|name| name.trim().to_string())
779            .filter(|name| !name.is_empty())
780            .collect::<Vec<_>>()
781    };
782
783    if raw.all {
784        members = ENGINES.iter().map(|name| (*name).to_string()).collect();
785    }
786    if raw.no_codex {
787        members.retain(|name| name != "codex");
788    }
789    if raw.no_claude {
790        members.retain(|name| name != "claude");
791    }
792    if raw.no_gemini {
793        members.retain(|name| name != "gemini");
794    }
795    for enabled in [
796        (raw.codex, "codex"),
797        (raw.claude, "claude"),
798        (raw.gemini, "gemini"),
799    ] {
800        if enabled.0 && !members.iter().any(|name| name == enabled.1) {
801            members.push(enabled.1.to_string());
802        }
803    }
804
805    members.dedup();
806    for member in &members {
807        validate_engine_name(member, false, "--members")?;
808    }
809    if members.is_empty() {
810        return Err("At least one member must be enabled.".to_string());
811    }
812    Ok(members)
813}
814
815fn validate_engine_name(name: &str, allow_auto: bool, flag: &str) -> Result<(), String> {
816    if allow_auto && name == "auto" {
817        return Ok(());
818    }
819    if Engine::parse(name).is_some() {
820        Ok(())
821    } else {
822        Err(format!(
823            "{flag} must be one of: {}",
824            if allow_auto {
825                "auto, codex, claude, gemini"
826            } else {
827                "codex, claude, gemini"
828            }
829        ))
830    }
831}
832
833fn validate_provider_effort(provider: &str, value: Option<&str>) -> Result<(), String> {
834    let Some(value) = value else {
835        return Ok(());
836    };
837    let engine = Engine::parse(provider).expect("provider effort validation uses known engines");
838    if engine.allowed_efforts().contains(&value) {
839        Ok(())
840    } else {
841        Err(format!("Unsupported --{provider}-effort value: {value}"))
842    }
843}
844
845fn validate_choice(flag: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
846    if allowed.contains(&value) {
847        Ok(())
848    } else {
849        Err(format!("{flag} must be one of: {}", allowed.join(", ")))
850    }
851}
852
853fn collect_auth_statuses(resolved: &ResolvedArgs) -> Vec<ProviderAuthStatus> {
854    resolved
855        .members
856        .iter()
857        .map(|provider| provider_auth_status(provider, resolved))
858        .collect()
859}
860
861fn provider_auth_status(provider: &str, resolved: &ResolvedArgs) -> ProviderAuthStatus {
862    let auth = provider_auth(resolved, provider);
863    match provider {
864        "codex" => {
865            let bin = resolve_binary("codex");
866            let args = vec!["login".to_string(), "status".to_string()];
867            status_from_command(provider, &auth, &bin, &args, &resolved.cwd)
868        }
869        "claude" => {
870            let bin = resolve_binary("claude");
871            let args = vec![
872                "auth".to_string(),
873                "status".to_string(),
874                "--text".to_string(),
875            ];
876            status_from_command(provider, &auth, &bin, &args, &resolved.cwd)
877        }
878        "gemini" => {
879            let has_api_key = std::env::var("GEMINI_API_KEY")
880                .ok()
881                .is_some_and(|value| !value.trim().is_empty());
882            let home = std::env::var("HOME").unwrap_or_default();
883            let has_oauth_file = !home.is_empty()
884                && [
885                    ".gemini/oauth_creds.json",
886                    ".gemini/oauth_tokens.json",
887                    ".gemini/settings.json",
888                ]
889                .iter()
890                .any(|path| Path::new(&home).join(path).exists());
891            let configured = has_api_key || has_oauth_file;
892            ProviderAuthStatus {
893                provider: provider.to_string(),
894                configured,
895                status: if configured { "configured" } else { "unknown" }.to_string(),
896                source: auth,
897                detail: if has_api_key {
898                    "GEMINI_API_KEY is present.".to_string()
899                } else if has_oauth_file {
900                    "Gemini local auth/config files are present.".to_string()
901                } else {
902                    "Gemini CLI does not currently expose a stable headless auth status command; use Social login from Studio or run gemini interactively.".to_string()
903                },
904            }
905        }
906        _ => ProviderAuthStatus {
907            provider: provider.to_string(),
908            configured: false,
909            status: "unknown".to_string(),
910            source: auth,
911            detail: "Unknown provider.".to_string(),
912        },
913    }
914}
915
916fn status_from_command(
917    provider: &str,
918    auth: &str,
919    bin: &str,
920    args: &[String],
921    cwd: &Path,
922) -> ProviderAuthStatus {
923    let result = run_command(bin, args, cwd, None, 15_000, HashMap::new());
924    let configured = result.code == Some(0);
925    ProviderAuthStatus {
926        provider: provider.to_string(),
927        configured,
928        status: if configured {
929            "configured"
930        } else if result.error.is_some() {
931            "missing-cli"
932        } else {
933            "not-configured"
934        }
935        .to_string(),
936        source: auth.to_string(),
937        detail: sanitize_status_detail(&compact_failure(&result)),
938    }
939}
940
941fn sanitize_status_detail(detail: &str) -> String {
942    let detail = detail.trim();
943    if detail.is_empty() {
944        return "No status detail returned.".to_string();
945    }
946    let detail = detail
947        .lines()
948        .take(6)
949        .map(str::trim)
950        .filter(|line| !line.is_empty())
951        .map(redact_possible_secret)
952        .collect::<Vec<_>>()
953        .join(" ");
954    redact_local_paths(&detail)
955}
956
957fn redact_possible_secret(value: &str) -> String {
958    value
959        .split_whitespace()
960        .map(|token| {
961            if looks_sensitive_token(token) {
962                "[redacted]"
963            } else if looks_like_email(token) {
964                "[email]"
965            } else {
966                token
967            }
968        })
969        .collect::<Vec<_>>()
970        .join(" ")
971}
972
973fn looks_sensitive_token(token: &str) -> bool {
974    let trimmed =
975        token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_');
976    trimmed.len() >= 28
977        && (trimmed.starts_with("sk-")
978            || trimmed.starts_with("sk_")
979            || trimmed.starts_with("gho_")
980            || trimmed.starts_with("ghp_")
981            || trimmed.starts_with("ghs_")
982            || trimmed.starts_with("ghu_")
983            || trimmed.starts_with("AIza")
984            || trimmed.starts_with("AQ."))
985}
986
987fn looks_like_email(token: &str) -> bool {
988    let trimmed = token.trim_matches(|ch: char| {
989        !ch.is_ascii_alphanumeric() && !matches!(ch, '@' | '.' | '_' | '-' | '+')
990    });
991    trimmed.contains('@') && trimmed.rsplit_once('.').is_some()
992}
993
994fn redact_local_paths(value: &str) -> String {
995    let mut redacted = value.to_string();
996    if let Ok(home) = std::env::var("HOME") {
997        if !home.trim().is_empty() {
998            redacted = redacted.replace(&home, "~");
999        }
1000    }
1001    redacted
1002}
1003
1004fn render_auth_statuses(statuses: &[ProviderAuthStatus]) -> String {
1005    let mut lines = vec!["Provider auth status".to_string()];
1006    for status in statuses {
1007        lines.push(format!(
1008            "- {}: {} via {} ({})",
1009            status.provider, status.status, status.source, status.detail
1010        ));
1011    }
1012    lines.join("\n")
1013}
1014
1015fn collect_provider_capability_statuses(resolved: &ResolvedArgs) -> Vec<ProviderCapabilityStatus> {
1016    resolved
1017        .members
1018        .iter()
1019        .map(|provider| provider_capability_status(provider, resolved))
1020        .collect()
1021}
1022
1023fn provider_capability_status(provider: &str, resolved: &ResolvedArgs) -> ProviderCapabilityStatus {
1024    match provider {
1025        "codex" => {
1026            let bin = resolve_binary("codex");
1027            let mcp = run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd);
1028            ProviderCapabilityStatus {
1029                provider: provider.to_string(),
1030                mcp,
1031                skills: None,
1032                tools: Some(run_capability_probe(
1033                    &bin,
1034                    &["plugin", "marketplace", "--help"],
1035                    &resolved.cwd,
1036                )),
1037                detail:
1038                    "Codex inherits ~/.codex config unless --codex-capabilities override is set."
1039                        .to_string(),
1040            }
1041        }
1042        "claude" => {
1043            let bin = resolve_binary("claude");
1044            ProviderCapabilityStatus {
1045                provider: provider.to_string(),
1046                mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd),
1047                skills: Some(run_capability_probe(&bin, &["plugin", "list"], &resolved.cwd)),
1048                tools: Some(run_capability_probe(&bin, &["agents"], &resolved.cwd)),
1049                detail: "Claude override can manage MCP config, tools, agents, plugin dirs, and slash-command skills.".to_string(),
1050            }
1051        }
1052        "gemini" => {
1053            let bin = resolve_binary("gemini");
1054            ProviderCapabilityStatus {
1055                provider: provider.to_string(),
1056                mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd),
1057                skills: Some(run_capability_probe(&bin, &["skills", "list"], &resolved.cwd)),
1058                tools: Some(run_capability_probe(&bin, &["extensions", "list"], &resolved.cwd)),
1059                detail: "Gemini override can manage settings, extensions, MCP server allowlists, and policy files.".to_string(),
1060            }
1061        }
1062        _ => ProviderCapabilityStatus {
1063            provider: provider.to_string(),
1064            mcp: CommandTelemetry {
1065                command: provider.to_string(),
1066                status: "unknown".to_string(),
1067                detail: "Unknown provider.".to_string(),
1068                exit_code: None,
1069                duration_ms: 0,
1070                stdout_chars: 0,
1071                stderr_chars: 0,
1072                timed_out: false,
1073            },
1074            skills: None,
1075            tools: None,
1076            detail: "Unknown provider.".to_string(),
1077        },
1078    }
1079}
1080
1081fn run_capability_probe(bin: &str, args: &[&str], cwd: &Path) -> CommandTelemetry {
1082    let args = args
1083        .iter()
1084        .map(|arg| (*arg).to_string())
1085        .collect::<Vec<_>>();
1086    command_telemetry(&run_command(bin, &args, cwd, None, 20_000, HashMap::new()))
1087}
1088
1089fn render_provider_capability_statuses(statuses: &[ProviderCapabilityStatus]) -> String {
1090    let mut lines = vec!["Provider capabilities".to_string()];
1091    for status in statuses {
1092        lines.push(format!(
1093            "- {} MCP: {} ({})",
1094            status.provider, status.mcp.status, status.mcp.command
1095        ));
1096        if !status.mcp.detail.trim().is_empty() {
1097            lines.push(format!("  {}", status.mcp.detail));
1098        }
1099        if let Some(skills) = &status.skills {
1100            lines.push(format!(
1101                "  skills/plugins: {} ({})",
1102                skills.status, skills.command
1103            ));
1104            if !skills.detail.trim().is_empty() {
1105                lines.push(format!("  {}", skills.detail));
1106            }
1107        }
1108        if let Some(tools) = &status.tools {
1109            lines.push(format!(
1110                "  tools/extensions: {} ({})",
1111                tools.status, tools.command
1112            ));
1113            if !tools.detail.trim().is_empty() {
1114                lines.push(format!("  {}", tools.detail));
1115            }
1116        }
1117        lines.push(format!("  {}", status.detail));
1118    }
1119    lines.join("\n")
1120}
1121
1122fn run_social_login(resolved: &ResolvedArgs) -> Result<(), String> {
1123    let providers = if resolved.raw.auth_login_providers.is_empty() {
1124        resolved.members.clone()
1125    } else {
1126        resolved.raw.auth_login_providers.clone()
1127    };
1128
1129    for provider in providers {
1130        validate_engine_name(&provider, false, "--auth-login-providers")?;
1131        let (bin, args, instruction): (String, Vec<String>, &str) =
1132            match provider.as_str() {
1133                "codex" => {
1134                    let mut args = vec!["login".to_string()];
1135                    if resolved.raw.auth_device_code {
1136                        args.push("--device-auth".to_string());
1137                    }
1138                    (
1139                        resolve_binary("codex"),
1140                        args,
1141                        "Complete the Codex browser login. Deeplinks and pasted codes are supported by the provider CLI when prompted.",
1142                    )
1143                }
1144                "claude" => {
1145                    let mut args = vec!["auth".to_string(), "login".to_string()];
1146                    match resolved.raw.claude_login_mode.as_str() {
1147                        "console" => args.push("--console".to_string()),
1148                        "sso" => args.push("--sso".to_string()),
1149                        _ => args.push("--claudeai".to_string()),
1150                    }
1151                    if let Some(email) = &resolved.raw.claude_login_email {
1152                        push_arg(&mut args, "--email", email.clone());
1153                    }
1154                    (
1155                        resolve_binary("claude"),
1156                        args,
1157                        "Complete the Claude browser login. Paste any shown login code into this terminal when prompted; deeplinks are opened when the CLI emits them.",
1158                    )
1159                }
1160                "gemini" => (
1161                    resolve_binary("gemini"),
1162                    vec![],
1163                    "Use the Gemini CLI auth selector, choose browser/social login, and complete local callback or code paste when prompted.",
1164                ),
1165                _ => unreachable!(),
1166            };
1167        eprintln!(
1168            "[auth] launching {provider}: {}",
1169            format_command(&bin, &args)
1170        );
1171        eprintln!("[auth] {provider}: {instruction}");
1172        let result = run_interactive_auth_command(
1173            &bin,
1174            &args,
1175            &resolved.cwd,
1176            resolved.raw.auth_timeout * 1000,
1177            !resolved.raw.no_auth_open_browser,
1178            &provider,
1179        );
1180        if result.code.unwrap_or(1) != 0 {
1181            return Err(format!(
1182                "{provider} social login failed: {}",
1183                compact_failure(&result)
1184            ));
1185        }
1186        let status = provider_auth_status(&provider, resolved);
1187        eprintln!("[auth] {provider}: {} ({})", status.status, status.detail);
1188    }
1189    Ok(())
1190}
1191
1192fn run_interactive_auth_command(
1193    command: &str,
1194    args: &[String],
1195    cwd: &Path,
1196    timeout_ms: u64,
1197    open_browser: bool,
1198    provider: &str,
1199) -> CommandResult {
1200    let started = Instant::now();
1201    let mut child = match Command::new(command)
1202        .args(args)
1203        .current_dir(cwd)
1204        .stdin(Stdio::inherit())
1205        .stdout(Stdio::piped())
1206        .stderr(Stdio::piped())
1207        .spawn()
1208    {
1209        Ok(child) => child,
1210        Err(error) => {
1211            return CommandResult {
1212                command: command.to_string(),
1213                args: args.to_vec(),
1214                code: None,
1215                stdout: String::new(),
1216                stderr: String::new(),
1217                timed_out: false,
1218                error: Some(error.to_string()),
1219                timeout_ms,
1220                duration_ms: started.elapsed().as_millis(),
1221            }
1222        }
1223    };
1224
1225    let seen_urls = Arc::new(Mutex::new(HashSet::new()));
1226    let stdout = child.stdout.take().map(|pipe| {
1227        read_auth_pipe(
1228            pipe,
1229            true,
1230            provider.to_string(),
1231            open_browser,
1232            Arc::clone(&seen_urls),
1233        )
1234    });
1235    let stderr = child.stderr.take().map(|pipe| {
1236        read_auth_pipe(
1237            pipe,
1238            false,
1239            provider.to_string(),
1240            open_browser,
1241            Arc::clone(&seen_urls),
1242        )
1243    });
1244    let timeout = Duration::from_millis(timeout_ms);
1245    let mut timed_out = false;
1246    let code;
1247    loop {
1248        match child.try_wait() {
1249            Ok(Some(status)) => {
1250                code = status.code();
1251                break;
1252            }
1253            Ok(None) => {
1254                if timeout_ms > 0 && started.elapsed() >= timeout {
1255                    timed_out = true;
1256                    let _ = child.kill();
1257                    let status = child.wait().ok();
1258                    code = status.and_then(|status| status.code());
1259                    break;
1260                }
1261                thread::sleep(Duration::from_millis(50));
1262            }
1263            Err(error) => {
1264                return CommandResult {
1265                    command: command.to_string(),
1266                    args: args.to_vec(),
1267                    code: None,
1268                    stdout: String::new(),
1269                    stderr: String::new(),
1270                    timed_out,
1271                    error: Some(error.to_string()),
1272                    timeout_ms,
1273                    duration_ms: started.elapsed().as_millis(),
1274                }
1275            }
1276        }
1277    }
1278
1279    CommandResult {
1280        command: command.to_string(),
1281        args: args.to_vec(),
1282        code,
1283        stdout: stdout
1284            .and_then(|handle| handle.join().ok())
1285            .unwrap_or_default(),
1286        stderr: stderr
1287            .and_then(|handle| handle.join().ok())
1288            .unwrap_or_default(),
1289        timed_out,
1290        error: None,
1291        timeout_ms,
1292        duration_ms: started.elapsed().as_millis(),
1293    }
1294}
1295
1296fn read_auth_pipe<R>(
1297    mut pipe: R,
1298    stdout: bool,
1299    provider: String,
1300    open_browser: bool,
1301    seen_urls: Arc<Mutex<HashSet<String>>>,
1302) -> thread::JoinHandle<String>
1303where
1304    R: Read + Send + 'static,
1305{
1306    thread::spawn(move || {
1307        let mut text = String::new();
1308        let mut buffer = [0u8; 4096];
1309        loop {
1310            let read = match pipe.read(&mut buffer) {
1311                Ok(0) => break,
1312                Ok(read) => read,
1313                Err(_) => break,
1314            };
1315            let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
1316            text.push_str(&chunk);
1317            if stdout {
1318                print!("{chunk}");
1319                let _ = io::stdout().flush();
1320            } else {
1321                eprint!("{chunk}");
1322                let _ = io::stderr().flush();
1323            }
1324            if open_browser {
1325                for url in extract_auth_urls(&chunk) {
1326                    let mut seen = seen_urls.lock().ok();
1327                    if seen.as_mut().is_some_and(|seen| !seen.insert(url.clone())) {
1328                        continue;
1329                    }
1330                    eprintln!("[auth] {provider}: opening {url}");
1331                    if let Err(error) = open_browser_url(&url) {
1332                        eprintln!("[auth] {provider}: failed to open {url}: {error}");
1333                    }
1334                }
1335            }
1336        }
1337        text
1338    })
1339}
1340
1341fn extract_auth_urls(text: &str) -> Vec<String> {
1342    text.split_whitespace()
1343        .map(|token| {
1344            token.trim_matches(|ch: char| {
1345                matches!(ch, '<' | '>' | '"' | '\'' | ')' | '(' | ',' | ';')
1346            })
1347        })
1348        .map(|token| token.trim_end_matches(['.', ':', ',', ';']))
1349        .filter(|token| {
1350            token.starts_with("http://")
1351                || token.starts_with("https://")
1352                || token.starts_with("codex://")
1353                || token.starts_with("openai://")
1354                || token.starts_with("claude://")
1355                || token.starts_with("anthropic://")
1356                || token.starts_with("gemini://")
1357                || token.starts_with("google://")
1358        })
1359        .map(ToString::to_string)
1360        .collect()
1361}
1362
1363fn open_browser_url(url: &str) -> Result<(), String> {
1364    let (command, args): (&str, Vec<String>) = if cfg!(target_os = "macos") {
1365        ("open", vec![url.to_string()])
1366    } else if cfg!(target_os = "windows") {
1367        (
1368            "cmd",
1369            vec![
1370                "/c".to_string(),
1371                "start".to_string(),
1372                "".to_string(),
1373                url.to_string(),
1374            ],
1375        )
1376    } else {
1377        ("xdg-open", vec![url.to_string()])
1378    };
1379    Command::new(command)
1380        .args(args)
1381        .stdin(Stdio::null())
1382        .stdout(Stdio::null())
1383        .stderr(Stdio::null())
1384        .spawn()
1385        .map(|mut child| {
1386            let _ = child.try_wait();
1387        })
1388        .map_err(|error| error.to_string())
1389}
1390
1391fn build_prompt_context(resolved: &ResolvedArgs) -> Result<PromptContext, String> {
1392    let mut prompt = resolved.prompt.trim().to_string();
1393    let mut sections = Vec::new();
1394    let mut commands = Vec::new();
1395
1396    for file in &resolved.raw.files {
1397        let path = if file.is_absolute() {
1398            file.clone()
1399        } else {
1400            resolved.cwd.join(file)
1401        };
1402        let content = fs::read_to_string(&path)
1403            .map_err(|error| format!("Failed to read tagged file {}: {error}", path.display()))?;
1404        sections.push(format!("--- file: {} ---\n{}", file.display(), content));
1405    }
1406
1407    for command in &resolved.raw.commands {
1408        let shell = if cfg!(windows) { "cmd" } else { "sh" };
1409        let args = if cfg!(windows) {
1410            vec!["/C".to_string(), command.clone()]
1411        } else {
1412            vec!["-lc".to_string(), command.clone()]
1413        };
1414        let result = run_command(
1415            shell,
1416            &args,
1417            &resolved.cwd,
1418            None,
1419            resolved.raw.timeout * 1000,
1420            HashMap::new(),
1421        );
1422        commands.push(command_telemetry(&result));
1423        sections.push(format!(
1424            "--- command: {} (exit {}) ---\nstdout:\n{}\nstderr:\n{}",
1425            command,
1426            result
1427                .code
1428                .map(|code| code.to_string())
1429                .unwrap_or_else(|| "unknown".to_string()),
1430            result.stdout.trim(),
1431            result.stderr.trim()
1432        ));
1433    }
1434
1435    if !sections.is_empty() {
1436        prompt.push_str("\n\nPrompt context:\n");
1437        prompt.push_str(&sections.join("\n\n"));
1438    }
1439    Ok(PromptContext { prompt, commands })
1440}
1441
1442fn run_amon_hen(
1443    resolved: &ResolvedArgs,
1444    query: String,
1445    prompt_commands: Vec<CommandTelemetry>,
1446) -> AmonHenResult {
1447    let workflow = build_workflow(resolved);
1448    let mut previous_iteration = Vec::new();
1449    let mut final_members = Vec::new();
1450
1451    for iteration in 1..=workflow.iterations {
1452        final_members = run_iteration(resolved, &query, &workflow, iteration, &previous_iteration);
1453        previous_iteration = final_members.clone();
1454    }
1455
1456    let successes = final_members
1457        .iter()
1458        .filter(|result| result.status == "ok")
1459        .cloned()
1460        .collect::<Vec<_>>();
1461    let summary = if successes.is_empty() {
1462        EngineResult {
1463            name: resolved.raw.summarizer.clone(),
1464            bin: None,
1465            status: "error".to_string(),
1466            duration_ms: 0,
1467            detail: "No Amon Hen member produced a response.".to_string(),
1468            exit_code: None,
1469            stdout: String::new(),
1470            stderr: String::new(),
1471            output: String::new(),
1472            command: String::new(),
1473            token_usage: token_usage("", ""),
1474            tool_calls: vec![],
1475            sub_agents: vec![],
1476            role: "summary".to_string(),
1477            iteration: workflow.iterations,
1478            total_iterations: workflow.iterations,
1479            team_size: 0,
1480        }
1481    } else {
1482        let summary_prompt =
1483            build_summary_prompt(&query, &successes, &workflow, resolved.raw.max_member_chars);
1484        let summarizer = pick_summarizer(resolved, &successes);
1485        let mut options = engine_options(
1486            resolved,
1487            &summarizer,
1488            summary_prompt,
1489            "summary",
1490            workflow.iterations,
1491            &workflow,
1492        );
1493        options.role = "summary".to_string();
1494        options.team_size = 0;
1495        run_engine(&summarizer, options)
1496    };
1497
1498    AmonHenResult {
1499        query,
1500        cwd: resolved.cwd.display().to_string(),
1501        members_requested: resolved.members.clone(),
1502        summarizer_requested: resolved.raw.summarizer.clone(),
1503        workflow,
1504        prompt_commands,
1505        members: final_members,
1506        summary,
1507    }
1508}
1509
1510fn build_workflow(resolved: &ResolvedArgs) -> Workflow {
1511    let mut teams = HashMap::new();
1512    for member in ENGINES {
1513        teams.insert(member.to_string(), resolved.raw.team_work);
1514    }
1515    if let Some(value) = resolved.raw.codex_sub_agents {
1516        teams.insert("codex".to_string(), value);
1517    }
1518    if let Some(value) = resolved.raw.claude_sub_agents {
1519        teams.insert("claude".to_string(), value);
1520    }
1521    if let Some(value) = resolved.raw.gemini_sub_agents {
1522        teams.insert("gemini".to_string(), value);
1523    }
1524    Workflow {
1525        handoff: resolved.raw.handoff,
1526        lead: resolved.raw.lead.clone(),
1527        planner: resolved.raw.planner.clone(),
1528        iterations: resolved.raw.iterations.max(1),
1529        team_work: resolved.raw.team_work,
1530        teams,
1531    }
1532}
1533
1534fn run_iteration(
1535    resolved: &ResolvedArgs,
1536    query: &str,
1537    workflow: &Workflow,
1538    iteration: usize,
1539    previous_iteration: &[EngineResult],
1540) -> Vec<EngineResult> {
1541    let ordered = execution_order(&resolved.members, workflow.planner.as_deref());
1542    if workflow.handoff {
1543        let mut results = Vec::new();
1544        for member in ordered {
1545            let role = role_for(&member, workflow);
1546            let prompt = build_member_prompt(
1547                query,
1548                &role,
1549                workflow,
1550                iteration,
1551                previous_iteration,
1552                &results,
1553                "",
1554            );
1555            let options = engine_options(resolved, &member, prompt, &role, iteration, workflow);
1556            results.push(run_engine(&member, options));
1557        }
1558        return results;
1559    }
1560
1561    let planner = workflow
1562        .planner
1563        .as_ref()
1564        .filter(|planner| ordered.contains(planner))
1565        .cloned();
1566    let mut results = Vec::new();
1567    let mut plan_output = String::new();
1568    if let Some(planner_name) = planner.as_ref() {
1569        let role = role_for(planner_name, workflow);
1570        let prompt = build_member_prompt(
1571            query,
1572            &role,
1573            workflow,
1574            iteration,
1575            previous_iteration,
1576            &[],
1577            "",
1578        );
1579        let options = engine_options(resolved, planner_name, prompt, &role, iteration, workflow);
1580        let result = run_engine(planner_name, options);
1581        if result.status == "ok" {
1582            plan_output = result.output.clone();
1583        }
1584        results.push(result);
1585    }
1586
1587    let (tx, rx) = mpsc::channel();
1588    let executors = ordered
1589        .into_iter()
1590        .filter(|member| Some(member) != planner.as_ref())
1591        .collect::<Vec<_>>();
1592    let resolved = Arc::new(resolved.clone());
1593    let workflow = Arc::new(workflow.clone());
1594    let previous_iteration = Arc::new(previous_iteration.to_vec());
1595    let plan_output = Arc::new(plan_output);
1596    for member in executors.clone() {
1597        let tx = tx.clone();
1598        let resolved = Arc::clone(&resolved);
1599        let workflow = Arc::clone(&workflow);
1600        let query = query.to_string();
1601        let previous_iteration = Arc::clone(&previous_iteration);
1602        let plan_output = Arc::clone(&plan_output);
1603        thread::spawn(move || {
1604            let role = role_for(&member, &workflow);
1605            let prompt = build_member_prompt(
1606                &query,
1607                &role,
1608                &workflow,
1609                iteration,
1610                previous_iteration.as_slice(),
1611                &[],
1612                &plan_output,
1613            );
1614            let options = engine_options(&resolved, &member, prompt, &role, iteration, &workflow);
1615            let _ = tx.send(run_engine(&member, options));
1616        });
1617    }
1618    drop(tx);
1619    let mut executor_results = rx.into_iter().collect::<Vec<_>>();
1620    executor_results.sort_by_key(|result| {
1621        executors
1622            .iter()
1623            .position(|member| member == &result.name)
1624            .unwrap_or(usize::MAX)
1625    });
1626    results.extend(executor_results);
1627    results
1628}
1629
1630fn engine_options(
1631    resolved: &ResolvedArgs,
1632    member: &str,
1633    prompt: String,
1634    role: &str,
1635    iteration: usize,
1636    workflow: &Workflow,
1637) -> EngineRunOptions {
1638    EngineRunOptions {
1639        prompt,
1640        cwd: resolved.cwd.clone(),
1641        timeout_ms: resolved.raw.timeout * 1000,
1642        effort: provider_effort(resolved, member),
1643        model: provider_model(resolved, member),
1644        permission: provider_permission(resolved, member),
1645        auth: provider_auth(resolved, member),
1646        capability: provider_capability(resolved, member),
1647        role: role.to_string(),
1648        iteration,
1649        total_iterations: workflow.iterations,
1650        team_size: *workflow.teams.get(member).unwrap_or(&DEFAULT_TEAM_SIZE),
1651        is_sub_agent: false,
1652    }
1653}
1654
1655fn provider_effort(resolved: &ResolvedArgs, member: &str) -> Option<String> {
1656    provider_option(
1657        member,
1658        &resolved.raw.codex_effort,
1659        &resolved.raw.claude_effort,
1660        &resolved.raw.gemini_effort,
1661    )
1662    .or_else(|| {
1663        resolved
1664            .raw
1665            .effort
1666            .map(|effort| effort.as_str().to_string())
1667    })
1668}
1669
1670fn provider_model(resolved: &ResolvedArgs, member: &str) -> Option<String> {
1671    provider_option(
1672        member,
1673        &resolved.raw.codex_model,
1674        &resolved.raw.claude_model,
1675        &resolved.raw.gemini_model,
1676    )
1677}
1678
1679fn provider_permission(resolved: &ResolvedArgs, member: &str) -> Option<String> {
1680    match Engine::parse(member) {
1681        Some(Engine::Codex) => Some(resolved.raw.codex_sandbox.clone()),
1682        Some(Engine::Claude) => Some(resolved.raw.claude_permission_mode.clone()),
1683        Some(Engine::Gemini) | None => None,
1684    }
1685}
1686
1687fn provider_auth(resolved: &ResolvedArgs, member: &str) -> String {
1688    match Engine::parse(member) {
1689        Some(Engine::Codex) => resolved.raw.codex_auth.clone(),
1690        Some(Engine::Claude) => resolved.raw.claude_auth.clone(),
1691        Some(Engine::Gemini) => resolved.raw.gemini_auth.clone(),
1692        None => DEFAULT_AUTH_MODE.to_string(),
1693    }
1694}
1695
1696fn provider_capability(resolved: &ResolvedArgs, member: &str) -> ProviderCapability {
1697    match Engine::parse(member).expect("provider capabilities use validated engines") {
1698        Engine::Codex => ProviderCapability {
1699            mode: inferred_capability_mode(
1700                &resolved.raw.codex_capabilities,
1701                !resolved.raw.codex_config.is_empty() || resolved.raw.codex_mcp_profile.is_some(),
1702            ),
1703            config: resolved.raw.codex_config.clone(),
1704            mcp_profile: resolved.raw.codex_mcp_profile.clone(),
1705            mcp_config: vec![],
1706            allowed_tools: vec![],
1707            disallowed_tools: vec![],
1708            tools: vec![],
1709            agent: None,
1710            agents_json: None,
1711            plugin_dirs: vec![],
1712            strict_mcp_config: false,
1713            disable_slash_commands: false,
1714            settings: None,
1715            tools_profile: vec![],
1716            allowed_mcp_servers: vec![],
1717            policy: vec![],
1718            admin_policy: vec![],
1719        },
1720        Engine::Claude => ProviderCapability {
1721            mode: inferred_capability_mode(
1722                &resolved.raw.claude_capabilities,
1723                !resolved.raw.claude_mcp_config.is_empty()
1724                    || !resolved.raw.claude_allowed_tools.is_empty()
1725                    || !resolved.raw.claude_disallowed_tools.is_empty()
1726                    || !resolved.raw.claude_tools.is_empty()
1727                    || resolved.raw.claude_agent.is_some()
1728                    || resolved.raw.claude_agents_json.is_some()
1729                    || !resolved.raw.claude_plugin_dir.is_empty()
1730                    || resolved.raw.claude_strict_mcp_config
1731                    || resolved.raw.claude_disable_slash_commands,
1732            ),
1733            config: vec![],
1734            mcp_profile: None,
1735            mcp_config: resolved.raw.claude_mcp_config.clone(),
1736            allowed_tools: resolved.raw.claude_allowed_tools.clone(),
1737            disallowed_tools: resolved.raw.claude_disallowed_tools.clone(),
1738            tools: resolved.raw.claude_tools.clone(),
1739            agent: resolved.raw.claude_agent.clone(),
1740            agents_json: resolved.raw.claude_agents_json.clone(),
1741            plugin_dirs: resolved.raw.claude_plugin_dir.clone(),
1742            strict_mcp_config: resolved.raw.claude_strict_mcp_config,
1743            disable_slash_commands: resolved.raw.claude_disable_slash_commands,
1744            settings: None,
1745            tools_profile: vec![],
1746            allowed_mcp_servers: vec![],
1747            policy: vec![],
1748            admin_policy: vec![],
1749        },
1750        Engine::Gemini => ProviderCapability {
1751            mode: inferred_capability_mode(
1752                &resolved.raw.gemini_capabilities,
1753                resolved.raw.gemini_settings.is_some()
1754                    || !resolved.raw.gemini_tools_profile.is_empty()
1755                    || !resolved.raw.gemini_allowed_mcp_servers.is_empty()
1756                    || !resolved.raw.gemini_policy.is_empty()
1757                    || !resolved.raw.gemini_admin_policy.is_empty(),
1758            ),
1759            config: vec![],
1760            mcp_profile: None,
1761            mcp_config: vec![],
1762            allowed_tools: vec![],
1763            disallowed_tools: vec![],
1764            tools: vec![],
1765            agent: None,
1766            agents_json: None,
1767            plugin_dirs: vec![],
1768            strict_mcp_config: false,
1769            disable_slash_commands: false,
1770            settings: resolved.raw.gemini_settings.clone(),
1771            tools_profile: resolved.raw.gemini_tools_profile.clone(),
1772            allowed_mcp_servers: resolved.raw.gemini_allowed_mcp_servers.clone(),
1773            policy: resolved.raw.gemini_policy.clone(),
1774            admin_policy: resolved.raw.gemini_admin_policy.clone(),
1775        },
1776    }
1777}
1778
1779fn inferred_capability_mode(configured: &str, has_override_flags: bool) -> String {
1780    if configured == CAPABILITY_INHERIT && has_override_flags {
1781        CAPABILITY_OVERRIDE.to_string()
1782    } else {
1783        configured.to_string()
1784    }
1785}
1786
1787fn provider_option(
1788    member: &str,
1789    codex: &Option<String>,
1790    claude: &Option<String>,
1791    gemini: &Option<String>,
1792) -> Option<String> {
1793    match Engine::parse(member) {
1794        Some(Engine::Codex) => codex.clone(),
1795        Some(Engine::Claude) => claude.clone(),
1796        Some(Engine::Gemini) => gemini.clone(),
1797        None => None,
1798    }
1799}
1800
1801fn build_sub_agent_prompt(original: &str, role: &str, index: usize, total: usize) -> String {
1802    format!(
1803        "You are sub-agent {index} of {total} for an Amon Hen provider assigned role `{role}`.\n\
1804         Work independently on a useful slice of the task. Inspect, reason, or verify as needed, \
1805         then return concise findings, risks, and concrete recommendations for the provider lead.\n\n\
1806         Original provider prompt:\n{original}"
1807    )
1808}
1809
1810fn build_team_lead_prompt(original: &str, sub_agents: &[EngineResult]) -> String {
1811    let handoff = sub_agents
1812        .iter()
1813        .map(|agent| {
1814            format!(
1815                "### {} [{}]\n{}",
1816                agent.role,
1817                agent.status,
1818                if agent.output.trim().is_empty() {
1819                    agent.detail.trim()
1820                } else {
1821                    agent.output.trim()
1822                }
1823            )
1824        })
1825        .collect::<Vec<_>>()
1826        .join("\n\n");
1827    format!(
1828        "You are the provider lead. Use the sub-agent handoffs below, resolve disagreements, \
1829         and produce the final provider response for the original Amon Hen role.\n\n\
1830         Sub-agent handoffs:\n{handoff}\n\nOriginal provider prompt:\n{original}"
1831    )
1832}
1833
1834fn run_engine(name: &str, options: EngineRunOptions) -> EngineResult {
1835    if options.team_size > 0 && options.role != "summary" && !options.is_sub_agent {
1836        return run_engine_team(name, options);
1837    }
1838    run_engine_single(name, options, vec![])
1839}
1840
1841fn run_engine_team(name: &str, options: EngineRunOptions) -> EngineResult {
1842    let team_size = options.team_size;
1843    let (tx, rx) = mpsc::channel();
1844    for index in 1..=team_size {
1845        let tx = tx.clone();
1846        let name = name.to_string();
1847        let mut sub_options = options.clone();
1848        sub_options.team_size = 0;
1849        sub_options.is_sub_agent = true;
1850        sub_options.role = format!("{}:sub-agent-{index}", options.role);
1851        sub_options.prompt =
1852            build_sub_agent_prompt(&options.prompt, &options.role, index, team_size);
1853        thread::spawn(move || {
1854            let _ = tx.send((index, run_engine_single(&name, sub_options, vec![])));
1855        });
1856    }
1857    drop(tx);
1858
1859    let mut indexed = rx.into_iter().collect::<Vec<_>>();
1860    indexed.sort_by_key(|(index, _)| *index);
1861    let sub_agents = indexed
1862        .into_iter()
1863        .map(|(_, result)| result)
1864        .collect::<Vec<_>>();
1865    let mut lead_options = options.clone();
1866    lead_options.prompt = build_team_lead_prompt(&options.prompt, &sub_agents);
1867    lead_options.is_sub_agent = true;
1868    run_engine_single(name, lead_options, sub_agents)
1869}
1870
1871fn run_engine_single(
1872    name: &str,
1873    options: EngineRunOptions,
1874    sub_agents: Vec<EngineResult>,
1875) -> EngineResult {
1876    let started = Instant::now();
1877    let bin = resolve_binary(name);
1878    let result = match Engine::parse(name) {
1879        Some(Engine::Codex) => run_codex(&bin, &options),
1880        Some(Engine::Claude) => run_claude(&bin, &options),
1881        Some(Engine::Gemini) => run_gemini(&bin, &options),
1882        None => CommandResult {
1883            command: name.to_string(),
1884            args: vec![],
1885            code: None,
1886            stdout: String::new(),
1887            stderr: String::new(),
1888            timed_out: false,
1889            error: Some(format!("Unknown engine: {name}")),
1890            timeout_ms: options.timeout_ms,
1891            duration_ms: started.elapsed().as_millis(),
1892        },
1893    };
1894    finalize_engine(
1895        name,
1896        &bin,
1897        started.elapsed().as_millis(),
1898        result,
1899        options,
1900        sub_agents,
1901    )
1902}
1903
1904fn push_arg(args: &mut Vec<String>, flag: &str, value: impl Into<String>) {
1905    args.push(flag.to_string());
1906    args.push(value.into());
1907}
1908
1909fn push_optional_arg(args: &mut Vec<String>, flag: &str, value: &Option<String>) {
1910    if let Some(value) = value {
1911        push_arg(args, flag, value.clone());
1912    }
1913}
1914
1915fn push_repeated_flag(args: &mut Vec<String>, flag: &str, values: &[String]) {
1916    if !values.is_empty() {
1917        args.push(flag.to_string());
1918        args.extend(values.iter().cloned());
1919    }
1920}
1921
1922fn push_each_arg(args: &mut Vec<String>, flag: &str, values: &[String]) {
1923    for value in values {
1924        push_arg(args, flag, value.clone());
1925    }
1926}
1927
1928fn run_codex(bin: &str, options: &EngineRunOptions) -> CommandResult {
1929    let temp = match tempfile::tempdir() {
1930        Ok(temp) => temp,
1931        Err(error) => {
1932            return CommandResult {
1933                command: bin.to_string(),
1934                args: vec![],
1935                code: None,
1936                stdout: String::new(),
1937                stderr: String::new(),
1938                timed_out: false,
1939                error: Some(error.to_string()),
1940                timeout_ms: options.timeout_ms,
1941                duration_ms: 0,
1942            }
1943        }
1944    };
1945    let output_path = temp.path().join("last-message.txt");
1946    let mut args = vec!["exec".to_string()];
1947    push_optional_arg(&mut args, "--model", &options.model);
1948    if let Some(effort) = &options.effort {
1949        push_arg(&mut args, "-c", format!("model_reasoning_effort={effort}"));
1950    }
1951    if options.capability.mode == CAPABILITY_OVERRIDE {
1952        for config in &options.capability.config {
1953            push_arg(&mut args, "-c", config.clone());
1954        }
1955        push_optional_arg(&mut args, "--profile", &options.capability.mcp_profile);
1956    }
1957    args.extend([
1958        "--skip-git-repo-check".to_string(),
1959        "--sandbox".to_string(),
1960        options
1961            .permission
1962            .clone()
1963            .unwrap_or_else(|| "read-only".to_string()),
1964        "--ephemeral".to_string(),
1965        "--json".to_string(),
1966        "-o".to_string(),
1967        output_path.display().to_string(),
1968        "-".to_string(),
1969    ]);
1970    let mut result = run_command(
1971        bin,
1972        &args,
1973        &options.cwd,
1974        Some(&options.prompt),
1975        options.timeout_ms,
1976        HashMap::new(),
1977    );
1978    if let Ok(output) = fs::read_to_string(output_path) {
1979        if !output.trim().is_empty() {
1980            result.stdout = output;
1981        }
1982    }
1983    result
1984}
1985
1986fn run_claude(bin: &str, options: &EngineRunOptions) -> CommandResult {
1987    let mut args = Vec::new();
1988    if should_use_claude_bare_mode(&options.auth) {
1989        args.push("--bare".to_string());
1990    }
1991    args.extend([
1992        "-p".to_string(),
1993        "--permission-mode".to_string(),
1994        options
1995            .permission
1996            .clone()
1997            .unwrap_or_else(|| "plan".to_string()),
1998        "--verbose".to_string(),
1999        "--output-format".to_string(),
2000        "stream-json".to_string(),
2001        "--include-partial-messages".to_string(),
2002        "--no-session-persistence".to_string(),
2003    ]);
2004    push_optional_arg(&mut args, "--model", &options.model);
2005    if let Some(effort) = options
2006        .effort
2007        .clone()
2008        .or_else(|| std::env::var("CLAUDE_CODE_EFFORT_LEVEL").ok())
2009    {
2010        push_arg(&mut args, "--effort", effort);
2011    }
2012    if options.capability.mode == CAPABILITY_OVERRIDE {
2013        push_repeated_flag(&mut args, "--mcp-config", &options.capability.mcp_config);
2014        push_repeated_flag(
2015            &mut args,
2016            "--allowedTools",
2017            &options.capability.allowed_tools,
2018        );
2019        push_repeated_flag(
2020            &mut args,
2021            "--disallowedTools",
2022            &options.capability.disallowed_tools,
2023        );
2024        push_repeated_flag(&mut args, "--tools", &options.capability.tools);
2025        push_optional_arg(&mut args, "--agent", &options.capability.agent);
2026        push_optional_arg(&mut args, "--agents", &options.capability.agents_json);
2027        push_each_arg(&mut args, "--plugin-dir", &options.capability.plugin_dirs);
2028        if options.capability.strict_mcp_config {
2029            args.push("--strict-mcp-config".to_string());
2030        }
2031        if options.capability.disable_slash_commands {
2032            args.push("--disable-slash-commands".to_string());
2033        }
2034    }
2035    run_command(
2036        bin,
2037        &args,
2038        &options.cwd,
2039        Some(&options.prompt),
2040        options.timeout_ms,
2041        HashMap::new(),
2042    )
2043}
2044
2045fn run_gemini(bin: &str, options: &EngineRunOptions) -> CommandResult {
2046    let mut args = Vec::new();
2047    push_optional_arg(&mut args, "--model", &options.model);
2048    if options.capability.mode == CAPABILITY_OVERRIDE {
2049        push_repeated_flag(&mut args, "--extensions", &options.capability.tools_profile);
2050        push_repeated_flag(
2051            &mut args,
2052            "--allowed-mcp-server-names",
2053            &options.capability.allowed_mcp_servers,
2054        );
2055        push_repeated_flag(&mut args, "--policy", &options.capability.policy);
2056        push_repeated_flag(
2057            &mut args,
2058            "--admin-policy",
2059            &options.capability.admin_policy,
2060        );
2061    }
2062    args.extend([
2063        "-p".to_string(),
2064        options.prompt.clone(),
2065        "--skip-trust".to_string(),
2066        "--approval-mode".to_string(),
2067        "plan".to_string(),
2068        "--output-format".to_string(),
2069        "json".to_string(),
2070    ]);
2071    let mut envs = HashMap::new();
2072    let effort_settings = prepare_gemini_settings(options);
2073    if let Some(path) = effort_settings.as_ref() {
2074        envs.insert(
2075            "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2076            path.path.display().to_string(),
2077        );
2078    } else if options.capability.mode == CAPABILITY_OVERRIDE {
2079        if let Some(settings) = &options.capability.settings {
2080            envs.insert(
2081                "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2082                settings.clone(),
2083            );
2084        }
2085    }
2086    run_command(bin, &args, &options.cwd, None, options.timeout_ms, envs)
2087}
2088
2089fn prepare_gemini_settings(options: &EngineRunOptions) -> Option<TempSettings> {
2090    let effort = options.effort.as_deref()?;
2091    let budget = match effort {
2092        "low" => 1024,
2093        "medium" => 8192,
2094        "high" => 24576,
2095        _ => return None,
2096    };
2097    let dir = tempfile::tempdir().ok()?;
2098    let path = dir.path().join("settings.json");
2099    let mut settings = if options.capability.mode == CAPABILITY_OVERRIDE {
2100        options
2101            .capability
2102            .settings
2103            .as_ref()
2104            .and_then(|path| fs::read_to_string(path).ok())
2105            .and_then(|text| serde_json::from_str::<Value>(&text).ok())
2106            .unwrap_or_else(|| Value::Object(Default::default()))
2107    } else {
2108        Value::Object(Default::default())
2109    };
2110    if let Value::Object(object) = &mut settings {
2111        object.insert("thinkingBudget".to_string(), Value::from(budget));
2112    }
2113    let _ = fs::write(&path, serde_json::to_vec(&settings).ok()?);
2114    Some(TempSettings { _dir: dir, path })
2115}
2116
2117fn should_use_claude_bare_mode(auth: &str) -> bool {
2118    if auth == "api-key" {
2119        return true;
2120    }
2121    if matches!(auth, "social-login" | "oauth" | "keychain") {
2122        return false;
2123    }
2124    if std::env::var("CLAUDE_CODE_OAUTH_TOKEN")
2125        .ok()
2126        .filter(|value| !value.trim().is_empty())
2127        .is_some()
2128    {
2129        return false;
2130    }
2131    std::env::var("ANTHROPIC_API_KEY")
2132        .ok()
2133        .filter(|value| !value.trim().is_empty())
2134        .is_some()
2135}
2136
2137fn run_command(
2138    command: &str,
2139    args: &[String],
2140    cwd: &Path,
2141    stdin_text: Option<&str>,
2142    timeout_ms: u64,
2143    envs: HashMap<String, String>,
2144) -> CommandResult {
2145    let started = Instant::now();
2146    let mut child = match Command::new(command)
2147        .args(args)
2148        .current_dir(cwd)
2149        .envs(envs)
2150        .stdin(Stdio::piped())
2151        .stdout(Stdio::piped())
2152        .stderr(Stdio::piped())
2153        .spawn()
2154    {
2155        Ok(child) => child,
2156        Err(error) => {
2157            return CommandResult {
2158                command: command.to_string(),
2159                args: args.to_vec(),
2160                code: None,
2161                stdout: String::new(),
2162                stderr: String::new(),
2163                timed_out: false,
2164                error: Some(error.to_string()),
2165                timeout_ms,
2166                duration_ms: started.elapsed().as_millis(),
2167            }
2168        }
2169    };
2170
2171    if let Some(mut stdin) = child.stdin.take() {
2172        if let Some(text) = stdin_text {
2173            let _ = stdin.write_all(text.as_bytes());
2174        }
2175    }
2176
2177    let stdout = child.stdout.take().map(read_pipe);
2178    let stderr = child.stderr.take().map(read_pipe);
2179    let timeout = Duration::from_millis(timeout_ms);
2180    let mut timed_out = false;
2181    let code;
2182    loop {
2183        match child.try_wait() {
2184            Ok(Some(status)) => {
2185                code = status.code();
2186                break;
2187            }
2188            Ok(None) => {
2189                if timeout_ms > 0 && started.elapsed() >= timeout {
2190                    timed_out = true;
2191                    let _ = child.kill();
2192                    let status = child.wait().ok();
2193                    code = status.and_then(|status| status.code());
2194                    break;
2195                }
2196                thread::sleep(Duration::from_millis(25));
2197            }
2198            Err(error) => {
2199                return CommandResult {
2200                    command: command.to_string(),
2201                    args: args.to_vec(),
2202                    code: None,
2203                    stdout: String::new(),
2204                    stderr: String::new(),
2205                    timed_out,
2206                    error: Some(error.to_string()),
2207                    timeout_ms,
2208                    duration_ms: started.elapsed().as_millis(),
2209                }
2210            }
2211        }
2212    }
2213
2214    CommandResult {
2215        command: command.to_string(),
2216        args: args.to_vec(),
2217        code,
2218        stdout: stdout
2219            .and_then(|handle| handle.join().ok())
2220            .unwrap_or_default(),
2221        stderr: stderr
2222            .and_then(|handle| handle.join().ok())
2223            .unwrap_or_default(),
2224        timed_out,
2225        error: None,
2226        timeout_ms,
2227        duration_ms: started.elapsed().as_millis(),
2228    }
2229}
2230
2231fn read_pipe<R>(mut pipe: R) -> thread::JoinHandle<String>
2232where
2233    R: Read + Send + 'static,
2234{
2235    thread::spawn(move || {
2236        let mut text = String::new();
2237        let _ = pipe.read_to_string(&mut text);
2238        text
2239    })
2240}
2241
2242fn finalize_engine(
2243    name: &str,
2244    bin: &str,
2245    duration_ms: u128,
2246    command_result: CommandResult,
2247    options: EngineRunOptions,
2248    sub_agents: Vec<EngineResult>,
2249) -> EngineResult {
2250    let output = match Engine::parse(name) {
2251        Some(Engine::Claude) => parse_claude_output(&command_result.stdout),
2252        Some(Engine::Gemini) => parse_gemini_output(&command_result.stdout),
2253        Some(Engine::Codex) => parse_codex_output(&command_result.stdout),
2254        None => command_result.stdout.trim().to_string(),
2255    };
2256    let status = if command_result.error.is_some() {
2257        "missing"
2258    } else if command_result.timed_out {
2259        "timeout"
2260    } else if command_result.code != Some(0) || output.trim().is_empty() {
2261        "error"
2262    } else {
2263        "ok"
2264    };
2265    let detail = if status == "ok" {
2266        String::new()
2267    } else if command_result.timed_out {
2268        format!("Timed out after {}s.", command_result.timeout_ms / 1000)
2269    } else {
2270        compact_failure(&command_result)
2271    };
2272    let usage = aggregate_token_usage(
2273        extract_token_usage(&command_result.stdout, &options.prompt, &output)
2274            .unwrap_or_else(|| token_usage(&options.prompt, &output)),
2275        &sub_agents,
2276    );
2277    let tool_calls = extract_tool_usage(name, &command_result.stdout, &command_result.stderr);
2278    EngineResult {
2279        name: name.to_string(),
2280        bin: Some(bin.to_string()),
2281        status: status.to_string(),
2282        duration_ms,
2283        detail,
2284        exit_code: command_result.code,
2285        stdout: command_result.stdout,
2286        stderr: command_result.stderr,
2287        output,
2288        command: format_command(&command_result.command, &command_result.args),
2289        token_usage: usage,
2290        tool_calls,
2291        sub_agents,
2292        role: options.role,
2293        iteration: options.iteration,
2294        total_iterations: options.total_iterations,
2295        team_size: options.team_size,
2296    }
2297}
2298
2299fn compact_failure(result: &CommandResult) -> String {
2300    if let Some(error) = &result.error {
2301        return error.clone();
2302    }
2303    if !result.stderr.trim().is_empty() {
2304        return result.stderr.trim().to_string();
2305    }
2306    if !result.stdout.trim().is_empty() {
2307        return result.stdout.trim().to_string();
2308    }
2309    result
2310        .code
2311        .map(|code| format!("Exited with code {code}."))
2312        .unwrap_or_else(|| "Command failed.".to_string())
2313}
2314
2315fn command_telemetry(result: &CommandResult) -> CommandTelemetry {
2316    let status = if result.error.is_some() {
2317        "missing"
2318    } else if result.timed_out {
2319        "timeout"
2320    } else if result.code == Some(0) {
2321        "ok"
2322    } else {
2323        "error"
2324    };
2325    CommandTelemetry {
2326        command: format_command(&result.command, &result.args),
2327        status: status.to_string(),
2328        detail: truncate(&sanitize_status_detail(&compact_failure(result)), 600),
2329        exit_code: result.code,
2330        duration_ms: result.duration_ms,
2331        stdout_chars: result.stdout.len(),
2332        stderr_chars: result.stderr.len(),
2333        timed_out: result.timed_out,
2334    }
2335}
2336
2337fn parse_codex_output(stdout: &str) -> String {
2338    stdout.trim().to_string()
2339}
2340
2341fn parse_claude_output(stdout: &str) -> String {
2342    let trimmed = stdout.trim();
2343    if trimmed.is_empty() {
2344        return String::new();
2345    }
2346    if let Some(value) = extract_json(trimmed) {
2347        if let Some(result) = value.get("result").and_then(Value::as_str) {
2348            return result.trim().to_string();
2349        }
2350    }
2351    let mut latest = String::new();
2352    for line in trimmed.lines() {
2353        let Ok(value) = serde_json::from_str::<Value>(line) else {
2354            continue;
2355        };
2356        if value.get("type").and_then(Value::as_str) == Some("result") {
2357            if let Some(result) = value.get("result").and_then(Value::as_str) {
2358                latest = result.trim().to_string();
2359            }
2360        }
2361        if value.get("type").and_then(Value::as_str) == Some("assistant") {
2362            if let Some(content) = value.pointer("/message/content").and_then(Value::as_array) {
2363                let text = content
2364                    .iter()
2365                    .filter_map(|block| {
2366                        (block.get("type").and_then(Value::as_str) == Some("text"))
2367                            .then(|| block.get("text").and_then(Value::as_str))
2368                            .flatten()
2369                    })
2370                    .collect::<String>();
2371                if !text.trim().is_empty() {
2372                    latest = text.trim().to_string();
2373                }
2374            }
2375        }
2376    }
2377    if latest.is_empty() {
2378        trimmed.to_string()
2379    } else {
2380        latest
2381    }
2382}
2383
2384fn parse_gemini_output(stdout: &str) -> String {
2385    let trimmed = stdout.trim();
2386    if trimmed.is_empty() {
2387        return String::new();
2388    }
2389    if let Some(value) = extract_json(trimmed) {
2390        if let Some(response) = value.get("response").and_then(Value::as_str) {
2391            return response.trim().to_string();
2392        }
2393    }
2394    trimmed.to_string()
2395}
2396
2397fn extract_json(text: &str) -> Option<Value> {
2398    serde_json::from_str::<Value>(text).ok().or_else(|| {
2399        let start = text.find('{')?;
2400        let end = text.rfind('}')?;
2401        serde_json::from_str::<Value>(&text[start..=end]).ok()
2402    })
2403}
2404
2405fn build_member_prompt(
2406    query: &str,
2407    role: &str,
2408    workflow: &Workflow,
2409    iteration: usize,
2410    previous_iteration: &[EngineResult],
2411    handoff_results: &[EngineResult],
2412    plan_output: &str,
2413) -> String {
2414    let mut sections = vec![
2415        "You are one member of a multi-model Amon Hen run.".to_string(),
2416        format!(
2417            "Amon Hen workflow: iteration {iteration} of {}.",
2418            workflow.iterations
2419        ),
2420        workflow
2421            .lead
2422            .as_ref()
2423            .map(|lead| format!("Lead model: {lead}."))
2424            .unwrap_or_else(|| "Lead model: auto.".to_string()),
2425        workflow
2426            .planner
2427            .as_ref()
2428            .map(|planner| format!("Planner model: {planner}."))
2429            .unwrap_or_else(|| "Planner model: none.".to_string()),
2430        format!("Your assigned role: {role}."),
2431        role_instruction(role).to_string(),
2432        "Answer the user query directly.".to_string(),
2433        "Do not introduce yourself.".to_string(),
2434        "Do not describe your tools, environment, or capabilities unless the user explicitly asks."
2435            .to_string(),
2436        "Be concise unless the user asks for depth.".to_string(),
2437    ];
2438    if workflow.team_work > 0 {
2439        sections.push(format!(
2440            "Team work: you may coordinate up to {} internal sub-agents or subtasks inside your own CLI if that helps.",
2441            workflow.team_work
2442        ));
2443    }
2444    if workflow.handoff {
2445        sections.push(
2446            "Handoff mode is enabled. Treat earlier Amon Hen outputs as working context."
2447                .to_string(),
2448        );
2449    }
2450    if !plan_output.trim().is_empty() {
2451        sections.push(format!("Planner handoff:\n{}", plan_output.trim()));
2452    }
2453    let context = previous_iteration
2454        .iter()
2455        .chain(handoff_results.iter())
2456        .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
2457        .map(|result| format!("### {}\n{}", result.name, result.output.trim()))
2458        .collect::<Vec<_>>()
2459        .join("\n\n");
2460    if !context.is_empty() {
2461        sections.push(format!("Earlier Amon Hen handoffs:\n{context}"));
2462    }
2463    sections.push(format!("Current user query:\n{}", query.trim()));
2464    sections.join("\n\n")
2465}
2466
2467fn build_summary_prompt(
2468    query: &str,
2469    responses: &[EngineResult],
2470    workflow: &Workflow,
2471    max_member_chars: usize,
2472) -> String {
2473    let blocks = responses
2474        .iter()
2475        .map(|response| {
2476            let output = truncate(&response.output, max_member_chars);
2477            format!("### {}\n{}", response.name, output.trim())
2478        })
2479        .collect::<Vec<_>>()
2480        .join("\n\n");
2481    format!(
2482        "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{}",
2483        workflow.iterations,
2484        if workflow.iterations == 1 { "" } else { "s" },
2485        if workflow.handoff { "handoff enabled" } else { "parallel consultation" },
2486        workflow.lead.as_deref().unwrap_or("auto"),
2487        workflow.planner.as_deref().unwrap_or("none"),
2488        query.trim(),
2489        blocks
2490    )
2491}
2492
2493fn role_instruction(role: &str) -> &'static str {
2494    match role {
2495        "planner" => "Plan the work: identify the approach, risks, checkpoints, and useful handoffs for the executors.",
2496        "lead" => "Lead the work: make the strongest direct attempt while watching for conflicts you may need to resolve in synthesis.",
2497        "lead+planner" => "Plan and lead the work: produce a practical plan, then make the strongest direct attempt from that plan.",
2498        _ => "Execute the work: use any plan or handoff context, then produce your independent best answer.",
2499    }
2500}
2501
2502fn execution_order(members: &[String], planner: Option<&str>) -> Vec<String> {
2503    let mut ordered = Vec::new();
2504    if let Some(planner) = planner {
2505        if members.iter().any(|member| member == planner) {
2506            ordered.push(planner.to_string());
2507        }
2508    }
2509    ordered.extend(
2510        members
2511            .iter()
2512            .filter(|member| Some(member.as_str()) != planner)
2513            .cloned(),
2514    );
2515    ordered
2516}
2517
2518fn role_for(member: &str, workflow: &Workflow) -> String {
2519    let is_lead = workflow.lead.as_deref() == Some(member);
2520    let is_planner = workflow.planner.as_deref() == Some(member);
2521    match (is_lead, is_planner) {
2522        (true, true) => "lead+planner",
2523        (true, false) => "lead",
2524        (false, true) => "planner",
2525        (false, false) => "executor",
2526    }
2527    .to_string()
2528}
2529
2530fn pick_summarizer(resolved: &ResolvedArgs, successes: &[EngineResult]) -> String {
2531    if resolved.raw.summarizer != "auto" {
2532        return resolved.raw.summarizer.clone();
2533    }
2534    if let Some(lead) = &resolved.raw.lead {
2535        if successes.iter().any(|result| &result.name == lead) {
2536            return lead.clone();
2537        }
2538    }
2539    DEFAULT_SUMMARIZER_ORDER
2540        .iter()
2541        .find(|name| successes.iter().any(|result| result.name == **name))
2542        .unwrap_or(&successes[0].name.as_str())
2543        .to_string()
2544}
2545
2546fn resolve_binary(name: &str) -> String {
2547    let Some(engine) = Engine::parse(name) else {
2548        return name.to_string();
2549    };
2550    std::env::var(engine.binary_env_var())
2551        .ok()
2552        .filter(|value| !value.trim().is_empty())
2553        .unwrap_or_else(|| engine.as_str().to_string())
2554}
2555
2556fn token_usage(prompt: &str, output: &str) -> TokenUsage {
2557    let input = estimate_tokens(prompt);
2558    let output = estimate_tokens(output);
2559    TokenUsage {
2560        input,
2561        output,
2562        total: input + output,
2563        estimated: true,
2564        source: "estimate".to_string(),
2565    }
2566}
2567
2568fn aggregate_token_usage(mut usage: TokenUsage, sub_agents: &[EngineResult]) -> TokenUsage {
2569    if sub_agents.is_empty() {
2570        return usage;
2571    }
2572    for agent in sub_agents {
2573        usage.input += agent.token_usage.input;
2574        usage.output += agent.token_usage.output;
2575        usage.total += agent.token_usage.total;
2576        usage.estimated |= agent.token_usage.estimated;
2577    }
2578    usage.source = format!("{}+sub-agents", usage.source);
2579    usage
2580}
2581
2582fn extract_token_usage(stdout: &str, prompt: &str, output: &str) -> Option<TokenUsage> {
2583    let mut usage = TokenAccumulator::default();
2584    for value in json_values(stdout) {
2585        collect_token_counts(&value, &mut usage);
2586    }
2587    if usage.input.is_none() && usage.output.is_none() && usage.total.is_none() {
2588        return None;
2589    }
2590    let input = usage.input.unwrap_or_else(|| estimate_tokens(prompt));
2591    let output = usage.output.unwrap_or_else(|| estimate_tokens(output));
2592    let total = usage.total.unwrap_or(input + output);
2593    Some(TokenUsage {
2594        input,
2595        output,
2596        total,
2597        estimated: usage.input.is_none() || usage.output.is_none(),
2598        source: "provider".to_string(),
2599    })
2600}
2601
2602#[derive(Default)]
2603struct TokenAccumulator {
2604    input: Option<usize>,
2605    output: Option<usize>,
2606    total: Option<usize>,
2607}
2608
2609fn collect_token_counts(value: &Value, usage: &mut TokenAccumulator) {
2610    match value {
2611        Value::Object(map) => {
2612            for (key, value) in map {
2613                let normalized = key.replace(['_', '-'], "").to_ascii_lowercase();
2614                if let Some(count) = value.as_u64().map(|value| value as usize) {
2615                    match normalized.as_str() {
2616                        "inputtokens" | "inputtokencount" | "prompttokens" | "prompttokencount" => {
2617                            usage.input = Some(count)
2618                        }
2619                        "outputtokens"
2620                        | "outputtokencount"
2621                        | "completiontokens"
2622                        | "candidatestokencount" => usage.output = Some(count),
2623                        "totaltokens" | "totaltokencount" => usage.total = Some(count),
2624                        _ => {}
2625                    }
2626                }
2627                collect_token_counts(value, usage);
2628            }
2629        }
2630        Value::Array(values) => {
2631            for value in values {
2632                collect_token_counts(value, usage);
2633            }
2634        }
2635        _ => {}
2636    }
2637}
2638
2639fn extract_tool_usage(provider: &str, stdout: &str, stderr: &str) -> Vec<ToolUsage> {
2640    let mut tools = Vec::new();
2641    for value in json_values(stdout) {
2642        collect_tool_usage(&value, &mut tools);
2643    }
2644    for line in stdout.lines().chain(stderr.lines()) {
2645        if let Some(tool) = line_tool_usage(provider, line) {
2646            tools.push(tool);
2647        }
2648    }
2649    dedupe_tools(tools)
2650}
2651
2652fn collect_tool_usage(value: &Value, tools: &mut Vec<ToolUsage>) {
2653    match value {
2654        Value::Object(map) => {
2655            let type_name = map.get("type").and_then(Value::as_str).unwrap_or_default();
2656            let name = map
2657                .get("name")
2658                .or_else(|| map.get("tool"))
2659                .or_else(|| map.get("tool_name"))
2660                .or_else(|| map.get("toolName"))
2661                .and_then(Value::as_str);
2662            if let Some(name) = name.filter(|_| {
2663                type_name.contains("tool")
2664                    || map.contains_key("input")
2665                    || map.contains_key("arguments")
2666                    || map.contains_key("toolCall")
2667            }) {
2668                tools.push(ToolUsage {
2669                    name: name.to_string(),
2670                    kind: if type_name.is_empty() {
2671                        "tool"
2672                    } else {
2673                        type_name
2674                    }
2675                    .to_string(),
2676                    status: "observed".to_string(),
2677                    detail: short_json_detail(value),
2678                });
2679            }
2680            for value in map.values() {
2681                collect_tool_usage(value, tools);
2682            }
2683        }
2684        Value::Array(values) => {
2685            for value in values {
2686                collect_tool_usage(value, tools);
2687            }
2688        }
2689        _ => {}
2690    }
2691}
2692
2693fn line_tool_usage(provider: &str, line: &str) -> Option<ToolUsage> {
2694    let trimmed = line.trim();
2695    let lowered = trimmed.to_ascii_lowercase();
2696    let marker = ["running shell:", "tool:", "tool_use", "mcp:", "command:"]
2697        .iter()
2698        .find(|marker| lowered.contains(**marker))?;
2699    Some(ToolUsage {
2700        name: marker.trim_end_matches(':').to_string(),
2701        kind: provider.to_string(),
2702        status: "observed".to_string(),
2703        detail: truncate(trimmed, 240),
2704    })
2705}
2706
2707fn short_json_detail(value: &Value) -> String {
2708    serde_json::to_string(value)
2709        .map(|text| truncate(&text, 240))
2710        .unwrap_or_default()
2711}
2712
2713fn dedupe_tools(tools: Vec<ToolUsage>) -> Vec<ToolUsage> {
2714    let mut seen = HashSet::new();
2715    tools
2716        .into_iter()
2717        .filter(|tool| seen.insert(format!("{}:{}:{}", tool.kind, tool.name, tool.detail)))
2718        .collect()
2719}
2720
2721fn json_values(text: &str) -> Vec<Value> {
2722    let trimmed = text.trim();
2723    if trimmed.is_empty() {
2724        return vec![];
2725    }
2726    let mut values = Vec::new();
2727    if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
2728        values.push(value);
2729        return values;
2730    }
2731    for line in trimmed.lines() {
2732        if let Ok(value) = serde_json::from_str::<Value>(line.trim()) {
2733            values.push(value);
2734        }
2735    }
2736    values
2737}
2738
2739fn estimate_tokens(text: &str) -> usize {
2740    if text.trim().is_empty() {
2741        0
2742    } else {
2743        text.len().div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN)
2744    }
2745}
2746
2747fn format_command(command: &str, args: &[String]) -> String {
2748    std::iter::once(command.to_string())
2749        .chain(args.iter().map(|arg| shell_quote(arg)))
2750        .collect::<Vec<_>>()
2751        .join(" ")
2752}
2753
2754fn shell_quote(value: &str) -> String {
2755    if value
2756        .chars()
2757        .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
2758    {
2759        value.to_string()
2760    } else {
2761        serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
2762    }
2763}
2764
2765fn truncate(text: &str, max_chars: usize) -> String {
2766    if text.chars().count() <= max_chars {
2767        text.to_string()
2768    } else {
2769        let mut truncated = text.chars().take(max_chars).collect::<String>();
2770        truncated.push_str("\n...[truncated]");
2771        truncated
2772    }
2773}
2774
2775fn should_show_banner(raw: &CliArgs) -> bool {
2776    !raw.no_banner && !raw.headless && !raw.json && !raw.json_stream && !raw.plain
2777}
2778
2779fn linear_delivery_requested(raw: &CliArgs) -> bool {
2780    raw.deliver_linear
2781        || raw.linear_until_complete
2782        || raw.linear_watch
2783        || !raw.linear_issue.is_empty()
2784        || raw
2785            .linear_query
2786            .as_ref()
2787            .is_some_and(|value| !value.trim().is_empty())
2788        || !raw.linear_project.is_empty()
2789        || !raw.linear_epic.is_empty()
2790}
2791
2792fn render_banner() -> &'static str {
2793    "    _                         _   _            \n   / \\   _ __ ___   ___  _ __ | | | | ___ _ __  \n  / _ \\ | '_ ` _ \\ / _ \\| '_ \\| |_| |/ _ \\ '_ \\ \n / ___ \\| | | | | | (_) | | | |  _  |  __/ | | |\n/_/   \\_\\_| |_| |_|\\___/|_| |_|_| |_|\\___|_| |_|"
2794}
2795
2796fn render_human_result(result: &AmonHenResult, verbose: bool) -> String {
2797    let mut lines = Vec::new();
2798    if verbose {
2799        lines.push(format!(
2800            "Amon Hen consulted: {}",
2801            result.members_requested.join(", ")
2802        ));
2803        for command in &result.prompt_commands {
2804            lines.push(format!(
2805                "cmd [{}] {} ({:.1}s)",
2806                command.status,
2807                command.command,
2808                command.duration_ms as f64 / 1000.0
2809            ));
2810        }
2811        for (index, member) in result.members.iter().enumerate() {
2812            lines.push(format!(
2813                "{}. [{}] {} ({:.1}s, tokens:{}, tools:{}, sub-agents:{}){}",
2814                index + 1,
2815                member.status,
2816                member.name,
2817                member.duration_ms as f64 / 1000.0,
2818                member.token_usage.total,
2819                member.tool_calls.len(),
2820                member.sub_agents.len(),
2821                if member.detail.is_empty() { "" } else { ": " }
2822            ));
2823            if !member.detail.is_empty() {
2824                lines.push(format!("   {}", member.detail));
2825            }
2826            for sub_agent in &member.sub_agents {
2827                lines.push(format!(
2828                    "   - {} [{}] tokens:{} tools:{}",
2829                    sub_agent.role,
2830                    sub_agent.status,
2831                    sub_agent.token_usage.total,
2832                    sub_agent.tool_calls.len()
2833                ));
2834            }
2835            if member.status == "ok" {
2836                lines.push(indent(&member.output, "   "));
2837            }
2838        }
2839        lines.push("----------- synthesis -----------".to_string());
2840    }
2841    if result.summary.status == "ok" {
2842        lines.push(result.summary.output.trim().to_string());
2843    } else {
2844        lines.push(format!("Synthesis failed: {}", result.summary.detail));
2845    }
2846    lines.join("\n")
2847}
2848
2849fn indent(text: &str, prefix: &str) -> String {
2850    text.lines()
2851        .map(|line| format!("{prefix}{line}"))
2852        .collect::<Vec<_>>()
2853        .join("\n")
2854}
2855
2856fn is_success(result: &AmonHenResult) -> bool {
2857    result.members.iter().any(|member| member.status == "ok") && result.summary.status == "ok"
2858}
2859
2860#[cfg(test)]
2861mod tests {
2862    use super::*;
2863
2864    #[test]
2865    fn help_flags_use_success_exit_code() {
2866        let long_help = CliArgs::try_parse_from(["amon-hen", "--help"]).unwrap_err();
2867        let short_help = CliArgs::try_parse_from(["amon-hen", "-h"]).unwrap_err();
2868
2869        assert_eq!(long_help.kind(), ErrorKind::DisplayHelp);
2870        assert_eq!(short_help.kind(), ErrorKind::DisplayHelp);
2871        assert_eq!(parse_error_exit_code(long_help.kind()), 0);
2872        assert_eq!(parse_error_exit_code(short_help.kind()), 0);
2873    }
2874
2875    #[test]
2876    fn parses_members_and_provider_flags() {
2877        let args = CliArgs::try_parse_from([
2878            "amon-hen",
2879            "--members",
2880            "codex,claude",
2881            "--planner",
2882            "codex",
2883            "--lead",
2884            "claude",
2885            "--codex-effort",
2886            "xhigh",
2887            "--claude-capabilities",
2888            "override",
2889            "--claude-allowed-tools",
2890            "Read,Bash(git:*)",
2891            "--claude-tools",
2892            "Read,Edit",
2893            "--claude-agent",
2894            "reviewer",
2895            "--gemini-allowed-mcp-servers",
2896            "linear,github",
2897            "ship it",
2898        ])
2899        .unwrap();
2900        let resolved = resolve_args(args).unwrap();
2901        assert_eq!(resolved.members, vec!["codex", "claude"]);
2902        assert_eq!(resolved.raw.codex_effort.as_deref(), Some("xhigh"));
2903        assert_eq!(
2904            resolved.raw.claude_allowed_tools,
2905            vec!["Read", "Bash(git:*)"]
2906        );
2907        assert_eq!(resolved.raw.claude_tools, vec!["Read", "Edit"]);
2908        assert_eq!(resolved.raw.claude_agent.as_deref(), Some("reviewer"));
2909        assert_eq!(
2910            resolved.raw.gemini_allowed_mcp_servers,
2911            vec!["linear", "github"]
2912        );
2913    }
2914
2915    #[test]
2916    fn parses_historical_linear_flags_in_rust() {
2917        let args = CliArgs::try_parse_from([
2918            "amon-hen",
2919            "--banner",
2920            "--auth-open-browser",
2921            "--members",
2922            "codex,claude,gemini",
2923            "--linear-project",
2924            "ENG",
2925            "--linear-endpoint",
2926            "https://linear.example/graphql",
2927            "--linear-auth",
2928            "oauth",
2929            "--linear-max-polls",
2930            "2",
2931            "--linear-max-concurrency",
2932            "3",
2933            "--linear-workflow-file",
2934            "docs/linear-workflow.md",
2935            "--linear-workspace-strategy",
2936            "copy",
2937            "--linear-completion-gate",
2938            "review-or-ci",
2939            "--delivery-phases",
2940            "plan,implement,verify,ship",
2941            "deliver it",
2942        ])
2943        .unwrap();
2944        let resolved = resolve_args(args).unwrap();
2945
2946        assert!(linear_delivery_requested(&resolved.raw));
2947        assert_eq!(
2948            resolved.raw.linear_endpoint.as_deref(),
2949            Some("https://linear.example/graphql")
2950        );
2951        assert_eq!(resolved.raw.linear_auth, "oauth");
2952        assert_eq!(resolved.raw.linear_max_polls, Some(2));
2953        assert_eq!(resolved.raw.linear_max_concurrency, 3);
2954        assert_eq!(
2955            resolved.raw.linear_workflow_file.as_deref(),
2956            Some(Path::new("docs/linear-workflow.md"))
2957        );
2958        assert_eq!(resolved.raw.linear_workspace_strategy, "copy");
2959        assert_eq!(resolved.raw.linear_completion_gate, "review-or-ci");
2960        assert_eq!(
2961            resolved.raw.delivery_phases,
2962            vec!["plan", "implement", "verify", "ship"]
2963        );
2964    }
2965
2966    #[test]
2967    fn builds_member_prompt_with_roles_and_handoff() {
2968        let workflow = Workflow {
2969            handoff: true,
2970            lead: Some("claude".to_string()),
2971            planner: Some("codex".to_string()),
2972            iterations: 2,
2973            team_work: 1,
2974            teams: HashMap::new(),
2975        };
2976        let prompt = build_member_prompt("Fix the bug", "planner", &workflow, 1, &[], &[], "");
2977        assert!(prompt.contains("Amon Hen workflow: iteration 1 of 2."));
2978        assert!(prompt.contains("Lead model: claude."));
2979        assert!(prompt.contains("Planner model: codex."));
2980        assert!(prompt.contains("Your assigned role: planner."));
2981        assert!(prompt.contains("Current user query:"));
2982    }
2983
2984    #[test]
2985    fn parses_claude_stream_json_result() {
2986        let text = r#"{"type":"system","subtype":"status","status":"requesting"}
2987{"type":"result","result":"done"}"#;
2988        assert_eq!(parse_claude_output(text), "done");
2989    }
2990
2991    #[test]
2992    fn parses_gemini_json_response() {
2993        assert_eq!(parse_gemini_output(r#"{"response":"hello"}"#), "hello");
2994    }
2995
2996    #[test]
2997    fn estimates_tokens_with_ceiling_chunks() {
2998        assert_eq!(estimate_tokens(""), 0);
2999        assert_eq!(estimate_tokens("abc"), 1);
3000        assert_eq!(estimate_tokens("abcd"), 1);
3001        assert_eq!(estimate_tokens("abcde"), 2);
3002    }
3003
3004    #[test]
3005    fn extracts_social_login_urls() {
3006        assert_eq!(
3007            extract_auth_urls("Open https://example.com/callback?code=123, then continue"),
3008            vec!["https://example.com/callback?code=123"]
3009        );
3010        assert_eq!(
3011            extract_auth_urls("deeplink: claude://login/complete."),
3012            vec!["claude://login/complete"]
3013        );
3014    }
3015
3016    #[test]
3017    fn parses_provider_token_usage() {
3018        let stdout = r#"{"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}"#;
3019        let usage = extract_token_usage(stdout, "hello", "world").unwrap();
3020        assert_eq!(usage.input, 12);
3021        assert_eq!(usage.output, 8);
3022        assert_eq!(usage.total, 20);
3023        assert!(!usage.estimated);
3024    }
3025
3026    #[test]
3027    fn extracts_tool_usage_from_provider_streams() {
3028        let stdout = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}"#;
3029        let tools = extract_tool_usage("claude", stdout, "");
3030        assert!(tools.iter().any(|tool| tool.name == "Bash"));
3031    }
3032
3033    #[test]
3034    fn builds_real_sub_agent_handoff_prompt() {
3035        let agent = EngineResult {
3036            name: "codex".to_string(),
3037            bin: Some("codex".to_string()),
3038            status: "ok".to_string(),
3039            duration_ms: 1,
3040            detail: String::new(),
3041            exit_code: Some(0),
3042            stdout: String::new(),
3043            stderr: String::new(),
3044            output: "inspect parser".to_string(),
3045            command: "codex exec".to_string(),
3046            token_usage: token_usage("a", "b"),
3047            tool_calls: vec![],
3048            sub_agents: vec![],
3049            role: "executor:sub-agent-1".to_string(),
3050            iteration: 1,
3051            total_iterations: 1,
3052            team_size: 0,
3053        };
3054        let prompt = build_team_lead_prompt("ship it", &[agent]);
3055        assert!(prompt.contains("Sub-agent handoffs"));
3056        assert!(prompt.contains("inspect parser"));
3057        assert!(prompt.contains("Original provider prompt"));
3058    }
3059
3060    #[test]
3061    fn system_time_is_available_for_test_environment() {
3062        assert!(std::time::SystemTime::now()
3063            .duration_since(std::time::UNIX_EPOCH)
3064            .is_ok());
3065    }
3066}