Skip to main content

amon_hen/
lib.rs

1use clap::{error::ErrorKind, ArgAction, Parser, ValueEnum};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::{HashMap, HashSet};
5use std::ffi::OsString;
6use std::fs;
7use std::io::{self, IsTerminal, Read, Write};
8use std::path::{Path, PathBuf};
9use std::process::{Child, Command, Stdio};
10use std::sync::{
11    atomic::{AtomicBool, AtomicU64, Ordering},
12    mpsc, Arc, Mutex,
13};
14use std::thread;
15use std::time::{Duration, Instant};
16
17mod linear_delivery;
18mod studio;
19
20const VERSION: &str = env!("CARGO_PKG_VERSION");
21const DEFAULT_TIMEOUT_MS: u64 = 600_000;
22const DEFAULT_MAX_MEMBER_CHARS: usize = 12_000;
23const DEFAULT_MAX_PROMPT_CHARS: usize = 240_000;
24const PROVIDER_PROMPT_HARD_LIMIT_CHARS: usize = 1_000_000;
25const DEFAULT_ITERATIONS: usize = 1;
26const DEFAULT_TEAM_SIZE: usize = 0;
27const TOKEN_ESTIMATE_CHARS_PER_TOKEN: usize = 4;
28const PROVIDER_STREAM_EVENT_MIN_INTERVAL: Duration = Duration::from_millis(250);
29const UPDATE_CRATE_NAME: &str = "amon-hen";
30const UPDATE_CHECK_URL: &str = "https://crates.io/api/v1/crates/amon-hen";
31const UPDATE_CHANGELOG_URL: &str =
32    "https://raw.githubusercontent.com/Dviros/Amon-Hen/main/crates/amon-hen/CHANGELOG.md";
33const UPDATE_CHECK_TIMEOUT_MS: u64 = 2_500;
34const UPDATE_CACHE_TTL_SECS: u64 = 6 * 60 * 60;
35const UPDATE_COMMAND_ENV: &str = "AMON_HEN_UPDATE_COMMAND";
36
37const ENGINES: [&str; 3] = ["codex", "claude", "gemini"];
38const DEFAULT_SUMMARIZER_ORDER: [&str; 3] = ["codex", "claude", "gemini"];
39const DEFAULT_AUTH_MODE: &str = "auto";
40const CAPABILITY_INHERIT: &str = "inherit";
41const CAPABILITY_OVERRIDE: &str = "override";
42const PLANNER_MODE_BLOCKING: &str = "blocking";
43const PLANNER_MODE_PARALLEL: &str = "parallel";
44const PLANNER_MODE_REVIEW_CHAIN: &str = "review-chain";
45const PLANNER_MODE_HANDSHAKE: &str = "handshake";
46const CONSENSUS_OFF: &str = "off";
47const CONSENSUS_REQUIRED: &str = "required";
48const FAILURE_POLICY_CONTINUE: &str = "continue";
49const FAILURE_POLICY_TAKEOVER: &str = "takeover";
50const STOP_WHEN_ITERATIONS: &str = "iterations";
51const STOP_WHEN_CONSENSUS: &str = "consensus";
52const OWNER_MAP_FLEXIBLE: &str = "flexible";
53const OWNER_MAP_STRICT: &str = "strict";
54const GEMINI_APPROVAL_MODES: [&str; 4] = ["plan", "default", "auto_edit", "yolo"];
55
56#[derive(Debug, Copy, Clone, Eq, PartialEq)]
57enum Engine {
58    Codex,
59    Claude,
60    Gemini,
61}
62
63impl Engine {
64    fn parse(name: &str) -> Option<Self> {
65        match name {
66            "codex" => Some(Self::Codex),
67            "claude" => Some(Self::Claude),
68            "gemini" => Some(Self::Gemini),
69            _ => None,
70        }
71    }
72
73    fn as_str(self) -> &'static str {
74        match self {
75            Self::Codex => "codex",
76            Self::Claude => "claude",
77            Self::Gemini => "gemini",
78        }
79    }
80
81    fn binary_env_var(self) -> &'static str {
82        match self {
83            Self::Codex => "AMON_HEN_CODEX_BIN",
84            Self::Claude => "AMON_HEN_CLAUDE_BIN",
85            Self::Gemini => "AMON_HEN_GEMINI_BIN",
86        }
87    }
88
89    fn allowed_efforts(self) -> &'static [&'static str] {
90        match self {
91            Self::Codex => &["low", "medium", "high", "xhigh", ""],
92            Self::Claude => &["low", "medium", "high", "xhigh", "max"],
93            Self::Gemini => &["low", "medium", "high", ""],
94        }
95    }
96}
97
98#[derive(Parser, Debug, Clone)]
99#[command(
100    name = "amon-hen",
101    version,
102    about = "Ask multiple AI CLIs the same question, then synthesize their answers.",
103    trailing_var_arg = true,
104    disable_version_flag = true
105)]
106pub struct CliArgs {
107    #[arg(short = 'v', long = "version", action = ArgAction::SetTrue)]
108    version: bool,
109    #[arg(long = "check-update", action = ArgAction::SetTrue)]
110    check_update: bool,
111    #[arg(long = "update", action = ArgAction::SetTrue)]
112    update: bool,
113    #[arg(long = "no-update-check", action = ArgAction::SetTrue)]
114    no_update_check: bool,
115
116    #[arg(long, action = ArgAction::SetTrue)]
117    json: bool,
118
119    #[arg(long = "json-stream", alias = "ndjson", action = ArgAction::SetTrue)]
120    json_stream: bool,
121
122    #[arg(long, action = ArgAction::SetTrue)]
123    headless: bool,
124
125    #[arg(long, action = ArgAction::SetTrue)]
126    studio: bool,
127
128    #[arg(long, action = ArgAction::SetTrue)]
129    plain: bool,
130
131    #[arg(long = "no-banner", action = ArgAction::SetTrue)]
132    no_banner: bool,
133    #[arg(long = "banner", hide = true, action = ArgAction::SetTrue)]
134    banner: bool,
135
136    #[arg(short = 'q', long = "quiet", alias = "summary-only", action = ArgAction::SetTrue)]
137    summary_only: bool,
138
139    #[arg(short = 'd', long, action = ArgAction::SetTrue)]
140    verbose: bool,
141
142    #[arg(long, action = ArgAction::SetTrue)]
143    all: bool,
144
145    #[arg(long, action = ArgAction::SetTrue)]
146    codex: bool,
147    #[arg(long = "no-codex", action = ArgAction::SetTrue)]
148    no_codex: bool,
149    #[arg(long, action = ArgAction::SetTrue)]
150    claude: bool,
151    #[arg(long = "no-claude", action = ArgAction::SetTrue)]
152    no_claude: bool,
153    #[arg(long, action = ArgAction::SetTrue)]
154    gemini: bool,
155    #[arg(long = "no-gemini", action = ArgAction::SetTrue)]
156    no_gemini: bool,
157
158    #[arg(long, value_delimiter = ',')]
159    members: Vec<String>,
160
161    #[arg(long, default_value = "auto")]
162    summarizer: String,
163
164    #[arg(long, value_enum)]
165    effort: Option<Effort>,
166
167    #[arg(long = "codex-model")]
168    codex_model: Option<String>,
169    #[arg(long = "claude-model")]
170    claude_model: Option<String>,
171    #[arg(long = "gemini-model")]
172    gemini_model: Option<String>,
173
174    #[arg(long = "codex-effort")]
175    codex_effort: Option<String>,
176    #[arg(long = "claude-effort")]
177    claude_effort: Option<String>,
178    #[arg(long = "gemini-effort")]
179    gemini_effort: Option<String>,
180
181    #[arg(long = "codex-sandbox", default_value = "read-only")]
182    codex_sandbox: String,
183    #[arg(long = "claude-permission-mode", default_value = "plan")]
184    claude_permission_mode: String,
185    #[arg(long = "gemini-approval-mode", default_value = "plan")]
186    gemini_approval_mode: String,
187
188    #[arg(long = "codex-auth", default_value = "auto")]
189    codex_auth: String,
190    #[arg(long = "claude-auth", default_value = "auto")]
191    claude_auth: String,
192    #[arg(long = "gemini-auth", default_value = "auto")]
193    gemini_auth: String,
194
195    #[arg(long = "codex-capabilities", default_value = "inherit")]
196    codex_capabilities: String,
197    #[arg(long = "claude-capabilities", default_value = "inherit")]
198    claude_capabilities: String,
199    #[arg(long = "gemini-capabilities", default_value = "inherit")]
200    gemini_capabilities: String,
201
202    #[arg(long = "codex-config", action = ArgAction::Append)]
203    codex_config: Vec<String>,
204    #[arg(long = "codex-mcp-profile")]
205    codex_mcp_profile: Option<String>,
206    #[arg(long = "claude-mcp-config", action = ArgAction::Append)]
207    claude_mcp_config: Vec<String>,
208    #[arg(long = "claude-allowed-tools", value_delimiter = ',', action = ArgAction::Append)]
209    claude_allowed_tools: Vec<String>,
210    #[arg(long = "claude-disallowed-tools", value_delimiter = ',', action = ArgAction::Append)]
211    claude_disallowed_tools: Vec<String>,
212    #[arg(long = "claude-tools", value_delimiter = ',', action = ArgAction::Append)]
213    claude_tools: Vec<String>,
214    #[arg(long = "claude-agent")]
215    claude_agent: Option<String>,
216    #[arg(long = "claude-agents-json")]
217    claude_agents_json: Option<String>,
218    #[arg(long = "claude-plugin-dir", action = ArgAction::Append)]
219    claude_plugin_dir: Vec<String>,
220    #[arg(long = "claude-strict-mcp-config", action = ArgAction::SetTrue)]
221    claude_strict_mcp_config: bool,
222    #[arg(long = "claude-disable-slash-commands", action = ArgAction::SetTrue)]
223    claude_disable_slash_commands: bool,
224    #[arg(long = "gemini-settings")]
225    gemini_settings: Option<String>,
226    #[arg(long = "gemini-tools-profile", value_delimiter = ',', action = ArgAction::Append)]
227    gemini_tools_profile: Vec<String>,
228    #[arg(long = "gemini-allowed-mcp-servers", value_delimiter = ',', action = ArgAction::Append)]
229    gemini_allowed_mcp_servers: Vec<String>,
230    #[arg(long = "gemini-policy", value_delimiter = ',', action = ArgAction::Append)]
231    gemini_policy: Vec<String>,
232    #[arg(long = "gemini-admin-policy", value_delimiter = ',', action = ArgAction::Append)]
233    gemini_admin_policy: Vec<String>,
234    #[arg(long = "capabilities-status", action = ArgAction::SetTrue)]
235    capabilities_status: bool,
236
237    #[arg(long = "auth-login", action = ArgAction::SetTrue)]
238    auth_login: bool,
239    #[arg(long = "auth-status", action = ArgAction::SetTrue)]
240    auth_status: bool,
241    #[arg(long = "auth-login-providers", value_delimiter = ',')]
242    auth_login_providers: Vec<String>,
243    #[arg(long = "auth-device-code", action = ArgAction::SetTrue)]
244    auth_device_code: bool,
245    #[arg(long = "no-auth-open-browser", action = ArgAction::SetTrue)]
246    no_auth_open_browser: bool,
247    #[arg(long = "auth-open-browser", hide = true, action = ArgAction::SetTrue)]
248    auth_open_browser: bool,
249    #[arg(long = "auth-timeout", default_value_t = 300)]
250    auth_timeout: u64,
251    #[arg(long = "claude-login-mode", default_value = "claudeai")]
252    claude_login_mode: String,
253    #[arg(long = "claude-login-email")]
254    claude_login_email: Option<String>,
255
256    #[arg(long = "file", alias = "tag-file", action = ArgAction::Append)]
257    files: Vec<PathBuf>,
258    #[arg(long = "cmd", alias = "prompt-command", action = ArgAction::Append)]
259    commands: Vec<String>,
260
261    #[arg(long, action = ArgAction::SetTrue)]
262    handoff: bool,
263    #[arg(long)]
264    lead: Option<String>,
265    #[arg(long)]
266    planner: Option<String>,
267    #[arg(long = "planner-mode", default_value = PLANNER_MODE_BLOCKING)]
268    planner_mode: String,
269    #[arg(long, default_value_t = DEFAULT_ITERATIONS)]
270    iterations: usize,
271    #[arg(long = "team-work", alias = "teamwork", alias = "sub-agents", default_value_t = DEFAULT_TEAM_SIZE)]
272    team_work: usize,
273    #[arg(long = "handshake", action = ArgAction::SetTrue)]
274    handshake: bool,
275    #[arg(long = "handshake-provider")]
276    handshake_provider: Option<String>,
277    #[arg(long = "handshake-agent", alias = "handshake-agents", value_delimiter = ',', action = ArgAction::Append)]
278    handshake_agents: Vec<String>,
279    #[arg(long = "handshake-sub-agents", default_value = "auto")]
280    handshake_sub_agents: String,
281    #[arg(long = "codex-sub-agents")]
282    codex_sub_agents: Option<usize>,
283    #[arg(long = "claude-sub-agents")]
284    claude_sub_agents: Option<usize>,
285    #[arg(long = "gemini-sub-agents")]
286    gemini_sub_agents: Option<usize>,
287
288    #[arg(long = "consensus", default_value = CONSENSUS_OFF)]
289    consensus: String,
290    #[arg(long = "consensus-reviewers", value_delimiter = ',')]
291    consensus_reviewers: Vec<String>,
292    #[arg(long = "failure-policy", default_value = FAILURE_POLICY_CONTINUE)]
293    failure_policy: String,
294    #[arg(long = "review-rounds", default_value_t = 1)]
295    review_rounds: usize,
296    #[arg(long = "require-final-diff-review", action = ArgAction::SetTrue)]
297    require_final_diff_review: bool,
298    #[arg(long = "require-tests", action = ArgAction::SetTrue)]
299    require_tests: bool,
300    #[arg(long = "require-secret-scan", action = ArgAction::SetTrue)]
301    require_secret_scan: bool,
302    #[arg(long = "require-clean-git-diff", action = ArgAction::SetTrue)]
303    require_clean_git_diff: bool,
304    #[arg(long = "stop-when", default_value = STOP_WHEN_ITERATIONS)]
305    stop_when: String,
306    #[arg(long = "owner-map", default_value = OWNER_MAP_FLEXIBLE)]
307    owner_map: String,
308
309    #[arg(long = "deliver-linear", alias = "linear", action = ArgAction::SetTrue)]
310    deliver_linear: bool,
311    #[arg(long = "linear-setup", action = ArgAction::SetTrue)]
312    linear_setup: bool,
313    #[arg(long = "linear-status", action = ArgAction::SetTrue)]
314    linear_status: bool,
315    #[arg(long = "linear-watch", action = ArgAction::SetTrue)]
316    linear_watch: bool,
317    #[arg(long = "linear-until-complete", action = ArgAction::SetTrue)]
318    linear_until_complete: bool,
319    #[arg(long = "linear-issue", value_delimiter = ',', action = ArgAction::Append)]
320    linear_issue: Vec<String>,
321    #[arg(long = "linear-query")]
322    linear_query: Option<String>,
323    #[arg(long = "linear-project", value_delimiter = ',', action = ArgAction::Append)]
324    linear_project: Vec<String>,
325    #[arg(long = "linear-epic", value_delimiter = ',', action = ArgAction::Append)]
326    linear_epic: Vec<String>,
327    #[arg(long = "linear-team")]
328    linear_team: Option<String>,
329    #[arg(long = "linear-state")]
330    linear_state: Option<String>,
331    #[arg(long = "linear-assignee")]
332    linear_assignee: Option<String>,
333    #[arg(long = "linear-limit", default_value_t = 3)]
334    linear_limit: usize,
335    #[arg(long = "linear-endpoint")]
336    linear_endpoint: Option<String>,
337    #[arg(long = "linear-auth", default_value = "api-key")]
338    linear_auth: String,
339    #[arg(long = "linear-api-key-env", default_value = "LINEAR_API_KEY")]
340    linear_api_key_env: String,
341    #[arg(long = "linear-oauth-token-env", default_value = "LINEAR_OAUTH_TOKEN")]
342    linear_oauth_token_env: String,
343    #[arg(long = "linear-completion-gate", default_value = "delivered")]
344    linear_completion_gate: String,
345    #[arg(long = "linear-review-state")]
346    linear_review_state: Option<String>,
347    #[arg(long = "linear-ci-timeout", default_value_t = 900)]
348    linear_ci_timeout: u64,
349    #[arg(long = "linear-ci-poll-interval", default_value_t = 30)]
350    linear_ci_poll_interval: u64,
351    #[arg(long = "linear-poll-interval", default_value_t = 60)]
352    linear_poll_interval: u64,
353    #[arg(long = "linear-max-polls")]
354    linear_max_polls: Option<usize>,
355    #[arg(long = "linear-max-concurrency", default_value_t = 1)]
356    linear_max_concurrency: usize,
357    #[arg(long = "linear-max-attempts", default_value_t = 3)]
358    linear_max_attempts: usize,
359    #[arg(long = "linear-retry-base", default_value_t = 60)]
360    linear_retry_base: u64,
361    #[arg(long = "linear-workspace-strategy", default_value = "worktree")]
362    linear_workspace_strategy: String,
363    #[arg(long = "linear-state-file")]
364    linear_state_file: Option<PathBuf>,
365    #[arg(long = "linear-workspace-root")]
366    linear_workspace_root: Option<PathBuf>,
367    #[arg(long = "linear-observability-dir")]
368    linear_observability_dir: Option<PathBuf>,
369    #[arg(long = "linear-workflow-file")]
370    linear_workflow_file: Option<PathBuf>,
371    #[arg(long = "linear-attach-media", value_delimiter = ',', action = ArgAction::Append)]
372    linear_attach_media: Vec<String>,
373    #[arg(long = "linear-attachment-title")]
374    linear_attachment_title: Option<String>,
375    #[arg(long = "no-linear-comments", action = ArgAction::SetTrue)]
376    no_linear_comments: bool,
377    #[arg(long = "linear-update-review-state", action = ArgAction::SetTrue)]
378    linear_update_review_state: bool,
379    #[arg(long = "delivery-phases", value_delimiter = ',')]
380    delivery_phases: Vec<String>,
381
382    #[arg(long, default_value_t = DEFAULT_TIMEOUT_MS / 1000)]
383    timeout: u64,
384    #[arg(long = "max-member-chars", default_value_t = DEFAULT_MAX_MEMBER_CHARS)]
385    max_member_chars: usize,
386    #[arg(long = "max-prompt-chars", default_value_t = DEFAULT_MAX_PROMPT_CHARS)]
387    max_prompt_chars: usize,
388    #[arg(long, default_value = ".")]
389    cwd: PathBuf,
390    #[arg(long = "resume", value_name = "RUN_DIR")]
391    resume: Option<PathBuf>,
392    #[arg(long, default_value = "auto")]
393    color: String,
394    #[arg(long = "no-color", action = ArgAction::SetTrue)]
395    no_color: bool,
396
397    #[arg(value_name = "QUERY")]
398    prompt: Vec<String>,
399}
400
401#[derive(Copy, Clone, Debug, ValueEnum)]
402enum Effort {
403    Low,
404    Medium,
405    High,
406}
407
408impl Effort {
409    fn as_str(self) -> &'static str {
410        match self {
411            Effort::Low => "low",
412            Effort::Medium => "medium",
413            Effort::High => "high",
414        }
415    }
416}
417
418#[derive(Debug, Clone)]
419struct ResolvedArgs {
420    raw: CliArgs,
421    members: Vec<String>,
422    prompt: String,
423    cwd: PathBuf,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct AmonHenResult {
428    query: String,
429    cwd: String,
430    members_requested: Vec<String>,
431    summarizer_requested: String,
432    workflow: Workflow,
433    prompt_commands: Vec<CommandTelemetry>,
434    iterations: Vec<IterationRecord>,
435    members: Vec<EngineResult>,
436    summary: EngineResult,
437    consensus: Option<ConsensusResult>,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
441struct Workflow {
442    handoff: bool,
443    lead: Option<String>,
444    planner: Option<String>,
445    planner_mode: String,
446    owner_map: String,
447    handshake: bool,
448    handshake_agents: Vec<AgentSlot>,
449    iterations: usize,
450    team_work: usize,
451    teams: HashMap<String, usize>,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455struct AgentSlot {
456    id: String,
457    provider: String,
458    role: String,
459    team_size: usize,
460}
461
462#[derive(Debug, Clone)]
463struct HandshakeAgentSpec {
464    role: String,
465    provider: String,
466    sub_agents: Option<usize>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470struct IterationRecord {
471    iteration: usize,
472    total_iterations: usize,
473    status: String,
474    duration_ms: u128,
475    members: Vec<EngineResult>,
476    sub_agents: Vec<EngineResult>,
477    handoff_context: Option<String>,
478    summary_context: Option<String>,
479    consensus: Option<ConsensusResult>,
480    token_usage: TokenUsage,
481    tool_calls: Vec<ToolUsage>,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct EngineResult {
486    name: String,
487    bin: Option<String>,
488    status: String,
489    duration_ms: u128,
490    detail: String,
491    exit_code: Option<i32>,
492    stdout: String,
493    stderr: String,
494    output: String,
495    command: String,
496    token_usage: TokenUsage,
497    tool_calls: Vec<ToolUsage>,
498    sub_agents: Vec<EngineResult>,
499    role: String,
500    iteration: usize,
501    total_iterations: usize,
502    team_size: usize,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize, Default)]
506struct TokenUsage {
507    input: usize,
508    output: usize,
509    total: usize,
510    estimated: bool,
511    source: String,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
515struct ToolUsage {
516    name: String,
517    kind: String,
518    status: String,
519    detail: String,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
523struct CommandTelemetry {
524    command: String,
525    status: String,
526    detail: String,
527    exit_code: Option<i32>,
528    duration_ms: u128,
529    stdout_chars: usize,
530    stderr_chars: usize,
531    timed_out: bool,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize)]
535struct ConsensusConfig {
536    mode: String,
537    reviewers: Vec<String>,
538    failure_policy: String,
539    review_rounds: usize,
540    require_final_diff_review: bool,
541    require_tests: bool,
542    require_secret_scan: bool,
543    require_clean_git_diff: bool,
544    stop_when: String,
545    owner_map: String,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
549struct ConsensusResult {
550    status: String,
551    iteration: usize,
552    approved: bool,
553    config: ConsensusConfig,
554    rounds: Vec<ConsensusRound>,
555    blockers: Vec<String>,
556    takeover_required: bool,
557    takeover_by: Vec<String>,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
561struct ConsensusRound {
562    round: usize,
563    status: String,
564    checks: Vec<ConsensusCheck>,
565    reviews: Vec<ConsensusReview>,
566    blockers: Vec<String>,
567    takeover_by: Vec<String>,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
571struct ConsensusCheck {
572    name: String,
573    command: CommandTelemetry,
574    stdout: String,
575    stderr: String,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
579struct ConsensusReview {
580    provider: String,
581    role: String,
582    status: String,
583    approved: bool,
584    blockers: Vec<String>,
585    takeover_for: Vec<String>,
586    result: EngineResult,
587}
588
589#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)]
590#[serde(rename_all = "snake_case")]
591enum ProgressStage {
592    Context,
593    Start,
594    Spawn,
595    Heartbeat,
596    Done,
597}
598
599#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)]
600#[serde(rename_all = "snake_case")]
601enum RuntimeEventKind {
602    Context,
603    Iteration,
604    Provider,
605    TokenUsage,
606    ToolUsage,
607    Result,
608}
609
610#[derive(Debug, Clone, Serialize)]
611struct ProgressEvent {
612    kind: RuntimeEventKind,
613    provider: Option<String>,
614    role: Option<String>,
615    stage: ProgressStage,
616    status: Option<String>,
617    iteration: Option<usize>,
618    total_iterations: Option<usize>,
619    is_sub_agent: bool,
620    duration_ms: Option<u128>,
621    token_usage: Option<TokenUsage>,
622    tool_calls: Vec<ToolUsage>,
623    message: String,
624}
625
626type ProgressSink = Arc<dyn Fn(ProgressEvent) + Send + Sync + 'static>;
627type RuntimeEventSink = Arc<dyn Fn(RuntimeEvent) + Send + Sync + 'static>;
628
629#[derive(Debug, Clone, Serialize)]
630struct RuntimeEvent {
631    sequence: u64,
632    elapsed_ms: u128,
633    #[serde(flatten)]
634    progress: ProgressEvent,
635    #[serde(skip_serializing_if = "Option::is_none")]
636    result: Option<Box<AmonHenResult>>,
637    #[serde(skip_serializing_if = "Option::is_none", rename = "linearResult")]
638    linear_result: Option<Box<linear_delivery::LinearDeliveryResult>>,
639}
640
641#[derive(Clone)]
642struct RuntimeEventBus {
643    started: Instant,
644    sequence: Arc<AtomicU64>,
645    emit_lock: Arc<Mutex<()>>,
646    sinks: Arc<Mutex<Vec<RuntimeEventSink>>>,
647}
648
649#[derive(Debug)]
650struct PromptContext {
651    prompt: String,
652    commands: Vec<CommandTelemetry>,
653}
654
655#[derive(Debug)]
656struct CommandResult {
657    command: String,
658    args: Vec<String>,
659    code: Option<i32>,
660    stdout: String,
661    stderr: String,
662    timed_out: bool,
663    cancelled: bool,
664    error: Option<String>,
665    timeout_ms: u64,
666    duration_ms: u128,
667}
668
669#[derive(Clone)]
670struct CommandProgress {
671    label: String,
672    sink: Option<ProgressSink>,
673    input_tokens: usize,
674}
675
676struct CommandRequest<'a> {
677    command: &'a str,
678    args: &'a [String],
679    cwd: &'a Path,
680    stdin_text: Option<&'a str>,
681    timeout_ms: u64,
682    envs: HashMap<String, String>,
683    progress: Option<CommandProgress>,
684    cancel: Option<Arc<AtomicBool>>,
685}
686
687impl<'a> CommandRequest<'a> {
688    fn new(command: &'a str, args: &'a [String], cwd: &'a Path, timeout_ms: u64) -> Self {
689        Self {
690            command,
691            args,
692            cwd,
693            stdin_text: None,
694            timeout_ms,
695            envs: HashMap::new(),
696            progress: None,
697            cancel: None,
698        }
699    }
700
701    fn stdin_text(mut self, stdin_text: Option<&'a str>) -> Self {
702        self.stdin_text = stdin_text;
703        self
704    }
705
706    fn envs(mut self, envs: HashMap<String, String>) -> Self {
707        self.envs = envs;
708        self
709    }
710
711    fn progress(mut self, progress: Option<CommandProgress>) -> Self {
712        self.progress = progress;
713        self
714    }
715
716    fn cancel(mut self, cancel: Option<Arc<AtomicBool>>) -> Self {
717        self.cancel = cancel;
718        self
719    }
720}
721
722#[derive(Clone)]
723struct EngineRunOptions {
724    prompt: String,
725    max_prompt_chars: usize,
726    max_member_chars: usize,
727    cwd: PathBuf,
728    timeout_ms: u64,
729    effort: Option<String>,
730    model: Option<String>,
731    permission: Option<String>,
732    auth: String,
733    capability: ProviderCapability,
734    role: String,
735    iteration: usize,
736    total_iterations: usize,
737    team_size: usize,
738    is_sub_agent: bool,
739    live: bool,
740    progress: Option<ProgressSink>,
741    cancel: Option<Arc<AtomicBool>>,
742}
743
744struct EngineOptionsInput<'a> {
745    member: &'a str,
746    prompt: String,
747    role: &'a str,
748    iteration: usize,
749    workflow: &'a Workflow,
750    progress: Option<ProgressSink>,
751    cancel: Option<Arc<AtomicBool>>,
752}
753
754struct MemberPromptInput<'a> {
755    query: &'a str,
756    role: &'a str,
757    workflow: &'a Workflow,
758    iteration: usize,
759    team_size: usize,
760    max_member_chars: usize,
761    max_prompt_chars: usize,
762    previous_iteration: &'a [EngineResult],
763    handoff_results: &'a [EngineResult],
764    plan_output: &'a str,
765}
766
767#[derive(Debug, Clone)]
768struct ProviderCapability {
769    mode: String,
770    config: Vec<String>,
771    mcp_profile: Option<String>,
772    mcp_config: Vec<String>,
773    allowed_tools: Vec<String>,
774    disallowed_tools: Vec<String>,
775    tools: Vec<String>,
776    agent: Option<String>,
777    agents_json: Option<String>,
778    plugin_dirs: Vec<String>,
779    strict_mcp_config: bool,
780    disable_slash_commands: bool,
781    settings: Option<String>,
782    tools_profile: Vec<String>,
783    allowed_mcp_servers: Vec<String>,
784    policy: Vec<String>,
785    admin_policy: Vec<String>,
786}
787
788#[derive(Debug, Clone, Serialize)]
789struct ProviderAuthStatus {
790    provider: String,
791    configured: bool,
792    status: String,
793    source: String,
794    detail: String,
795}
796
797#[derive(Debug, Clone, Serialize)]
798struct ProviderCapabilityStatus {
799    provider: String,
800    mcp: CommandTelemetry,
801    skills: Option<CommandTelemetry>,
802    tools: Option<CommandTelemetry>,
803    detail: String,
804}
805
806#[derive(Debug, Clone, Serialize, Deserialize)]
807struct UpdateInfo {
808    current_version: String,
809    latest_version: String,
810    update_available: bool,
811    changelog: String,
812    source: String,
813}
814
815#[derive(Debug, Clone, Deserialize)]
816struct CratesIoResponse {
817    #[serde(rename = "crate")]
818    krate: CratesIoCrate,
819}
820
821#[derive(Debug, Clone, Deserialize)]
822struct CratesIoCrate {
823    newest_version: String,
824}
825
826#[derive(Debug, Clone, Serialize, Deserialize)]
827struct UpdateCache {
828    checked_at_unix_secs: u64,
829    info: UpdateInfo,
830}
831
832struct TempSettings {
833    _dir: tempfile::TempDir,
834    path: PathBuf,
835}
836
837pub fn run_from_env() -> i32 {
838    let args: Vec<OsString> = std::env::args_os().skip(1).collect();
839    run_with_args(args)
840}
841
842pub fn run_with_args<I, T>(args: I) -> i32
843where
844    I: IntoIterator<Item = T>,
845    T: Into<OsString>,
846{
847    let raw_args: Vec<OsString> = args.into_iter().map(Into::into).collect();
848    if raw_args.first().and_then(|value| value.to_str()) == Some("help") {
849        println!("{}", render_cli_help());
850        return 0;
851    }
852
853    let mut parse_args = vec![OsString::from("amon-hen")];
854    parse_args.extend(raw_args);
855    let parsed = match CliArgs::try_parse_from(parse_args) {
856        Ok(parsed) => parsed,
857        Err(error) => {
858            if error.kind() == ErrorKind::DisplayHelp {
859                println!("{}", render_cli_help());
860                return 0;
861            }
862            if error.kind() == ErrorKind::DisplayVersion {
863                println!("{VERSION}");
864                return 0;
865            }
866            let exit_code = parse_error_exit_code(error.kind());
867            let _ = error.print();
868            return exit_code;
869        }
870    };
871
872    if parsed.version {
873        println!("{VERSION}");
874        return 0;
875    }
876
877    if parsed.check_update {
878        return match check_for_update(&parsed.cwd, false) {
879            Ok(info) => {
880                println!("{}", render_update_check(&info));
881                0
882            }
883            Err(error) => {
884                eprintln!("Update check failed: {error}");
885                1
886            }
887        };
888    }
889
890    if parsed.update {
891        return run_self_update(&parsed.cwd);
892    }
893
894    let mut resolved = match resolve_args(parsed) {
895        Ok(resolved) => resolved,
896        Err(error) => {
897            eprintln!("{error}");
898            return 64;
899        }
900    };
901    if let Err(error) = apply_resume_prompt(&mut resolved) {
902        eprintln!("{error}");
903        return 64;
904    }
905
906    if resolved.raw.studio {
907        return studio::run_studio(&resolved);
908    }
909
910    maybe_print_startup_update_notice(&resolved.raw, &resolved.cwd);
911
912    let event_bus = resolved.raw.json_stream.then(RuntimeEventBus::new);
913    if let Some(bus) = &event_bus {
914        let stdout_lock = Arc::new(Mutex::new(()));
915        bus.add_sink(Arc::new(move |event| {
916            let Ok(line) = serde_json::to_string(&event) else {
917                return;
918            };
919            let _guard = stdout_lock.lock().ok();
920            println!("{line}");
921            let _ = io::stdout().flush();
922        }));
923    }
924    let progress = event_bus.as_ref().map(RuntimeEventBus::progress_sink);
925
926    if resolved.raw.auth_status {
927        println!(
928            "{}",
929            render_auth_statuses(&collect_auth_statuses(&resolved))
930        );
931        if resolved.prompt.trim().is_empty()
932            && !resolved.raw.auth_login
933            && !resolved.raw.deliver_linear
934            && !resolved.raw.capabilities_status
935        {
936            return 0;
937        }
938    }
939
940    if resolved.raw.capabilities_status {
941        println!(
942            "{}",
943            render_provider_capability_statuses(&collect_provider_capability_statuses(&resolved))
944        );
945        if resolved.prompt.trim().is_empty()
946            && !resolved.raw.auth_login
947            && !resolved.raw.deliver_linear
948        {
949            return 0;
950        }
951    }
952
953    if resolved.raw.auth_login {
954        if let Err(error) = run_social_login(&resolved) {
955            eprintln!("{error}");
956            return 1;
957        }
958        if resolved.prompt.trim().is_empty() && !resolved.raw.deliver_linear {
959            return 0;
960        }
961    }
962
963    if resolved.raw.linear_setup || resolved.raw.linear_status {
964        match linear_delivery::get_linear_status(&resolved) {
965            Ok(status) => {
966                println!("{}", linear_delivery::render_linear_status(&status));
967                return 0;
968            }
969            Err(error) => {
970                eprintln!("{error}");
971                return 1;
972            }
973        }
974    }
975
976    if linear_delivery_requested(&resolved.raw) {
977        match linear_delivery::run_linear_delivery_with_progress(&resolved, progress.clone(), None)
978        {
979            Ok(result) => {
980                if let Some(bus) = &event_bus {
981                    bus.emit_linear_result(&result);
982                } else if resolved.raw.json {
983                    let serialized = serde_json::to_string_pretty(&result);
984                    println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
985                } else {
986                    println!(
987                        "{}",
988                        linear_delivery::render_linear_delivery_result(&result)
989                    );
990                }
991                return if result.success { 0 } else { 1 };
992            }
993            Err(error) => {
994                eprintln!("{error}");
995                return 1;
996            }
997        }
998    }
999
1000    if resolved.prompt.trim().is_empty() {
1001        eprintln!("No query provided.\n\n{}", render_cli_help());
1002        return 64;
1003    }
1004
1005    let prompt_context = match build_prompt_context_with_progress(&resolved, progress.clone()) {
1006        Ok(context) => context,
1007        Err(error) => {
1008            eprintln!("{error}");
1009            return 1;
1010        }
1011    };
1012
1013    if should_show_banner(&resolved.raw) {
1014        eprintln!("{}", render_banner());
1015    }
1016
1017    let result = run_amon_hen_with_progress(
1018        &resolved,
1019        prompt_context.prompt,
1020        prompt_context.commands,
1021        progress,
1022    );
1023    if let Some(bus) = &event_bus {
1024        bus.emit_result(&result);
1025    } else if resolved.raw.json {
1026        let serialized = serde_json::to_string_pretty(&result);
1027        println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
1028    } else {
1029        println!("{}", render_human_result(&result, resolved.raw.verbose));
1030    }
1031
1032    if is_success(&result) {
1033        0
1034    } else {
1035        1
1036    }
1037}
1038
1039fn maybe_print_startup_update_notice(raw: &CliArgs, cwd: &Path) {
1040    if !should_auto_check_updates(raw) {
1041        return;
1042    }
1043    let Ok(info) = check_for_update(cwd, true) else {
1044        return;
1045    };
1046    if info.update_available {
1047        eprintln!("{}", render_startup_update_notice(&info));
1048    }
1049}
1050
1051fn should_auto_check_updates(raw: &CliArgs) -> bool {
1052    !raw.no_update_check
1053        && !raw.check_update
1054        && !raw.update
1055        && !raw.json
1056        && !raw.json_stream
1057        && !raw.plain
1058        && !raw.headless
1059        && !raw.summary_only
1060        && io::stderr().is_terminal()
1061}
1062
1063fn check_for_update(cwd: &Path, use_cache: bool) -> Result<UpdateInfo, String> {
1064    if use_cache {
1065        if let Some(info) = read_cached_update_info(cwd) {
1066            return Ok(info);
1067        }
1068    }
1069
1070    let info = fetch_update_info()?;
1071    write_update_cache(cwd, &info);
1072    Ok(info)
1073}
1074
1075fn fetch_update_info() -> Result<UpdateInfo, String> {
1076    let client = reqwest::blocking::Client::builder()
1077        .timeout(Duration::from_millis(UPDATE_CHECK_TIMEOUT_MS))
1078        .user_agent(format!("{UPDATE_CRATE_NAME}/{VERSION}"))
1079        .build()
1080        .map_err(|error| error.to_string())?;
1081    let response: CratesIoResponse = client
1082        .get(UPDATE_CHECK_URL)
1083        .send()
1084        .map_err(|error| error.to_string())?
1085        .error_for_status()
1086        .map_err(|error| error.to_string())?
1087        .json()
1088        .map_err(|error| error.to_string())?;
1089    let latest_version = response.krate.newest_version;
1090    let update_available = version_cmp(&latest_version, VERSION) == std::cmp::Ordering::Greater;
1091    let changelog = if update_available {
1092        fetch_remote_changelog(&client)
1093            .ok()
1094            .and_then(|text| extract_changelog_range(&text, VERSION, &latest_version))
1095            .unwrap_or_else(|| "Changelog unavailable; run --check-update again or open the crates.io release page.".to_string())
1096    } else {
1097        String::new()
1098    };
1099    Ok(UpdateInfo {
1100        current_version: VERSION.to_string(),
1101        latest_version,
1102        update_available,
1103        changelog,
1104        source: UPDATE_CHECK_URL.to_string(),
1105    })
1106}
1107
1108fn fetch_remote_changelog(client: &reqwest::blocking::Client) -> Result<String, String> {
1109    client
1110        .get(UPDATE_CHANGELOG_URL)
1111        .send()
1112        .map_err(|error| error.to_string())?
1113        .error_for_status()
1114        .map_err(|error| error.to_string())?
1115        .text()
1116        .map_err(|error| error.to_string())
1117}
1118
1119fn read_cached_update_info(cwd: &Path) -> Option<UpdateInfo> {
1120    let path = update_cache_path(cwd)?;
1121    let text = fs::read_to_string(path).ok()?;
1122    let cache: UpdateCache = serde_json::from_str(&text).ok()?;
1123    if cache.info.current_version != VERSION {
1124        return None;
1125    }
1126    let age = unix_now_secs().saturating_sub(cache.checked_at_unix_secs);
1127    (age <= UPDATE_CACHE_TTL_SECS).then_some(cache.info)
1128}
1129
1130fn write_update_cache(cwd: &Path, info: &UpdateInfo) {
1131    let Some(path) = update_cache_path(cwd) else {
1132        return;
1133    };
1134    if let Some(parent) = path.parent() {
1135        let _ = fs::create_dir_all(parent);
1136    }
1137    let cache = UpdateCache {
1138        checked_at_unix_secs: unix_now_secs(),
1139        info: info.clone(),
1140    };
1141    if let Ok(text) = serde_json::to_string_pretty(&cache) {
1142        let _ = fs::write(path, text + "\n");
1143    }
1144}
1145
1146fn update_cache_path(cwd: &Path) -> Option<PathBuf> {
1147    if let Some(path) = std::env::var_os("AMON_HEN_UPDATE_CACHE").map(PathBuf::from) {
1148        return Some(if path.is_absolute() {
1149            path
1150        } else {
1151            cwd.join(path)
1152        });
1153    }
1154    if let Some(root) = std::env::var_os("XDG_CACHE_HOME").map(PathBuf::from) {
1155        return Some(root.join("amon-hen").join("update-check.json"));
1156    }
1157    std::env::var_os("HOME").map(PathBuf::from).map(|home| {
1158        home.join(".cache")
1159            .join("amon-hen")
1160            .join("update-check.json")
1161    })
1162}
1163
1164fn unix_now_secs() -> u64 {
1165    std::time::SystemTime::now()
1166        .duration_since(std::time::UNIX_EPOCH)
1167        .unwrap_or_default()
1168        .as_secs()
1169}
1170
1171fn render_startup_update_notice(info: &UpdateInfo) -> String {
1172    let mut lines = vec![
1173        format!(
1174            "Amon Hen update available: {} -> {}",
1175            info.current_version, info.latest_version
1176        ),
1177        "Run `amon-hen --update` to update from this terminal.".to_string(),
1178    ];
1179    let changelog = render_changelog_excerpt(&info.changelog, 10);
1180    if !changelog.is_empty() {
1181        lines.push("Changelog:".to_string());
1182        lines.push(indent(&changelog, "  "));
1183    }
1184    lines.join("\n")
1185}
1186
1187fn render_update_check(info: &UpdateInfo) -> String {
1188    if !info.update_available {
1189        return format!(
1190            "Amon Hen is up to date: {} (checked {})",
1191            info.current_version, info.source
1192        );
1193    }
1194    let mut lines = vec![
1195        format!(
1196            "Amon Hen update available: {} -> {}",
1197            info.current_version, info.latest_version
1198        ),
1199        "Update command: amon-hen --update".to_string(),
1200    ];
1201    let changelog = render_changelog_excerpt(&info.changelog, 40);
1202    if !changelog.is_empty() {
1203        lines.push(String::new());
1204        lines.push("Changelog:".to_string());
1205        lines.push(changelog);
1206    }
1207    lines.join("\n")
1208}
1209
1210fn render_changelog_excerpt(changelog: &str, max_lines: usize) -> String {
1211    let mut lines = changelog
1212        .lines()
1213        .map(str::trim_end)
1214        .filter(|line| !line.trim().is_empty())
1215        .take(max_lines)
1216        .map(ToString::to_string)
1217        .collect::<Vec<_>>();
1218    if changelog
1219        .lines()
1220        .filter(|line| !line.trim().is_empty())
1221        .count()
1222        > max_lines
1223    {
1224        lines.push("...".to_string());
1225    }
1226    lines.join("\n")
1227}
1228
1229fn extract_changelog_range(changelog: &str, current: &str, latest: &str) -> Option<String> {
1230    let mut selected = Vec::new();
1231    let mut include = false;
1232    for line in changelog.lines() {
1233        if let Some(version) = parse_changelog_heading(line) {
1234            include = version_cmp(&version, current) == std::cmp::Ordering::Greater
1235                && version_cmp(&version, latest) != std::cmp::Ordering::Greater;
1236        }
1237        if include {
1238            selected.push(line.to_string());
1239        }
1240    }
1241    let text = selected.join("\n").trim().to_string();
1242    (!text.is_empty()).then_some(text)
1243}
1244
1245fn parse_changelog_heading(line: &str) -> Option<String> {
1246    let rest = line.trim().strip_prefix("## ")?;
1247    let first = rest
1248        .trim()
1249        .trim_matches(['[', ']'])
1250        .split_whitespace()
1251        .next()?
1252        .trim_start_matches('v')
1253        .trim_matches(['[', ']']);
1254    (!first.is_empty()).then(|| first.to_string())
1255}
1256
1257fn version_cmp(left: &str, right: &str) -> std::cmp::Ordering {
1258    let left_parts = version_numeric_parts(left);
1259    let right_parts = version_numeric_parts(right);
1260    let len = left_parts.len().max(right_parts.len()).max(3);
1261    for index in 0..len {
1262        let left = left_parts.get(index).copied().unwrap_or(0);
1263        let right = right_parts.get(index).copied().unwrap_or(0);
1264        match left.cmp(&right) {
1265            std::cmp::Ordering::Equal => {}
1266            ordering => return ordering,
1267        }
1268    }
1269    std::cmp::Ordering::Equal
1270}
1271
1272fn version_numeric_parts(version: &str) -> Vec<u64> {
1273    version
1274        .trim()
1275        .trim_start_matches('v')
1276        .split(|character: char| !character.is_ascii_digit())
1277        .filter(|part| !part.is_empty())
1278        .filter_map(|part| part.parse::<u64>().ok())
1279        .collect()
1280}
1281
1282fn run_self_update(cwd: &Path) -> i32 {
1283    match check_for_update(cwd, false) {
1284        Ok(info) => eprintln!("{}", render_update_check(&info)),
1285        Err(error) => eprintln!("Update check failed before install: {error}"),
1286    }
1287    let (_, _, display) = self_update_command();
1288    eprintln!("Running update command: {display}");
1289    let result = run_self_update_command(cwd, None);
1290    print_command_result(&result);
1291    if command_telemetry(&result).status == "ok" {
1292        eprintln!("Amon Hen updated. Restart the terminal command to use the new binary.");
1293        0
1294    } else {
1295        eprintln!("Amon Hen update failed.");
1296        1
1297    }
1298}
1299
1300fn run_self_update_command(cwd: &Path, cancel: Option<Arc<AtomicBool>>) -> CommandResult {
1301    let (command, args, _) = self_update_command();
1302    run_command(CommandRequest::new(&command, &args, cwd, 10 * 60 * 1000).cancel(cancel))
1303}
1304
1305fn self_update_command() -> (String, Vec<String>, String) {
1306    if let Ok(command) = std::env::var(UPDATE_COMMAND_ENV) {
1307        let shell = if cfg!(windows) { "cmd" } else { "sh" }.to_string();
1308        let args = if cfg!(windows) {
1309            vec!["/C".to_string(), command.clone()]
1310        } else {
1311            vec!["-lc".to_string(), command.clone()]
1312        };
1313        return (shell, args, command);
1314    }
1315    (
1316        "cargo".to_string(),
1317        vec![
1318            "install".to_string(),
1319            UPDATE_CRATE_NAME.to_string(),
1320            "--force".to_string(),
1321        ],
1322        format!("cargo install {UPDATE_CRATE_NAME} --force"),
1323    )
1324}
1325
1326fn print_command_result(result: &CommandResult) {
1327    if !result.stdout.trim().is_empty() {
1328        eprintln!("{}", result.stdout.trim_end());
1329    }
1330    if !result.stderr.trim().is_empty() {
1331        eprintln!("{}", result.stderr.trim_end());
1332    }
1333    if let Some(error) = &result.error {
1334        eprintln!("{error}");
1335    }
1336}
1337
1338fn parse_error_exit_code(kind: ErrorKind) -> i32 {
1339    match kind {
1340        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
1341        _ => 64,
1342    }
1343}
1344
1345fn render_cli_help() -> &'static str {
1346    r#"Amon Hen
1347Rust-native orchestration for Codex, Claude, Gemini, and Linear delivery.
1348
1349Usage:
1350  amon-hen [OPTIONS] "your task"
1351  amon-hen --studio --members codex,claude,gemini "your task"
1352  amon-hen --deliver-linear --linear-project "Project" --linear-until-complete
1353
1354Core run:
1355  --members codex,claude,gemini       Providers to consult. Also supports --codex, --claude, --gemini, --all.
1356  --summarizer auto|codex|claude|gemini
1357                                      Provider used for synthesis. auto tries the configured order.
1358  --studio                            Open the interactive native Studio.
1359  --plain                             Disable rich terminal output for script-friendly runs.
1360  --json, --json-stream               Emit JSON or newline-delimited JSON.
1361  -q, --quiet                         Print the synthesis only.
1362  -d, --verbose                       Include provider details, telemetry, and tool usage.
1363
1364Models, effort, and permissions:
1365  --codex-model NAME                  Model passed to Codex.
1366  --claude-model NAME                 Model passed to Claude.
1367  --gemini-model NAME                 Model passed to Gemini.
1368  --effort low|medium|high            Shared default effort.
1369  --codex-effort low|medium|high|xhigh
1370  --claude-effort low|medium|high|xhigh|max
1371  --gemini-effort low|medium|high
1372  --codex-sandbox read-only|workspace-write|danger-full-access
1373  --claude-permission-mode MODE       Claude permission mode such as plan, acceptEdits, bypassPermissions.
1374  --gemini-approval-mode MODE         Gemini approval mode: plan, default, auto_edit, or yolo.
1375
1376Auth and provider capabilities:
1377  --auth-login                        Start provider social-login flows.
1378  --auth-status                       Show configured auth sources for each provider.
1379  --codex-auth auto|api-key|social-login|oauth|keychain
1380  --claude-auth auto|api-key|social-login|oauth|keychain
1381  --gemini-auth auto|api-key|social-login|oauth|keychain
1382  --capabilities-status               Probe provider Skills / MCP / Tools support.
1383  --codex-capabilities inherit|override
1384  --claude-capabilities inherit|override
1385  --gemini-capabilities inherit|override
1386  --codex-config PATH                 Extra Codex config file, repeatable.
1387  --codex-mcp-profile NAME            Codex MCP profile.
1388  --claude-mcp-config PATH            Claude MCP config file, repeatable.
1389  --claude-allowed-tools LIST         Allowed Claude tools.
1390  --claude-disallowed-tools LIST      Disallowed Claude tools.
1391  --claude-tools LIST                 Claude tool list override.
1392  --claude-agent NAME                 Claude agent profile.
1393  --claude-agents-json JSON           Claude agents JSON override.
1394  --claude-plugin-dir PATH            Claude plugin directory, repeatable.
1395  --gemini-settings PATH              Gemini settings file.
1396  --gemini-tools-profile LIST         Gemini tool profile list.
1397  --gemini-allowed-mcp-servers LIST   Gemini MCP allow-list.
1398  --gemini-policy LIST                Gemini policy values.
1399  --gemini-admin-policy LIST          Gemini admin policy values.
1400
1401Team workflow:
1402  --handoff                           Feed planner/lead context between providers.
1403  --planner codex|claude|gemini       Assign the planning role.
1404  --planner-mode blocking|parallel|review-chain|handshake
1405                                      Wait, run beside executors, serially review, or run a role handshake.
1406  --lead codex|claude|gemini          Assign the lead reviewer/synthesizer role.
1407  --iterations N                      Run multiple provider rounds per prompt.
1408  --team-work N                       Spawn N same-provider sub-agents per provider.
1409  --handshake                         Run a planner -> executor -> reviewer agent handshake.
1410  --handshake-provider codex|claude|gemini
1411                                      Non-declarative single-provider handshake model.
1412  --handshake-agent ROLE=PROVIDER[:SUBS]
1413                                      Declarative role slot, repeatable or comma-list. Roles: planner, executor, reviewer.
1414  --handshake-sub-agents N|auto       Sub-agent count for generated handshake slots.
1415  --codex-sub-agents N                Override Codex sub-agent count.
1416  --claude-sub-agents N               Override Claude sub-agent count.
1417  --gemini-sub-agents N               Override Gemini sub-agent count.
1418  --consensus off|required            Require reviewer consensus before success.
1419  --consensus-reviewers LIST          Providers that must approve consensus.
1420  --failure-policy continue|takeover  Continue after failures or require reviewer takeover.
1421  --review-rounds N                   Consensus review rounds before blocking.
1422  --require-final-diff-review         Review final git diff as part of consensus.
1423  --require-tests                     Run detected test suite as consensus evidence.
1424  --require-secret-scan               Block on sensitive strings/local paths in repo.
1425  --require-clean-git-diff            Run git diff hygiene checks.
1426  --stop-when iterations|consensus    Stop after configured iterations or first consensus.
1427  --owner-map flexible|strict         Require explicit ownership review.
1428  --file PATH                         Tag a local file into the prompt context, repeatable.
1429  --cmd COMMAND                       Run a local command and include its telemetry, repeatable.
1430
1431Linear delivery:
1432  --deliver-linear                    Deliver Linear work instead of a one-shot prompt.
1433  --linear-watch                      Poll Linear for matching work.
1434  --linear-until-complete             Continue until review/delivery gate is met.
1435  --linear-issue ID,ID                Target specific Linear issues.
1436  --linear-project NAME,NAME          Target Linear projects.
1437  --linear-epic NAME,NAME             Target Linear epics.
1438  --linear-team KEY                   Restrict to a Linear team.
1439  --linear-state NAME                 Restrict by Linear workflow state.
1440  --linear-completion-gate delivered|review|ci
1441  --linear-max-concurrency N          Isolated workspace concurrency.
1442  --linear-max-attempts N             Retry attempts per issue.
1443  --linear-workspace-strategy worktree|local
1444  --linear-workspace-root PATH        Root for per-issue workspaces.
1445  --linear-observability-dir PATH     Logs, reconciliation, and run telemetry.
1446  --linear-attach-media PATH,PATH     Attach generated media back to Linear.
1447
1448Runtime:
1449  --cwd PATH                          Working directory for provider CLIs.
1450  --resume RUN_DIR                    Reopen a Studio run from its saved state directory.
1451  --timeout SECONDS                   Per-provider timeout.
1452  --max-member-chars N                Max provider output included in synthesis.
1453  --max-prompt-chars N                Hard cap for each provider prompt before launch.
1454  --color auto|always|never           Color mode.
1455  --no-color                          Disable color.
1456  --check-update                      Check crates.io for a newer Amon Hen and print the changelog.
1457  --update                            Run the terminal self-update command.
1458  --no-update-check                   Disable the startup update notice.
1459  -v, --version                       Print version.
1460  -h, --help                          Show this help.
1461
1462Examples:
1463  amon-hen --studio --members codex,claude,gemini "Inspect this repo"
1464  amon-hen --members codex,claude,gemini --planner codex --lead claude --handoff --iterations 2 "Suggest the cleanest next patch"
1465  amon-hen --deliver-linear --linear-project "Developer Platform" --linear-until-complete --team-work 2
1466  amon-hen --auth-login --auth-login-providers codex,claude,gemini"#
1467}
1468
1469fn resolve_args(raw: CliArgs) -> Result<ResolvedArgs, String> {
1470    let members = resolve_members(&raw)?;
1471    validate_engine_name(&raw.summarizer, true, "--summarizer")?;
1472    if let Some(lead) = &raw.lead {
1473        validate_engine_name(lead, false, "--lead")?;
1474        if !handshake_requested(&raw) && !members.contains(lead) {
1475            return Err(format!(
1476                "--lead must be one of the enabled members: {}",
1477                members.join(", ")
1478            ));
1479        }
1480    }
1481    if let Some(planner) = &raw.planner {
1482        validate_engine_name(planner, false, "--planner")?;
1483        if !handshake_requested(&raw) && !members.contains(planner) {
1484            return Err(format!(
1485                "--planner must be one of the enabled members: {}",
1486                members.join(", ")
1487            ));
1488        }
1489    }
1490    validate_choice(
1491        "--planner-mode",
1492        &raw.planner_mode,
1493        &[
1494            PLANNER_MODE_BLOCKING,
1495            PLANNER_MODE_PARALLEL,
1496            PLANNER_MODE_REVIEW_CHAIN,
1497            PLANNER_MODE_HANDSHAKE,
1498        ],
1499    )?;
1500    if let Some(provider) = &raw.handshake_provider {
1501        validate_engine_name(provider, false, "--handshake-provider")?;
1502    }
1503    validate_handshake_sub_agents(&raw.handshake_sub_agents)?;
1504    for agent in &raw.handshake_agents {
1505        parse_handshake_agent_spec(agent)?;
1506    }
1507    validate_choice(
1508        "--consensus",
1509        &raw.consensus,
1510        &[CONSENSUS_OFF, CONSENSUS_REQUIRED],
1511    )?;
1512    validate_choice(
1513        "--failure-policy",
1514        &raw.failure_policy,
1515        &[FAILURE_POLICY_CONTINUE, FAILURE_POLICY_TAKEOVER],
1516    )?;
1517    validate_choice(
1518        "--stop-when",
1519        &raw.stop_when,
1520        &[STOP_WHEN_ITERATIONS, STOP_WHEN_CONSENSUS],
1521    )?;
1522    validate_choice(
1523        "--owner-map",
1524        &raw.owner_map,
1525        &[OWNER_MAP_FLEXIBLE, OWNER_MAP_STRICT],
1526    )?;
1527    if raw.review_rounds == 0 {
1528        return Err("--review-rounds requires a positive integer.".to_string());
1529    }
1530    for reviewer in raw
1531        .consensus_reviewers
1532        .iter()
1533        .map(|reviewer| reviewer.trim())
1534        .filter(|reviewer| !reviewer.is_empty())
1535    {
1536        validate_engine_name(reviewer, false, "--consensus-reviewers")?;
1537    }
1538    validate_provider_effort("codex", raw.codex_effort.as_deref())?;
1539    validate_provider_effort("claude", raw.claude_effort.as_deref())?;
1540    validate_provider_effort("gemini", raw.gemini_effort.as_deref())?;
1541    validate_choice(
1542        "--gemini-approval-mode",
1543        &raw.gemini_approval_mode,
1544        &GEMINI_APPROVAL_MODES,
1545    )?;
1546    validate_choice(
1547        "--claude-login-mode",
1548        &raw.claude_login_mode,
1549        &["claudeai", "console", "sso"],
1550    )?;
1551    validate_choice(
1552        "--codex-capabilities",
1553        &raw.codex_capabilities,
1554        &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
1555    )?;
1556    validate_choice(
1557        "--claude-capabilities",
1558        &raw.claude_capabilities,
1559        &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
1560    )?;
1561    validate_choice(
1562        "--gemini-capabilities",
1563        &raw.gemini_capabilities,
1564        &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
1565    )?;
1566    validate_choice("--linear-auth", &raw.linear_auth, &["api-key", "oauth"])?;
1567    validate_choice(
1568        "--linear-completion-gate",
1569        &raw.linear_completion_gate,
1570        &["delivered", "human-review", "ci-success", "review-or-ci"],
1571    )?;
1572    validate_choice(
1573        "--linear-workspace-strategy",
1574        &raw.linear_workspace_strategy,
1575        &["worktree", "copy", "none"],
1576    )?;
1577    if raw.linear_limit == 0 {
1578        return Err("--linear-limit requires a positive integer.".to_string());
1579    }
1580    if raw.linear_max_concurrency == 0 {
1581        return Err("--linear-max-concurrency requires a positive integer.".to_string());
1582    }
1583    if raw.linear_max_polls == Some(0) {
1584        return Err("--linear-max-polls requires a positive integer.".to_string());
1585    }
1586    if raw.max_member_chars == 0 {
1587        return Err("--max-member-chars requires a positive integer.".to_string());
1588    }
1589    if raw.max_prompt_chars < 16_384 {
1590        return Err("--max-prompt-chars must be at least 16384.".to_string());
1591    }
1592    if raw.max_prompt_chars > PROVIDER_PROMPT_HARD_LIMIT_CHARS {
1593        return Err(format!(
1594            "--max-prompt-chars cannot exceed {PROVIDER_PROMPT_HARD_LIMIT_CHARS}; provider CLIs reject larger prompts."
1595        ));
1596    }
1597    if raw
1598        .linear_endpoint
1599        .as_ref()
1600        .is_some_and(|value| value.trim().is_empty())
1601    {
1602        return Err("--linear-endpoint requires a non-empty value.".to_string());
1603    }
1604    for phase in raw
1605        .delivery_phases
1606        .iter()
1607        .map(|phase| phase.trim())
1608        .filter(|phase| !phase.is_empty())
1609    {
1610        validate_choice(
1611            "--delivery-phases",
1612            phase,
1613            &["plan", "implement", "verify", "ship"],
1614        )?;
1615    }
1616
1617    let mut prompt = raw.prompt.join(" ");
1618    if prompt.trim().is_empty() && !io::stdin().is_terminal() {
1619        let mut stdin = String::new();
1620        io::stdin()
1621            .read_to_string(&mut stdin)
1622            .map_err(|error| format!("Failed to read stdin: {error}"))?;
1623        prompt = stdin;
1624    }
1625    let cwd = raw.cwd.canonicalize().unwrap_or_else(|_| raw.cwd.clone());
1626
1627    Ok(ResolvedArgs {
1628        raw,
1629        members,
1630        prompt,
1631        cwd,
1632    })
1633}
1634
1635fn apply_resume_prompt(resolved: &mut ResolvedArgs) -> Result<(), String> {
1636    let Some(resume) = &resolved.raw.resume else {
1637        return Ok(());
1638    };
1639    let run_dir = resolve_resume_dir(&resolved.cwd, resume);
1640    if !run_dir.exists() {
1641        return Err(format!(
1642            "--resume run directory was not found: {}",
1643            run_dir.display()
1644        ));
1645    }
1646    if !resolved.prompt.trim().is_empty() {
1647        return Ok(());
1648    }
1649    if let Some(prompt) = read_resume_prompt(&run_dir)? {
1650        resolved.prompt = prompt;
1651        return Ok(());
1652    }
1653    Err(format!(
1654        "--resume could not find a saved prompt in {}",
1655        run_dir.display()
1656    ))
1657}
1658
1659fn read_resume_prompt(run_dir: &Path) -> Result<Option<String>, String> {
1660    let state_path = run_dir.join("state.json");
1661    if state_path.exists() {
1662        let text = fs::read_to_string(&state_path)
1663            .map_err(|error| format!("Failed to read {}: {error}", state_path.display()))?;
1664        let value: Value = serde_json::from_str(&text)
1665            .map_err(|error| format!("Failed to parse {}: {error}", state_path.display()))?;
1666        if let Some(prompt) = value.get("prompt").and_then(Value::as_str) {
1667            return Ok(Some(prompt.to_string()));
1668        }
1669    }
1670
1671    let prompt_path = run_dir.join("prompt.txt");
1672    if prompt_path.exists() {
1673        return fs::read_to_string(&prompt_path)
1674            .map(Some)
1675            .map_err(|error| format!("Failed to read {}: {error}", prompt_path.display()));
1676    }
1677    Ok(None)
1678}
1679
1680fn resolve_resume_dir(cwd: &Path, resume: &Path) -> PathBuf {
1681    if resume.is_absolute() {
1682        resume.to_path_buf()
1683    } else {
1684        cwd.join(resume)
1685    }
1686}
1687
1688fn resolve_members(raw: &CliArgs) -> Result<Vec<String>, String> {
1689    let explicit_members =
1690        !raw.members.is_empty() || raw.all || raw.codex || raw.claude || raw.gemini;
1691    let mut members = if handshake_requested(raw) && !explicit_members {
1692        default_handshake_members(raw)?
1693    } else if raw.members.is_empty() {
1694        ENGINES
1695            .iter()
1696            .map(|name| (*name).to_string())
1697            .collect::<Vec<_>>()
1698    } else {
1699        raw.members
1700            .iter()
1701            .map(|name| name.trim().to_string())
1702            .filter(|name| !name.is_empty())
1703            .collect::<Vec<_>>()
1704    };
1705
1706    if raw.all {
1707        members = ENGINES.iter().map(|name| (*name).to_string()).collect();
1708    }
1709    if raw.no_codex {
1710        members.retain(|name| name != "codex");
1711    }
1712    if raw.no_claude {
1713        members.retain(|name| name != "claude");
1714    }
1715    if raw.no_gemini {
1716        members.retain(|name| name != "gemini");
1717    }
1718    for enabled in [
1719        (raw.codex, "codex"),
1720        (raw.claude, "claude"),
1721        (raw.gemini, "gemini"),
1722    ] {
1723        if enabled.0 && !members.iter().any(|name| name == enabled.1) {
1724            members.push(enabled.1.to_string());
1725        }
1726    }
1727
1728    let mut seen = HashSet::new();
1729    members.retain(|member| seen.insert(member.clone()));
1730    for member in &members {
1731        validate_engine_name(member, false, "--members")?;
1732    }
1733    if members.is_empty() {
1734        return Err("At least one member must be enabled.".to_string());
1735    }
1736    Ok(members)
1737}
1738
1739fn handshake_requested(raw: &CliArgs) -> bool {
1740    raw.handshake || raw.planner_mode == PLANNER_MODE_HANDSHAKE || !raw.handshake_agents.is_empty()
1741}
1742
1743fn default_handshake_members(raw: &CliArgs) -> Result<Vec<String>, String> {
1744    let mut members = Vec::new();
1745    for spec in raw
1746        .handshake_agents
1747        .iter()
1748        .map(|value| parse_handshake_agent_spec(value))
1749    {
1750        let spec = spec?;
1751        if !members.iter().any(|member| member == &spec.provider) {
1752            members.push(spec.provider);
1753        }
1754    }
1755    if members.is_empty() {
1756        let provider = raw
1757            .handshake_provider
1758            .as_deref()
1759            .or(raw.planner.as_deref())
1760            .or(raw.lead.as_deref())
1761            .unwrap_or("codex");
1762        validate_engine_name(provider, false, "--handshake-provider")?;
1763        members.push(provider.to_string());
1764    }
1765    Ok(members)
1766}
1767
1768fn validate_engine_name(name: &str, allow_auto: bool, flag: &str) -> Result<(), String> {
1769    if allow_auto && name == "auto" {
1770        return Ok(());
1771    }
1772    if Engine::parse(name).is_some() {
1773        Ok(())
1774    } else {
1775        Err(format!(
1776            "{flag} must be one of: {}",
1777            if allow_auto {
1778                "auto, codex, claude, gemini"
1779            } else {
1780                "codex, claude, gemini"
1781            }
1782        ))
1783    }
1784}
1785
1786fn validate_provider_effort(provider: &str, value: Option<&str>) -> Result<(), String> {
1787    let Some(value) = value else {
1788        return Ok(());
1789    };
1790    let engine = Engine::parse(provider).expect("provider effort validation uses known engines");
1791    if engine.allowed_efforts().contains(&value) {
1792        Ok(())
1793    } else {
1794        Err(format!("Unsupported --{provider}-effort value: {value}"))
1795    }
1796}
1797
1798fn validate_choice(flag: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
1799    if allowed.contains(&value) {
1800        Ok(())
1801    } else {
1802        Err(format!("{flag} must be one of: {}", allowed.join(", ")))
1803    }
1804}
1805
1806fn validate_handshake_sub_agents(value: &str) -> Result<(), String> {
1807    handshake_sub_agent_override(value).map(|_| ())
1808}
1809
1810fn handshake_sub_agent_override(value: &str) -> Result<Option<usize>, String> {
1811    let value = value.trim();
1812    if value.eq_ignore_ascii_case("auto") || value.is_empty() {
1813        return Ok(None);
1814    }
1815    value
1816        .parse::<usize>()
1817        .map(Some)
1818        .map_err(|_| "--handshake-sub-agents must be `auto` or a non-negative integer.".to_string())
1819}
1820
1821fn parse_handshake_agent_spec(spec: &str) -> Result<HandshakeAgentSpec, String> {
1822    let spec = spec.trim();
1823    if spec.is_empty() {
1824        return Err("--handshake-agent cannot be empty.".to_string());
1825    }
1826    let (role, rest) = spec
1827        .split_once('=')
1828        .or_else(|| spec.split_once(':'))
1829        .ok_or_else(|| {
1830            "--handshake-agent must look like ROLE=PROVIDER[:SUB_AGENTS].".to_string()
1831        })?;
1832    let role = normalize_handshake_role(role)?;
1833    let mut parts = rest.split(':');
1834    let provider = parts
1835        .next()
1836        .map(str::trim)
1837        .filter(|value| !value.is_empty())
1838        .ok_or_else(|| "--handshake-agent provider cannot be empty.".to_string())?;
1839    validate_engine_name(provider, false, "--handshake-agent")?;
1840    let sub_agents = parts
1841        .next()
1842        .map(str::trim)
1843        .filter(|value| !value.is_empty())
1844        .map(|value| {
1845            value.parse::<usize>().map_err(|_| {
1846                "--handshake-agent sub-agent count must be a non-negative integer.".to_string()
1847            })
1848        })
1849        .transpose()?;
1850    if parts.next().is_some() {
1851        return Err("--handshake-agent must look like ROLE=PROVIDER[:SUB_AGENTS].".to_string());
1852    }
1853    Ok(HandshakeAgentSpec {
1854        role,
1855        provider: provider.to_string(),
1856        sub_agents,
1857    })
1858}
1859
1860fn normalize_handshake_role(role: &str) -> Result<String, String> {
1861    match role.trim().to_ascii_lowercase().as_str() {
1862        "planner" | "plan" => Ok("planner".to_string()),
1863        "executor" | "execute" | "exec" | "builder" | "implementer" => Ok("executor".to_string()),
1864        "reviewer" | "review" | "verifier" | "critic" => Ok("reviewer".to_string()),
1865        _ => Err("--handshake-agent role must be one of: planner, executor, reviewer.".to_string()),
1866    }
1867}
1868
1869fn collect_auth_statuses(resolved: &ResolvedArgs) -> Vec<ProviderAuthStatus> {
1870    collect_auth_statuses_with_cancel(resolved, None)
1871}
1872
1873fn collect_auth_statuses_with_cancel(
1874    resolved: &ResolvedArgs,
1875    cancel: Option<Arc<AtomicBool>>,
1876) -> Vec<ProviderAuthStatus> {
1877    resolved
1878        .members
1879        .iter()
1880        .map(|provider| provider_auth_status_with_cancel(provider, resolved, cancel.clone()))
1881        .collect()
1882}
1883
1884fn provider_auth_status(provider: &str, resolved: &ResolvedArgs) -> ProviderAuthStatus {
1885    provider_auth_status_with_cancel(provider, resolved, None)
1886}
1887
1888fn provider_auth_status_with_cancel(
1889    provider: &str,
1890    resolved: &ResolvedArgs,
1891    cancel: Option<Arc<AtomicBool>>,
1892) -> ProviderAuthStatus {
1893    let auth = provider_auth(resolved, provider);
1894    if cancel
1895        .as_ref()
1896        .is_some_and(|cancel| cancel.load(Ordering::Relaxed))
1897    {
1898        return ProviderAuthStatus {
1899            provider: provider.to_string(),
1900            configured: false,
1901            status: "cancelled".to_string(),
1902            source: auth,
1903            detail: "Auth status refresh cancelled.".to_string(),
1904        };
1905    }
1906    match provider {
1907        "codex" => {
1908            let bin = resolve_binary("codex");
1909            let args = vec!["login".to_string(), "status".to_string()];
1910            status_from_command(provider, &auth, &bin, &args, &resolved.cwd, cancel)
1911        }
1912        "claude" => {
1913            let bin = resolve_binary("claude");
1914            let args = vec![
1915                "auth".to_string(),
1916                "status".to_string(),
1917                "--text".to_string(),
1918            ];
1919            status_from_command(provider, &auth, &bin, &args, &resolved.cwd, cancel)
1920        }
1921        "gemini" => {
1922            let has_api_key = std::env::var("GEMINI_API_KEY")
1923                .ok()
1924                .is_some_and(|value| !value.trim().is_empty());
1925            let home = std::env::var("HOME").unwrap_or_default();
1926            let has_oauth_file = !home.is_empty()
1927                && [
1928                    ".gemini/oauth_creds.json",
1929                    ".gemini/oauth_tokens.json",
1930                    ".gemini/settings.json",
1931                ]
1932                .iter()
1933                .any(|path| Path::new(&home).join(path).exists());
1934            let configured = has_api_key || has_oauth_file;
1935            ProviderAuthStatus {
1936                provider: provider.to_string(),
1937                configured,
1938                status: if configured { "configured" } else { "unknown" }.to_string(),
1939                source: auth,
1940                detail: if has_api_key {
1941                    "GEMINI_API_KEY is present.".to_string()
1942                } else if has_oauth_file {
1943                    "Gemini local auth/config files are present.".to_string()
1944                } else {
1945                    "Gemini CLI does not currently expose a stable headless auth status command; use Social login from Studio or run gemini interactively.".to_string()
1946                },
1947            }
1948        }
1949        _ => ProviderAuthStatus {
1950            provider: provider.to_string(),
1951            configured: false,
1952            status: "unknown".to_string(),
1953            source: auth,
1954            detail: "Unknown provider.".to_string(),
1955        },
1956    }
1957}
1958
1959fn status_from_command(
1960    provider: &str,
1961    auth: &str,
1962    bin: &str,
1963    args: &[String],
1964    cwd: &Path,
1965    cancel: Option<Arc<AtomicBool>>,
1966) -> ProviderAuthStatus {
1967    let result = run_command(CommandRequest::new(bin, args, cwd, 15_000).cancel(cancel));
1968    let configured = result.code == Some(0);
1969    ProviderAuthStatus {
1970        provider: provider.to_string(),
1971        configured,
1972        status: if configured {
1973            "configured"
1974        } else if result.cancelled {
1975            "cancelled"
1976        } else if result.error.is_some() {
1977            "missing-cli"
1978        } else {
1979            "not-configured"
1980        }
1981        .to_string(),
1982        source: auth.to_string(),
1983        detail: sanitize_status_detail(&compact_failure(&result)),
1984    }
1985}
1986
1987fn sanitize_status_detail(detail: &str) -> String {
1988    let cleaned = strip_terminal_control_sequences(detail);
1989    let cleaned = normalize_escaped_log_text(&cleaned);
1990    let detail = cleaned.trim();
1991    if detail.is_empty() {
1992        return "No status detail returned.".to_string();
1993    }
1994    let detail = detail
1995        .lines()
1996        .take(6)
1997        .map(str::trim)
1998        .filter(|line| !line.is_empty())
1999        .map(redact_possible_secret)
2000        .collect::<Vec<_>>()
2001        .join(" ");
2002    redact_local_paths(&detail)
2003}
2004
2005fn normalize_escaped_log_text(value: &str) -> String {
2006    value
2007        .replace("\\\\r", " ")
2008        .replace("\\\\n", " ")
2009        .replace("\\\\t", " ")
2010        .replace("\\r", " ")
2011        .replace("\\n", " ")
2012        .replace("\\t", " ")
2013        .replace("\\\\\"", "\"")
2014        .replace("\\\"", "\"")
2015}
2016
2017fn strip_terminal_control_sequences(value: &str) -> String {
2018    let needs_cleaning = value.as_bytes().contains(&0x1b)
2019        || value.contains('\u{9b}')
2020        || value.contains('\\')
2021        || value.contains("u001b")
2022        || value.contains("u001B")
2023        || value.contains("u{1b}")
2024        || value.contains("u{1B}")
2025        || value
2026            .chars()
2027            .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'));
2028    if !needs_cleaning {
2029        return value.to_string();
2030    }
2031
2032    let chars = value.chars().collect::<Vec<_>>();
2033    let mut output = String::with_capacity(value.len());
2034    let mut index = 0;
2035    while index < chars.len() {
2036        if chars[index] == '\x1b' {
2037            index = skip_terminal_escape(&chars, index + 1);
2038            continue;
2039        }
2040        if chars[index] == '\u{9b}' {
2041            index = skip_csi_sequence(&chars, index + 1);
2042            continue;
2043        }
2044        if let Some(after_escape) = escaped_escape_end(&chars, index) {
2045            index = skip_terminal_escape(&chars, after_escape);
2046            continue;
2047        }
2048        if let Some(after_escape) = bare_escaped_escape_end(&chars, index) {
2049            index = skip_terminal_escape(&chars, after_escape);
2050            continue;
2051        }
2052        let ch = chars[index];
2053        if ch.is_control() && !matches!(ch, '\n' | '\r' | '\t') {
2054            output.push(' ');
2055        } else {
2056            output.push(ch);
2057        }
2058        index += 1;
2059    }
2060    output
2061}
2062
2063fn bare_escaped_escape_end(chars: &[char], index: usize) -> Option<usize> {
2064    if starts_with_chars(chars, index, &['u', '0', '0', '1', 'b'])
2065        || starts_with_chars(chars, index, &['u', '0', '0', '1', 'B'])
2066    {
2067        return Some(index + 5);
2068    }
2069    if starts_with_chars(chars, index, &['u', '{', '1', 'b', '}'])
2070        || starts_with_chars(chars, index, &['u', '{', '1', 'B', '}'])
2071    {
2072        return Some(index + 5);
2073    }
2074    None
2075}
2076
2077fn escaped_escape_end(chars: &[char], index: usize) -> Option<usize> {
2078    if chars.get(index) != Some(&'\\') {
2079        return None;
2080    }
2081    let mut cursor = index;
2082    while chars.get(cursor) == Some(&'\\') {
2083        cursor += 1;
2084    }
2085    if starts_with_chars(chars, cursor, &['u', '0', '0', '1', 'b'])
2086        || starts_with_chars(chars, cursor, &['u', '0', '0', '1', 'B'])
2087    {
2088        return Some(cursor + 5);
2089    }
2090    if starts_with_chars(chars, cursor, &['u', '{', '1', 'b', '}'])
2091        || starts_with_chars(chars, cursor, &['u', '{', '1', 'B', '}'])
2092    {
2093        return Some(cursor + 5);
2094    }
2095    if starts_with_chars(chars, cursor, &['x', '1', 'b'])
2096        || starts_with_chars(chars, cursor, &['x', '1', 'B'])
2097    {
2098        return Some(cursor + 3);
2099    }
2100    if starts_with_chars(chars, cursor, &['0', '3', '3']) {
2101        return Some(cursor + 3);
2102    }
2103    if starts_with_chars(chars, cursor, &['e']) {
2104        return Some(cursor + 1);
2105    }
2106    None
2107}
2108
2109fn starts_with_chars(chars: &[char], index: usize, needle: &[char]) -> bool {
2110    chars
2111        .get(index..index.saturating_add(needle.len()))
2112        .is_some_and(|slice| slice == needle)
2113}
2114
2115fn skip_terminal_escape(chars: &[char], index: usize) -> usize {
2116    let Some(ch) = chars.get(index).copied() else {
2117        return index;
2118    };
2119    match ch {
2120        '[' => skip_csi_sequence(chars, index + 1),
2121        ']' => skip_terminated_control_string(chars, index + 1),
2122        'P' | '^' | '_' | 'X' => skip_terminated_control_string(chars, index + 1),
2123        '(' | ')' | '*' | '+' | '-' | '.' | '/' => (index + 2).min(chars.len()),
2124        '@'..='_' => (index + 1).min(chars.len()),
2125        _ => index,
2126    }
2127}
2128
2129fn skip_csi_sequence(chars: &[char], mut index: usize) -> usize {
2130    while index < chars.len() {
2131        let ch = chars[index];
2132        index += 1;
2133        if ('@'..='~').contains(&ch) {
2134            break;
2135        }
2136    }
2137    index
2138}
2139
2140fn skip_terminated_control_string(chars: &[char], mut index: usize) -> usize {
2141    while index < chars.len() {
2142        if chars[index] == '\u{7}' {
2143            return index + 1;
2144        }
2145        if chars[index] == '\x1b' && chars.get(index + 1) == Some(&'\\') {
2146            return index + 2;
2147        }
2148        if let Some(after_escape) = escaped_escape_end(chars, index) {
2149            if chars.get(after_escape) == Some(&'\\') {
2150                return after_escape + 1;
2151            }
2152            index = after_escape;
2153            continue;
2154        }
2155        index += 1;
2156    }
2157    index
2158}
2159
2160fn redact_possible_secret(value: &str) -> String {
2161    value
2162        .split_whitespace()
2163        .map(|token| {
2164            if looks_sensitive_token(token) {
2165                "[redacted]"
2166            } else if looks_like_email(token) {
2167                "[email]"
2168            } else {
2169                token
2170            }
2171        })
2172        .collect::<Vec<_>>()
2173        .join(" ")
2174}
2175
2176fn looks_sensitive_token(token: &str) -> bool {
2177    let trimmed =
2178        token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_');
2179    trimmed.len() >= 28
2180        && (trimmed.starts_with("sk-")
2181            || trimmed.starts_with("sk_")
2182            || trimmed.starts_with("gho_")
2183            || trimmed.starts_with("ghp_")
2184            || trimmed.starts_with("ghs_")
2185            || trimmed.starts_with("ghu_")
2186            || trimmed.starts_with("AIza")
2187            || trimmed.starts_with("AQ."))
2188}
2189
2190fn looks_like_email(token: &str) -> bool {
2191    let trimmed = token.trim_matches(|ch: char| {
2192        !ch.is_ascii_alphanumeric() && !matches!(ch, '@' | '.' | '_' | '-' | '+')
2193    });
2194    trimmed.contains('@') && trimmed.rsplit_once('.').is_some()
2195}
2196
2197fn redact_local_paths(value: &str) -> String {
2198    let mut redacted = value.to_string();
2199    if let Ok(home) = std::env::var("HOME") {
2200        if !home.trim().is_empty() {
2201            redacted = redacted.replace(&home, "~");
2202        }
2203    }
2204    redacted
2205}
2206
2207fn render_auth_statuses(statuses: &[ProviderAuthStatus]) -> String {
2208    let mut lines = vec!["Provider auth status".to_string()];
2209    for status in statuses {
2210        lines.push(format!(
2211            "- {}: {} via {} ({})",
2212            status.provider, status.status, status.source, status.detail
2213        ));
2214    }
2215    lines.join("\n")
2216}
2217
2218fn collect_provider_capability_statuses(resolved: &ResolvedArgs) -> Vec<ProviderCapabilityStatus> {
2219    collect_provider_capability_statuses_with_cancel(resolved, None)
2220}
2221
2222fn collect_provider_capability_statuses_with_cancel(
2223    resolved: &ResolvedArgs,
2224    cancel: Option<Arc<AtomicBool>>,
2225) -> Vec<ProviderCapabilityStatus> {
2226    resolved
2227        .members
2228        .iter()
2229        .map(|provider| provider_capability_status(provider, resolved, cancel.clone()))
2230        .collect()
2231}
2232
2233fn provider_capability_status(
2234    provider: &str,
2235    resolved: &ResolvedArgs,
2236    cancel: Option<Arc<AtomicBool>>,
2237) -> ProviderCapabilityStatus {
2238    match provider {
2239        "codex" => {
2240            let bin = resolve_binary("codex");
2241            let mcp = run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone());
2242            ProviderCapabilityStatus {
2243                provider: provider.to_string(),
2244                mcp,
2245                skills: None,
2246                tools: Some(run_capability_probe(
2247                    &bin,
2248                    &["plugin", "marketplace", "--help"],
2249                    &resolved.cwd,
2250                    cancel,
2251                )),
2252                detail:
2253                    "Codex inherits ~/.codex config unless --codex-capabilities override is set."
2254                        .to_string(),
2255            }
2256        }
2257        "claude" => {
2258            let bin = resolve_binary("claude");
2259            ProviderCapabilityStatus {
2260                provider: provider.to_string(),
2261                mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
2262                skills: Some(run_capability_probe(
2263                    &bin,
2264                    &["plugin", "list"],
2265                    &resolved.cwd,
2266                    cancel.clone(),
2267                )),
2268                tools: Some(run_capability_probe(
2269                    &bin,
2270                    &["agents"],
2271                    &resolved.cwd,
2272                    cancel,
2273                )),
2274                detail: "Claude override can manage MCP config, tools, agents, plugin dirs, and slash-command skills.".to_string(),
2275            }
2276        }
2277        "gemini" => {
2278            let bin = resolve_binary("gemini");
2279            ProviderCapabilityStatus {
2280                provider: provider.to_string(),
2281                mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
2282                skills: Some(run_capability_probe(
2283                    &bin,
2284                    &["skills", "list"],
2285                    &resolved.cwd,
2286                    cancel.clone(),
2287                )),
2288                tools: Some(run_capability_probe(
2289                    &bin,
2290                    &["extensions", "list"],
2291                    &resolved.cwd,
2292                    cancel,
2293                )),
2294                detail: "Gemini override can manage settings, extensions, MCP server allowlists, and policy files.".to_string(),
2295            }
2296        }
2297        _ => ProviderCapabilityStatus {
2298            provider: provider.to_string(),
2299            mcp: CommandTelemetry {
2300                command: provider.to_string(),
2301                status: "unknown".to_string(),
2302                detail: "Unknown provider.".to_string(),
2303                exit_code: None,
2304                duration_ms: 0,
2305                stdout_chars: 0,
2306                stderr_chars: 0,
2307                timed_out: false,
2308            },
2309            skills: None,
2310            tools: None,
2311            detail: "Unknown provider.".to_string(),
2312        },
2313    }
2314}
2315
2316fn run_capability_probe(
2317    bin: &str,
2318    args: &[&str],
2319    cwd: &Path,
2320    cancel: Option<Arc<AtomicBool>>,
2321) -> CommandTelemetry {
2322    let args = args
2323        .iter()
2324        .map(|arg| (*arg).to_string())
2325        .collect::<Vec<_>>();
2326    command_telemetry(&run_command(
2327        CommandRequest::new(bin, &args, cwd, 20_000).cancel(cancel),
2328    ))
2329}
2330
2331fn emit_runtime_event(
2332    progress: &Option<ProgressSink>,
2333    fallback_to_stderr: bool,
2334    event: ProgressEvent,
2335) {
2336    if let Some(progress) = progress {
2337        progress(event);
2338    } else if fallback_to_stderr {
2339        eprintln!("{}", event.message);
2340    }
2341}
2342
2343impl RuntimeEventBus {
2344    fn new() -> Self {
2345        Self {
2346            started: Instant::now(),
2347            sequence: Arc::new(AtomicU64::new(1)),
2348            emit_lock: Arc::new(Mutex::new(())),
2349            sinks: Arc::new(Mutex::new(Vec::new())),
2350        }
2351    }
2352
2353    fn add_sink(&self, sink: RuntimeEventSink) {
2354        if let Ok(mut sinks) = self.sinks.lock() {
2355            sinks.push(sink);
2356        }
2357    }
2358
2359    fn progress_sink(&self) -> ProgressSink {
2360        let bus = self.clone();
2361        Arc::new(move |event| bus.emit(event, None, None))
2362    }
2363
2364    fn emit_result(&self, result: &AmonHenResult) {
2365        let status = if is_success(result) { "ok" } else { "error" };
2366        self.emit(
2367            ProgressEvent {
2368                kind: RuntimeEventKind::Result,
2369                provider: Some(result.summary.name.clone()),
2370                role: Some(result.summary.role.clone()),
2371                stage: ProgressStage::Done,
2372                status: Some(status.to_string()),
2373                iteration: Some(result.workflow.iterations),
2374                total_iterations: Some(result.workflow.iterations),
2375                is_sub_agent: false,
2376                duration_ms: Some(result.summary.duration_ms),
2377                token_usage: Some(result.summary.token_usage.clone()),
2378                tool_calls: result.summary.tool_calls.clone(),
2379                message: format!("[amon-hen] result {status}"),
2380            },
2381            Some(Box::new(result.clone())),
2382            None,
2383        );
2384    }
2385
2386    fn emit_linear_result(&self, result: &linear_delivery::LinearDeliveryResult) {
2387        let status = if result.success { "ok" } else { "error" };
2388        self.emit(
2389            progress_event_with_context(
2390                Some("linear"),
2391                Some("delivery"),
2392                ProgressStage::Done,
2393                Some(status),
2394                None,
2395                None,
2396                false,
2397                Some(result.duration_ms),
2398                None,
2399                vec![],
2400                format!("[amon-hen] linear delivery {status}"),
2401            ),
2402            None,
2403            Some(Box::new(result.clone())),
2404        );
2405    }
2406
2407    fn emit(
2408        &self,
2409        progress: ProgressEvent,
2410        result: Option<Box<AmonHenResult>>,
2411        linear_result: Option<Box<linear_delivery::LinearDeliveryResult>>,
2412    ) {
2413        let _emit_guard = self.emit_lock.lock().ok();
2414        let event = RuntimeEvent {
2415            sequence: self.sequence.fetch_add(1, Ordering::SeqCst),
2416            elapsed_ms: self.started.elapsed().as_millis(),
2417            progress,
2418            result,
2419            linear_result,
2420        };
2421        let sinks = self
2422            .sinks
2423            .lock()
2424            .map(|sinks| sinks.clone())
2425            .unwrap_or_default();
2426        for sink in sinks {
2427            sink(event.clone());
2428        }
2429    }
2430}
2431
2432fn progress_event(
2433    provider: Option<&str>,
2434    role: Option<&str>,
2435    stage: ProgressStage,
2436    status: Option<&str>,
2437    message: impl Into<String>,
2438) -> ProgressEvent {
2439    progress_event_with_context(
2440        provider,
2441        role,
2442        stage,
2443        status,
2444        None,
2445        None,
2446        false,
2447        None,
2448        None,
2449        vec![],
2450        message,
2451    )
2452}
2453
2454#[allow(clippy::too_many_arguments)]
2455fn progress_event_with_context(
2456    provider: Option<&str>,
2457    role: Option<&str>,
2458    stage: ProgressStage,
2459    status: Option<&str>,
2460    iteration: Option<usize>,
2461    total_iterations: Option<usize>,
2462    is_sub_agent: bool,
2463    duration_ms: Option<u128>,
2464    token_usage: Option<TokenUsage>,
2465    tool_calls: Vec<ToolUsage>,
2466    message: impl Into<String>,
2467) -> ProgressEvent {
2468    ProgressEvent {
2469        kind: runtime_event_kind(provider, stage, token_usage.as_ref(), &tool_calls),
2470        provider: provider.map(ToString::to_string),
2471        role: role.map(ToString::to_string),
2472        stage,
2473        status: status.map(ToString::to_string),
2474        iteration,
2475        total_iterations,
2476        is_sub_agent,
2477        duration_ms,
2478        token_usage,
2479        tool_calls,
2480        message: message.into(),
2481    }
2482}
2483
2484fn runtime_event_kind(
2485    provider: Option<&str>,
2486    stage: ProgressStage,
2487    token_usage: Option<&TokenUsage>,
2488    tool_calls: &[ToolUsage],
2489) -> RuntimeEventKind {
2490    if !tool_calls.is_empty() {
2491        RuntimeEventKind::ToolUsage
2492    } else if token_usage.is_some() {
2493        RuntimeEventKind::TokenUsage
2494    } else if provider.is_some() {
2495        RuntimeEventKind::Provider
2496    } else if matches!(stage, ProgressStage::Context) {
2497        RuntimeEventKind::Context
2498    } else {
2499        RuntimeEventKind::Iteration
2500    }
2501}
2502
2503fn command_progress(label: Option<&str>, sink: Option<ProgressSink>) -> Option<CommandProgress> {
2504    command_progress_with_input(label, sink, 0)
2505}
2506
2507fn command_progress_with_input(
2508    label: Option<&str>,
2509    sink: Option<ProgressSink>,
2510    input_tokens: usize,
2511) -> Option<CommandProgress> {
2512    label.map(|label| CommandProgress {
2513        label: label.to_string(),
2514        sink,
2515        input_tokens,
2516    })
2517}
2518
2519fn render_provider_capability_statuses(statuses: &[ProviderCapabilityStatus]) -> String {
2520    let mut lines = vec!["Provider capabilities".to_string()];
2521    for status in statuses {
2522        lines.push(format!(
2523            "- {} MCP: {} ({})",
2524            status.provider, status.mcp.status, status.mcp.command
2525        ));
2526        if !status.mcp.detail.trim().is_empty() {
2527            lines.push(format!("  {}", status.mcp.detail));
2528        }
2529        if let Some(skills) = &status.skills {
2530            lines.push(format!(
2531                "  skills/plugins: {} ({})",
2532                skills.status, skills.command
2533            ));
2534            if !skills.detail.trim().is_empty() {
2535                lines.push(format!("  {}", skills.detail));
2536            }
2537        }
2538        if let Some(tools) = &status.tools {
2539            lines.push(format!(
2540                "  tools/extensions: {} ({})",
2541                tools.status, tools.command
2542            ));
2543            if !tools.detail.trim().is_empty() {
2544                lines.push(format!("  {}", tools.detail));
2545            }
2546        }
2547        lines.push(format!("  {}", status.detail));
2548    }
2549    lines.join("\n")
2550}
2551
2552fn run_social_login(resolved: &ResolvedArgs) -> Result<(), String> {
2553    let providers = if resolved.raw.auth_login_providers.is_empty() {
2554        resolved.members.clone()
2555    } else {
2556        resolved.raw.auth_login_providers.clone()
2557    };
2558
2559    for provider in providers {
2560        validate_engine_name(&provider, false, "--auth-login-providers")?;
2561        let (bin, args, instruction): (String, Vec<String>, &str) =
2562            match provider.as_str() {
2563                "codex" => {
2564                    let mut args = vec!["login".to_string()];
2565                    if resolved.raw.auth_device_code {
2566                        args.push("--device-auth".to_string());
2567                    }
2568                    (
2569                        resolve_binary("codex"),
2570                        args,
2571                        "Complete the Codex browser login. Deeplinks and pasted codes are supported by the provider CLI when prompted.",
2572                    )
2573                }
2574                "claude" => {
2575                    let mut args = vec!["auth".to_string(), "login".to_string()];
2576                    match resolved.raw.claude_login_mode.as_str() {
2577                        "console" => args.push("--console".to_string()),
2578                        "sso" => args.push("--sso".to_string()),
2579                        _ => args.push("--claudeai".to_string()),
2580                    }
2581                    if let Some(email) = &resolved.raw.claude_login_email {
2582                        push_arg(&mut args, "--email", email.clone());
2583                    }
2584                    (
2585                        resolve_binary("claude"),
2586                        args,
2587                        "Complete the Claude browser login. Paste any shown login code into this terminal when prompted; deeplinks are opened when the CLI emits them.",
2588                    )
2589                }
2590                "gemini" => (
2591                    resolve_binary("gemini"),
2592                    vec![],
2593                    "Use the Gemini CLI auth selector, choose browser/social login, and complete local callback or code paste when prompted.",
2594                ),
2595                _ => unreachable!(),
2596            };
2597        eprintln!(
2598            "[auth] launching {provider}: {}",
2599            format_command(&bin, &args)
2600        );
2601        eprintln!("[auth] {provider}: {instruction}");
2602        let result = run_interactive_auth_command(
2603            &bin,
2604            &args,
2605            &resolved.cwd,
2606            resolved.raw.auth_timeout * 1000,
2607            !resolved.raw.no_auth_open_browser,
2608            &provider,
2609        );
2610        if result.code.unwrap_or(1) != 0 {
2611            return Err(format!(
2612                "{provider} social login failed: {}",
2613                compact_failure(&result)
2614            ));
2615        }
2616        let status = provider_auth_status(&provider, resolved);
2617        eprintln!("[auth] {provider}: {} ({})", status.status, status.detail);
2618    }
2619    Ok(())
2620}
2621
2622fn run_interactive_auth_command(
2623    command: &str,
2624    args: &[String],
2625    cwd: &Path,
2626    timeout_ms: u64,
2627    open_browser: bool,
2628    provider: &str,
2629) -> CommandResult {
2630    let started = Instant::now();
2631    let mut process = Command::new(command);
2632    process
2633        .args(args)
2634        .current_dir(cwd)
2635        .stdin(Stdio::inherit())
2636        .stdout(Stdio::piped())
2637        .stderr(Stdio::piped());
2638    configure_process_group(&mut process);
2639    let mut child = match process.spawn() {
2640        Ok(child) => child,
2641        Err(error) => {
2642            return CommandResult {
2643                command: command.to_string(),
2644                args: args.to_vec(),
2645                code: None,
2646                stdout: String::new(),
2647                stderr: String::new(),
2648                timed_out: false,
2649                cancelled: false,
2650                error: Some(error.to_string()),
2651                timeout_ms,
2652                duration_ms: started.elapsed().as_millis(),
2653            }
2654        }
2655    };
2656
2657    let seen_urls = Arc::new(Mutex::new(HashSet::new()));
2658    let stdout = child.stdout.take().map(|pipe| {
2659        read_auth_pipe(
2660            pipe,
2661            true,
2662            provider.to_string(),
2663            open_browser,
2664            Arc::clone(&seen_urls),
2665        )
2666    });
2667    let stderr = child.stderr.take().map(|pipe| {
2668        read_auth_pipe(
2669            pipe,
2670            false,
2671            provider.to_string(),
2672            open_browser,
2673            Arc::clone(&seen_urls),
2674        )
2675    });
2676    let timeout = Duration::from_millis(timeout_ms);
2677    let mut timed_out = false;
2678    let code;
2679    loop {
2680        match child.try_wait() {
2681            Ok(Some(status)) => {
2682                code = status.code();
2683                break;
2684            }
2685            Ok(None) => {
2686                if timeout_ms > 0 && started.elapsed() >= timeout {
2687                    timed_out = true;
2688                    terminate_child_tree(&mut child);
2689                    let status = child.wait().ok();
2690                    code = status.and_then(|status| status.code());
2691                    break;
2692                }
2693                thread::sleep(Duration::from_millis(50));
2694            }
2695            Err(error) => {
2696                return CommandResult {
2697                    command: command.to_string(),
2698                    args: args.to_vec(),
2699                    code: None,
2700                    stdout: String::new(),
2701                    stderr: String::new(),
2702                    timed_out,
2703                    cancelled: false,
2704                    error: Some(error.to_string()),
2705                    timeout_ms,
2706                    duration_ms: started.elapsed().as_millis(),
2707                }
2708            }
2709        }
2710    }
2711
2712    CommandResult {
2713        command: command.to_string(),
2714        args: args.to_vec(),
2715        code,
2716        stdout: stdout
2717            .and_then(|handle| handle.join().ok())
2718            .unwrap_or_default(),
2719        stderr: stderr
2720            .and_then(|handle| handle.join().ok())
2721            .unwrap_or_default(),
2722        timed_out,
2723        cancelled: false,
2724        error: None,
2725        timeout_ms,
2726        duration_ms: started.elapsed().as_millis(),
2727    }
2728}
2729
2730fn read_auth_pipe<R>(
2731    mut pipe: R,
2732    stdout: bool,
2733    provider: String,
2734    open_browser: bool,
2735    seen_urls: Arc<Mutex<HashSet<String>>>,
2736) -> thread::JoinHandle<String>
2737where
2738    R: Read + Send + 'static,
2739{
2740    thread::spawn(move || {
2741        let mut text = String::new();
2742        let mut buffer = [0u8; 4096];
2743        loop {
2744            let read = match pipe.read(&mut buffer) {
2745                Ok(0) => break,
2746                Ok(read) => read,
2747                Err(_) => break,
2748            };
2749            let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
2750            text.push_str(&chunk);
2751            if stdout {
2752                print!("{chunk}");
2753                let _ = io::stdout().flush();
2754            } else {
2755                eprint!("{chunk}");
2756                let _ = io::stderr().flush();
2757            }
2758            if open_browser {
2759                for url in extract_auth_urls(&chunk) {
2760                    let mut seen = seen_urls.lock().ok();
2761                    if seen.as_mut().is_some_and(|seen| !seen.insert(url.clone())) {
2762                        continue;
2763                    }
2764                    eprintln!("[auth] {provider}: opening {url}");
2765                    if let Err(error) = open_browser_url(&url) {
2766                        eprintln!("[auth] {provider}: failed to open {url}: {error}");
2767                    }
2768                }
2769            }
2770        }
2771        text
2772    })
2773}
2774
2775fn extract_auth_urls(text: &str) -> Vec<String> {
2776    text.split_whitespace()
2777        .map(|token| {
2778            token.trim_matches(|ch: char| {
2779                matches!(ch, '<' | '>' | '"' | '\'' | ')' | '(' | ',' | ';')
2780            })
2781        })
2782        .map(|token| token.trim_end_matches(['.', ':', ',', ';']))
2783        .filter(|token| {
2784            token.starts_with("http://")
2785                || token.starts_with("https://")
2786                || token.starts_with("codex://")
2787                || token.starts_with("openai://")
2788                || token.starts_with("claude://")
2789                || token.starts_with("anthropic://")
2790                || token.starts_with("gemini://")
2791                || token.starts_with("google://")
2792        })
2793        .map(ToString::to_string)
2794        .collect()
2795}
2796
2797fn open_browser_url(url: &str) -> Result<(), String> {
2798    let (command, args): (&str, Vec<String>) = if cfg!(target_os = "macos") {
2799        ("open", vec![url.to_string()])
2800    } else if cfg!(target_os = "windows") {
2801        (
2802            "cmd",
2803            vec![
2804                "/c".to_string(),
2805                "start".to_string(),
2806                "".to_string(),
2807                url.to_string(),
2808            ],
2809        )
2810    } else {
2811        ("xdg-open", vec![url.to_string()])
2812    };
2813    Command::new(command)
2814        .args(args)
2815        .stdin(Stdio::null())
2816        .stdout(Stdio::null())
2817        .stderr(Stdio::null())
2818        .spawn()
2819        .map(|mut child| {
2820            let _ = child.try_wait();
2821        })
2822        .map_err(|error| error.to_string())
2823}
2824
2825fn build_prompt_context_with_progress(
2826    resolved: &ResolvedArgs,
2827    progress: Option<ProgressSink>,
2828) -> Result<PromptContext, String> {
2829    let mut prompt = resolved.prompt.trim().to_string();
2830    let mut sections = Vec::new();
2831    let mut commands = Vec::new();
2832
2833    for file in &resolved.raw.files {
2834        let path = if file.is_absolute() {
2835            file.clone()
2836        } else {
2837            resolved.cwd.join(file)
2838        };
2839        let content = fs::read_to_string(&path)
2840            .map_err(|error| format!("Failed to read tagged file {}: {error}", path.display()))?;
2841        sections.push(format!("--- file: {} ---\n{}", file.display(), content));
2842    }
2843
2844    for command in &resolved.raw.commands {
2845        let shell = if cfg!(windows) { "cmd" } else { "sh" };
2846        let args = if cfg!(windows) {
2847            vec!["/C".to_string(), command.clone()]
2848        } else {
2849            vec!["-lc".to_string(), command.clone()]
2850        };
2851        let live_label = (resolved.raw.verbose || progress.is_some())
2852            .then(|| format!("context command `{}`", truncate(command, 80)));
2853        emit_runtime_event(
2854            &progress,
2855            false,
2856            progress_event(
2857                None,
2858                None,
2859                ProgressStage::Context,
2860                None,
2861                format!("[amon-hen] context command `{}`", truncate(command, 80)),
2862            ),
2863        );
2864        let result = run_command(
2865            CommandRequest::new(shell, &args, &resolved.cwd, resolved.raw.timeout * 1000)
2866                .progress(command_progress(live_label.as_deref(), progress.clone())),
2867        );
2868        let telemetry = command_telemetry(&result);
2869        let mut event = progress_event(
2870            None,
2871            None,
2872            ProgressStage::Done,
2873            Some(&telemetry.status),
2874            format!(
2875                "[amon-hen] context command `{}` {} in {:.1}s",
2876                truncate(command, 80),
2877                telemetry.status,
2878                result.duration_ms as f64 / 1000.0
2879            ),
2880        );
2881        event.kind = RuntimeEventKind::Context;
2882        emit_runtime_event(&progress, false, event);
2883        commands.push(telemetry);
2884        sections.push(format!(
2885            "--- command: {} (exit {}) ---\nstdout:\n{}\nstderr:\n{}",
2886            command,
2887            result
2888                .code
2889                .map(|code| code.to_string())
2890                .unwrap_or_else(|| "unknown".to_string()),
2891            result.stdout.trim(),
2892            result.stderr.trim()
2893        ));
2894    }
2895
2896    if !sections.is_empty() {
2897        prompt.push_str("\n\nPrompt context:\n");
2898        prompt.push_str(&sections.join("\n\n"));
2899    }
2900    Ok(PromptContext { prompt, commands })
2901}
2902
2903fn run_amon_hen_with_progress(
2904    resolved: &ResolvedArgs,
2905    query: String,
2906    prompt_commands: Vec<CommandTelemetry>,
2907    progress: Option<ProgressSink>,
2908) -> AmonHenResult {
2909    run_amon_hen_with_progress_and_cancel(resolved, query, prompt_commands, progress, None)
2910}
2911
2912fn run_amon_hen_with_progress_and_cancel(
2913    resolved: &ResolvedArgs,
2914    query: String,
2915    prompt_commands: Vec<CommandTelemetry>,
2916    progress: Option<ProgressSink>,
2917    cancel: Option<Arc<AtomicBool>>,
2918) -> AmonHenResult {
2919    let workflow = build_workflow(resolved);
2920    let mut previous_iteration = Vec::new();
2921    let mut final_members = Vec::new();
2922    let mut iterations = Vec::new();
2923    let mut final_consensus = None;
2924
2925    for iteration in 1..=workflow.iterations {
2926        let iteration_started = Instant::now();
2927        let handoff_context =
2928            iteration_handoff_context(&previous_iteration, resolved.raw.max_member_chars);
2929        emit_runtime_event(
2930            &progress,
2931            resolved.raw.verbose,
2932            progress_event_with_context(
2933                None,
2934                None,
2935                ProgressStage::Start,
2936                None,
2937                Some(iteration),
2938                Some(workflow.iterations),
2939                false,
2940                None,
2941                None,
2942                vec![],
2943                format!(
2944                    "[amon-hen] iteration {iteration}/{} started",
2945                    workflow.iterations
2946                ),
2947            ),
2948        );
2949        final_members = run_iteration(
2950            resolved,
2951            &query,
2952            &workflow,
2953            iteration,
2954            &previous_iteration,
2955            progress.clone(),
2956            cancel.clone(),
2957        );
2958        let mut record = iteration_record(
2959            iteration,
2960            workflow.iterations,
2961            final_members.clone(),
2962            iteration_started.elapsed().as_millis(),
2963            handoff_context,
2964            None,
2965        );
2966        if consensus_enabled(resolved) {
2967            let consensus = run_consensus_loop(ConsensusRunInput {
2968                resolved,
2969                query: &query,
2970                workflow: &workflow,
2971                iteration,
2972                members: &final_members,
2973                prior_iterations: &iterations,
2974                progress: progress.clone(),
2975                cancel: cancel.clone(),
2976            });
2977            let approved = consensus.approved;
2978            record.consensus = Some(consensus.clone());
2979            final_consensus = Some(consensus);
2980            iterations.push(record);
2981            previous_iteration = final_members.clone();
2982            if approved && resolved.raw.stop_when == STOP_WHEN_CONSENSUS {
2983                break;
2984            }
2985            continue;
2986        }
2987        iterations.push(record);
2988        previous_iteration = final_members.clone();
2989    }
2990
2991    let successes = final_members
2992        .iter()
2993        .filter(|result| result.status == "ok")
2994        .cloned()
2995        .collect::<Vec<_>>();
2996    let summary = if successes.is_empty() {
2997        EngineResult {
2998            name: resolved.raw.summarizer.clone(),
2999            bin: None,
3000            status: "error".to_string(),
3001            duration_ms: 0,
3002            detail: "No Amon Hen member produced a response.".to_string(),
3003            exit_code: None,
3004            stdout: String::new(),
3005            stderr: String::new(),
3006            output: String::new(),
3007            command: String::new(),
3008            token_usage: token_usage("", ""),
3009            tool_calls: vec![],
3010            sub_agents: vec![],
3011            role: "summary".to_string(),
3012            iteration: workflow.iterations,
3013            total_iterations: workflow.iterations,
3014            team_size: 0,
3015        }
3016    } else {
3017        let summary_prompt =
3018            build_summary_prompt(&query, &successes, &workflow, resolved.raw.max_member_chars);
3019        if let Some(record) = iterations.last_mut() {
3020            record.summary_context = Some(truncate(&summary_prompt, resolved.raw.max_member_chars));
3021        }
3022        let summarizer = pick_summarizer(resolved, &successes);
3023        let mut options = engine_options(
3024            resolved,
3025            EngineOptionsInput {
3026                member: &summarizer,
3027                prompt: summary_prompt,
3028                role: "summary",
3029                iteration: workflow.iterations,
3030                workflow: &workflow,
3031                progress: progress.clone(),
3032                cancel: cancel.clone(),
3033            },
3034        );
3035        options.role = "summary".to_string();
3036        options.team_size = 0;
3037        run_engine(&summarizer, options)
3038    };
3039
3040    AmonHenResult {
3041        query,
3042        cwd: resolved.cwd.display().to_string(),
3043        members_requested: resolved.members.clone(),
3044        summarizer_requested: resolved.raw.summarizer.clone(),
3045        workflow,
3046        prompt_commands,
3047        iterations,
3048        members: final_members,
3049        summary,
3050        consensus: final_consensus,
3051    }
3052}
3053
3054fn build_workflow(resolved: &ResolvedArgs) -> Workflow {
3055    let mut teams = HashMap::new();
3056    for member in ENGINES {
3057        teams.insert(member.to_string(), resolved.raw.team_work);
3058    }
3059    if let Some(value) = resolved.raw.codex_sub_agents {
3060        teams.insert("codex".to_string(), value);
3061    }
3062    if let Some(value) = resolved.raw.claude_sub_agents {
3063        teams.insert("claude".to_string(), value);
3064    }
3065    if let Some(value) = resolved.raw.gemini_sub_agents {
3066        teams.insert("gemini".to_string(), value);
3067    }
3068    let mut workflow = Workflow {
3069        handoff: resolved.raw.handoff || resolved.raw.planner_mode == PLANNER_MODE_REVIEW_CHAIN,
3070        lead: resolved.raw.lead.clone(),
3071        planner: resolved.raw.planner.clone(),
3072        planner_mode: resolved.raw.planner_mode.clone(),
3073        owner_map: resolved.raw.owner_map.clone(),
3074        handshake: handshake_requested(&resolved.raw),
3075        handshake_agents: Vec::new(),
3076        iterations: resolved.raw.iterations.max(1),
3077        team_work: resolved.raw.team_work,
3078        teams,
3079    };
3080    workflow.handoff = workflow.handoff || workflow.handshake;
3081    if workflow.handshake {
3082        workflow.handshake_agents = build_handshake_agent_slots(resolved, &workflow);
3083    }
3084    workflow
3085}
3086
3087fn effective_team_size(workflow: &Workflow, member: &str) -> usize {
3088    *workflow.teams.get(member).unwrap_or(&workflow.team_work)
3089}
3090
3091fn build_handshake_agent_slots(resolved: &ResolvedArgs, workflow: &Workflow) -> Vec<AgentSlot> {
3092    let override_sub_agents =
3093        handshake_sub_agent_override(&resolved.raw.handshake_sub_agents).unwrap_or(None);
3094    let specs = resolved
3095        .raw
3096        .handshake_agents
3097        .iter()
3098        .filter_map(|value| parse_handshake_agent_spec(value).ok())
3099        .collect::<Vec<_>>();
3100    let specs = if specs.is_empty() {
3101        let provider = resolved
3102            .raw
3103            .handshake_provider
3104            .as_deref()
3105            .or(workflow.planner.as_deref())
3106            .or(workflow.lead.as_deref())
3107            .or_else(|| resolved.members.first().map(String::as_str))
3108            .unwrap_or("codex")
3109            .to_string();
3110        vec![
3111            HandshakeAgentSpec {
3112                role: "planner".to_string(),
3113                provider: provider.clone(),
3114                sub_agents: None,
3115            },
3116            HandshakeAgentSpec {
3117                role: "executor".to_string(),
3118                provider: provider.clone(),
3119                sub_agents: None,
3120            },
3121            HandshakeAgentSpec {
3122                role: "reviewer".to_string(),
3123                provider,
3124                sub_agents: None,
3125            },
3126        ]
3127    } else {
3128        specs
3129    };
3130
3131    specs
3132        .into_iter()
3133        .enumerate()
3134        .map(|(index, spec)| {
3135            let team_size = spec
3136                .sub_agents
3137                .or(override_sub_agents)
3138                .unwrap_or_else(|| effective_team_size(workflow, &spec.provider));
3139            AgentSlot {
3140                id: format!("{}-{}", spec.role, index + 1),
3141                provider: spec.provider,
3142                role: spec.role,
3143                team_size,
3144            }
3145        })
3146        .collect()
3147}
3148
3149fn iteration_record(
3150    iteration: usize,
3151    total_iterations: usize,
3152    members: Vec<EngineResult>,
3153    duration_ms: u128,
3154    handoff_context: Option<String>,
3155    summary_context: Option<String>,
3156) -> IterationRecord {
3157    let status = if members.iter().any(|member| member.status == "ok") {
3158        "ok"
3159    } else if members.iter().any(|member| member.status == "cancelled") {
3160        "cancelled"
3161    } else if members.iter().any(|member| member.status == "timeout") {
3162        "timeout"
3163    } else if members.iter().any(|member| member.status == "missing") {
3164        "missing"
3165    } else {
3166        "error"
3167    };
3168    let sub_agents = members
3169        .iter()
3170        .flat_map(|member| member.sub_agents.iter().cloned())
3171        .collect::<Vec<_>>();
3172    let token_usage = aggregate_results_token_usage(members.iter());
3173    let tool_calls = members
3174        .iter()
3175        .chain(sub_agents.iter())
3176        .flat_map(|member| member.tool_calls.iter().cloned())
3177        .collect::<Vec<_>>();
3178    IterationRecord {
3179        iteration,
3180        total_iterations,
3181        status: status.to_string(),
3182        duration_ms,
3183        members,
3184        sub_agents,
3185        handoff_context,
3186        summary_context,
3187        consensus: None,
3188        token_usage,
3189        tool_calls,
3190    }
3191}
3192
3193fn iteration_handoff_context(
3194    previous_iteration: &[EngineResult],
3195    max_member_chars: usize,
3196) -> Option<String> {
3197    let context = previous_iteration
3198        .iter()
3199        .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
3200        .map(|result| {
3201            let output = bounded_context_text(result.output.trim(), max_member_chars, "handoff");
3202            format!("### {} ({})\n{}", result.name, result.role, output)
3203        })
3204        .collect::<Vec<_>>()
3205        .join("\n\n");
3206    (!context.is_empty()).then_some(context)
3207}
3208
3209fn consensus_enabled(resolved: &ResolvedArgs) -> bool {
3210    resolved.raw.consensus == CONSENSUS_REQUIRED
3211}
3212
3213fn consensus_config(resolved: &ResolvedArgs) -> ConsensusConfig {
3214    let reviewers = if resolved.raw.consensus_reviewers.is_empty() {
3215        resolved.members.clone()
3216    } else {
3217        resolved
3218            .raw
3219            .consensus_reviewers
3220            .iter()
3221            .map(|reviewer| reviewer.trim().to_string())
3222            .filter(|reviewer| !reviewer.is_empty())
3223            .collect()
3224    };
3225    ConsensusConfig {
3226        mode: resolved.raw.consensus.clone(),
3227        reviewers,
3228        failure_policy: resolved.raw.failure_policy.clone(),
3229        review_rounds: resolved.raw.review_rounds.max(1),
3230        require_final_diff_review: resolved.raw.require_final_diff_review,
3231        require_tests: resolved.raw.require_tests,
3232        require_secret_scan: resolved.raw.require_secret_scan,
3233        require_clean_git_diff: resolved.raw.require_clean_git_diff,
3234        stop_when: resolved.raw.stop_when.clone(),
3235        owner_map: resolved.raw.owner_map.clone(),
3236    }
3237}
3238
3239struct ConsensusRunInput<'a> {
3240    resolved: &'a ResolvedArgs,
3241    query: &'a str,
3242    workflow: &'a Workflow,
3243    iteration: usize,
3244    members: &'a [EngineResult],
3245    prior_iterations: &'a [IterationRecord],
3246    progress: Option<ProgressSink>,
3247    cancel: Option<Arc<AtomicBool>>,
3248}
3249
3250fn run_consensus_loop(input: ConsensusRunInput<'_>) -> ConsensusResult {
3251    let resolved = input.resolved;
3252    let workflow = input.workflow;
3253    let iteration = input.iteration;
3254    let config = consensus_config(resolved);
3255    let failed_members = input
3256        .members
3257        .iter()
3258        .filter(|member| member.status != "ok")
3259        .map(|member| member.name.clone())
3260        .collect::<Vec<_>>();
3261    let takeover_required =
3262        config.failure_policy == FAILURE_POLICY_TAKEOVER && !failed_members.is_empty();
3263    let mut rounds = Vec::new();
3264    let mut final_blockers = Vec::new();
3265    let mut final_takeover_by = Vec::new();
3266
3267    for round in 1..=config.review_rounds {
3268        emit_runtime_event(
3269            &input.progress,
3270            resolved.raw.verbose,
3271            progress_event_with_context(
3272                None,
3273                None,
3274                ProgressStage::Start,
3275                Some("consensus"),
3276                Some(iteration),
3277                Some(workflow.iterations),
3278                false,
3279                None,
3280                None,
3281                vec![],
3282                format!(
3283                    "[amon-hen] consensus round {round}/{} started",
3284                    config.review_rounds
3285                ),
3286            ),
3287        );
3288        let checks = run_consensus_checks(
3289            resolved,
3290            &config,
3291            input.progress.clone(),
3292            input.cancel.clone(),
3293        );
3294        let mut blockers = consensus_check_blockers(&checks);
3295        let mut reviews = Vec::new();
3296        let mut round_takeover_by = Vec::new();
3297        let mut prior_reviews = Vec::new();
3298
3299        for reviewer in &config.reviewers {
3300            let review = run_consensus_reviewer(ConsensusReviewerInput {
3301                resolved,
3302                query: input.query,
3303                workflow,
3304                iteration,
3305                round,
3306                config: &config,
3307                members: input.members,
3308                prior_iterations: input.prior_iterations,
3309                checks: &checks,
3310                prior_reviews: &prior_reviews,
3311                failed_members: &failed_members,
3312                progress: input.progress.clone(),
3313                cancel: input.cancel.clone(),
3314                reviewer,
3315            });
3316            if review.approved {
3317                for takeover in &review.takeover_for {
3318                    if failed_members.iter().any(|failed| failed == takeover)
3319                        && !round_takeover_by.iter().any(|seen| seen == reviewer)
3320                    {
3321                        round_takeover_by.push(reviewer.clone());
3322                    }
3323                }
3324            }
3325            if review.status != "ok" {
3326                blockers.push(format!(
3327                    "{} consensus review failed with status {}",
3328                    reviewer, review.status
3329                ));
3330            } else if !review.approved {
3331                blockers.push(format!("{reviewer} did not approve consensus"));
3332            }
3333            blockers.extend(
3334                review
3335                    .blockers
3336                    .iter()
3337                    .map(|blocker| format!("{reviewer}: {blocker}")),
3338            );
3339            prior_reviews.push(review.result.clone());
3340            reviews.push(review);
3341        }
3342
3343        if takeover_required && round_takeover_by.is_empty() {
3344            blockers.push(format!(
3345                "failure-policy takeover requires a successful reviewer to take over failed agents: {}",
3346                failed_members.join(",")
3347            ));
3348        }
3349        if config.owner_map == OWNER_MAP_STRICT
3350            && !reviews
3351                .iter()
3352                .any(|review| review.approved && review.status == "ok")
3353        {
3354            blockers.push(
3355                "owner-map strict requires at least one successful approving reviewer".to_string(),
3356            );
3357        }
3358        blockers.sort();
3359        blockers.dedup();
3360        round_takeover_by.sort();
3361        round_takeover_by.dedup();
3362        let approved = blockers.is_empty()
3363            && reviews.len() == config.reviewers.len()
3364            && reviews
3365                .iter()
3366                .all(|review| review.approved && review.status == "ok");
3367        let status = if approved { "approved" } else { "blocked" }.to_string();
3368        emit_runtime_event(
3369            &input.progress,
3370            resolved.raw.verbose,
3371            progress_event_with_context(
3372                None,
3373                None,
3374                ProgressStage::Done,
3375                Some(&status),
3376                Some(iteration),
3377                Some(workflow.iterations),
3378                false,
3379                None,
3380                None,
3381                vec![],
3382                format!("[amon-hen] consensus round {round} {status}"),
3383            ),
3384        );
3385        final_blockers = blockers.clone();
3386        final_takeover_by = round_takeover_by.clone();
3387        rounds.push(ConsensusRound {
3388            round,
3389            status: status.clone(),
3390            checks,
3391            reviews,
3392            blockers,
3393            takeover_by: round_takeover_by,
3394        });
3395        if approved {
3396            break;
3397        }
3398    }
3399
3400    let approved = rounds
3401        .last()
3402        .is_some_and(|round| round.status == "approved");
3403    ConsensusResult {
3404        status: if approved { "approved" } else { "blocked" }.to_string(),
3405        iteration,
3406        approved,
3407        config,
3408        rounds,
3409        blockers: final_blockers,
3410        takeover_required,
3411        takeover_by: final_takeover_by,
3412    }
3413}
3414
3415struct ConsensusReviewerInput<'a> {
3416    resolved: &'a ResolvedArgs,
3417    query: &'a str,
3418    workflow: &'a Workflow,
3419    iteration: usize,
3420    round: usize,
3421    config: &'a ConsensusConfig,
3422    members: &'a [EngineResult],
3423    prior_iterations: &'a [IterationRecord],
3424    checks: &'a [ConsensusCheck],
3425    prior_reviews: &'a [EngineResult],
3426    failed_members: &'a [String],
3427    progress: Option<ProgressSink>,
3428    cancel: Option<Arc<AtomicBool>>,
3429    reviewer: &'a str,
3430}
3431
3432fn run_consensus_reviewer(input: ConsensusReviewerInput<'_>) -> ConsensusReview {
3433    let resolved = input.resolved;
3434    let iteration = input.iteration;
3435    let round = input.round;
3436    let reviewer = input.reviewer;
3437    let role = format!("consensus-reviewer-round-{round}");
3438    let prompt = build_consensus_review_prompt(ConsensusPromptInput {
3439        query: input.query,
3440        workflow: input.workflow,
3441        iteration,
3442        round,
3443        config: input.config,
3444        members: input.members,
3445        prior_iterations: input.prior_iterations,
3446        checks: input.checks,
3447        prior_reviews: input.prior_reviews,
3448        failed_members: input.failed_members,
3449        max_prompt_chars: resolved.raw.max_prompt_chars,
3450        reviewer,
3451    });
3452    let mut options = engine_options(
3453        resolved,
3454        EngineOptionsInput {
3455            member: reviewer,
3456            prompt,
3457            role: &role,
3458            iteration,
3459            workflow: input.workflow,
3460            progress: input.progress,
3461            cancel: input.cancel,
3462        },
3463    );
3464    options.team_size = 0;
3465    let result = run_engine(reviewer, options);
3466    let approved = result.status == "ok" && consensus_output_approves(&result.output);
3467    let blockers = consensus_output_blockers(&result.output);
3468    let takeover_for = consensus_output_takeover_for(&result.output);
3469    ConsensusReview {
3470        provider: reviewer.to_string(),
3471        role,
3472        status: result.status.clone(),
3473        approved,
3474        blockers,
3475        takeover_for,
3476        result,
3477    }
3478}
3479
3480struct ConsensusPromptInput<'a> {
3481    query: &'a str,
3482    workflow: &'a Workflow,
3483    iteration: usize,
3484    round: usize,
3485    config: &'a ConsensusConfig,
3486    members: &'a [EngineResult],
3487    prior_iterations: &'a [IterationRecord],
3488    checks: &'a [ConsensusCheck],
3489    prior_reviews: &'a [EngineResult],
3490    failed_members: &'a [String],
3491    max_prompt_chars: usize,
3492    reviewer: &'a str,
3493}
3494
3495fn build_consensus_review_prompt(input: ConsensusPromptInput<'_>) -> String {
3496    let mut sections = vec![
3497        "You are an Amon Hen consensus reviewer in a fail-closed agent-to-agent review loop."
3498            .to_string(),
3499        format!(
3500            "Reviewer: {}. Consensus round: {}. Iteration: {}/{}.",
3501            input.reviewer, input.round, input.iteration, input.workflow.iterations
3502        ),
3503        format!(
3504            "Consensus policy: mode={}, failure_policy={}, owner_map={}, require_final_diff_review={}, require_tests={}, require_secret_scan={}, require_clean_git_diff={}.",
3505            input.config.mode,
3506            input.config.failure_policy,
3507            input.config.owner_map,
3508            input.config.require_final_diff_review,
3509            input.config.require_tests,
3510            input.config.require_secret_scan,
3511            input.config.require_clean_git_diff,
3512        ),
3513        "Your job is to review the actual agent outputs, current repository evidence, and prior reviewer notes. Approve only if the implementation is safe, tested, reviewable, and no required evidence is missing.".to_string(),
3514        "If the failure policy is takeover and an agent failed, explicitly take over only if you reviewed the failed scope and can stand behind the final state.".to_string(),
3515        "If owner-map is strict, block unless ownership is clear and changes do not create uncontrolled overlap.".to_string(),
3516    ];
3517    if !input.failed_members.is_empty() {
3518        sections.push(format!(
3519            "Failed agents requiring takeover consideration: {}",
3520            input.failed_members.join(",")
3521        ));
3522    }
3523    sections.push(format!("Original user query:\n{}", input.query.trim()));
3524    let member_blocks = input
3525        .members
3526        .iter()
3527        .map(|member| {
3528            format!(
3529                "### {} ({}) status={}\nDetail: {}\nOutput:\n{}",
3530                member.name,
3531                member.role,
3532                member.status,
3533                sanitize_status_detail(&member.detail),
3534                bounded_context_text(
3535                    &member.output,
3536                    input.config_review_budget(),
3537                    "member output"
3538                )
3539            )
3540        })
3541        .collect::<Vec<_>>()
3542        .join("\n\n");
3543    sections.push(format!("Current iteration agent outputs:\n{member_blocks}"));
3544    if !input.prior_iterations.is_empty() {
3545        let prior = input
3546            .prior_iterations
3547            .iter()
3548            .filter_map(|iteration| iteration.consensus.as_ref())
3549            .map(|consensus| {
3550                format!(
3551                    "iteration {} consensus={} blockers={}",
3552                    consensus.iteration,
3553                    consensus.status,
3554                    consensus.blockers.join(" | ")
3555                )
3556            })
3557            .collect::<Vec<_>>()
3558            .join("\n");
3559        if !prior.trim().is_empty() {
3560            sections.push(format!("Prior consensus rounds:\n{prior}"));
3561        }
3562    }
3563    if !input.prior_reviews.is_empty() {
3564        let reviews = input
3565            .prior_reviews
3566            .iter()
3567            .map(|review| {
3568                format!(
3569                    "### {} ({}) status={}\n{}",
3570                    review.name,
3571                    review.role,
3572                    review.status,
3573                    bounded_context_text(
3574                        &review.output,
3575                        input.config_review_budget(),
3576                        "review output"
3577                    )
3578                )
3579            })
3580            .collect::<Vec<_>>()
3581            .join("\n\n");
3582        sections.push(format!(
3583            "Earlier reviewer handoffs in this round:\n{reviews}"
3584        ));
3585    }
3586    let checks = input
3587        .checks
3588        .iter()
3589        .map(|check| {
3590            format!(
3591                "### {} [{}]\ncommand: {}\ndetail: {}\nstdout:\n{}\nstderr:\n{}",
3592                check.name,
3593                check.command.status,
3594                check.command.command,
3595                check.command.detail,
3596                bounded_context_text(&check.stdout, 6_000, "check stdout"),
3597                bounded_context_text(&check.stderr, 3_000, "check stderr")
3598            )
3599        })
3600        .collect::<Vec<_>>()
3601        .join("\n\n");
3602    sections.push(format!("Required evidence checks:\n{checks}"));
3603    sections.push(
3604        "Return your review with concrete findings. End with exactly these machine-readable lines:\n\
3605         CONSENSUS: approve|block\n\
3606         BLOCKERS: comma-separated blockers or none\n\
3607         TAKEOVER_FOR: comma-separated failed providers you explicitly take over or none"
3608            .to_string(),
3609    );
3610    limit_provider_prompt(&sections.join("\n\n"), input.max_prompt_chars)
3611}
3612
3613trait ConsensusPromptBudget {
3614    fn config_review_budget(&self) -> usize;
3615}
3616
3617impl ConsensusPromptBudget for ConsensusPromptInput<'_> {
3618    fn config_review_budget(&self) -> usize {
3619        12_000
3620    }
3621}
3622
3623fn run_consensus_checks(
3624    resolved: &ResolvedArgs,
3625    config: &ConsensusConfig,
3626    progress: Option<ProgressSink>,
3627    cancel: Option<Arc<AtomicBool>>,
3628) -> Vec<ConsensusCheck> {
3629    let mut checks = Vec::new();
3630    if config.require_clean_git_diff {
3631        checks.push(run_consensus_shell_check(
3632            resolved,
3633            "clean-git-diff",
3634            "git diff --check && git status --short && git diff --stat",
3635            progress.clone(),
3636            cancel.clone(),
3637        ));
3638    }
3639    if config.require_secret_scan {
3640        let command = secret_scan_command();
3641        checks.push(run_consensus_shell_check(
3642            resolved,
3643            "secret-scan",
3644            &command,
3645            progress.clone(),
3646            cancel.clone(),
3647        ));
3648    }
3649    if config.require_tests {
3650        checks.push(run_consensus_shell_check(
3651            resolved,
3652            "tests",
3653            "if [ -f Makefile ] && grep -Eq '(^|^[[:space:]]*)test:' Makefile; then make test; elif [ -f Cargo.toml ]; then cargo test --workspace --locked; elif [ -f package.json ]; then npm test; elif [ -f pyproject.toml ] || [ -d tests ]; then python -m pytest; else echo 'No standard test command detected'; exit 1; fi",
3654            progress.clone(),
3655            cancel.clone(),
3656        ));
3657    }
3658    if config.require_final_diff_review {
3659        checks.push(run_consensus_shell_check(
3660            resolved,
3661            "final-diff-review",
3662            "git status --short && git diff --stat && git diff --name-only",
3663            progress,
3664            cancel,
3665        ));
3666    }
3667    checks
3668}
3669
3670fn secret_scan_command() -> String {
3671    let sensitive_patterns = [
3672        format!("{}{}", "sk-", "ant"),
3673        "AQ[.]Ab".to_string(),
3674        format!("{}{}", "lin_", "api"),
3675        format!("{}{}", "cfat", "_"),
3676        format!("{}{}", "OPEN", "SSH"),
3677        format!("{}{}", "Digital", "Ocean"),
3678        "104[.]248".to_string(),
3679        format!("{}{}", "cf", "@"),
3680        format!("{}{}", "e132", "cc"),
3681        "/Users/[^[:space:]]+".to_string(),
3682        format!("{}{}", "Downloads/", "council"),
3683        format!("{}{}", "node ", "/Users"),
3684    ]
3685    .join("|");
3686
3687    format!(
3688        "if rg -n '{sensitive_patterns}' -g '!target/**' -g '!web/node_modules/**' -g '!web/dist/**' -g '!web/.astro/**' .; then echo 'sensitive pattern scan found matches'; exit 1; else code=$?; if [ \"$code\" -eq 1 ]; then echo 'sensitive pattern scan clean'; exit 0; else exit \"$code\"; fi; fi"
3689    )
3690}
3691
3692fn run_consensus_shell_check(
3693    resolved: &ResolvedArgs,
3694    name: &str,
3695    command: &str,
3696    progress: Option<ProgressSink>,
3697    cancel: Option<Arc<AtomicBool>>,
3698) -> ConsensusCheck {
3699    let shell = if cfg!(windows) { "cmd" } else { "sh" };
3700    let args = if cfg!(windows) {
3701        vec!["/C".to_string(), command.to_string()]
3702    } else {
3703        vec!["-lc".to_string(), command.to_string()]
3704    };
3705    emit_runtime_event(
3706        &progress,
3707        resolved.raw.verbose,
3708        progress_event(
3709            None,
3710            None,
3711            ProgressStage::Context,
3712            Some("consensus-check"),
3713            format!("[amon-hen] consensus check `{name}`"),
3714        ),
3715    );
3716    let result = run_command(
3717        CommandRequest::new(shell, &args, &resolved.cwd, resolved.raw.timeout * 1000)
3718            .progress(command_progress(
3719                Some(&format!("consensus check `{name}`")),
3720                progress,
3721            ))
3722            .cancel(cancel),
3723    );
3724    ConsensusCheck {
3725        name: name.to_string(),
3726        command: command_telemetry(&result),
3727        stdout: truncate(&sanitize_status_detail(&result.stdout), 20_000),
3728        stderr: truncate(&sanitize_status_detail(&result.stderr), 10_000),
3729    }
3730}
3731
3732fn consensus_check_blockers(checks: &[ConsensusCheck]) -> Vec<String> {
3733    checks
3734        .iter()
3735        .filter(|check| check.command.status != "ok")
3736        .map(|check| {
3737            format!(
3738                "{} check failed: {}",
3739                check.name,
3740                sanitize_status_detail(&check.command.detail)
3741            )
3742        })
3743        .collect()
3744}
3745
3746fn consensus_output_approves(output: &str) -> bool {
3747    output
3748        .lines()
3749        .rev()
3750        .map(|line| line.trim().to_ascii_lowercase())
3751        .any(|line| line == "consensus: approve" || line == "consensus: approved")
3752}
3753
3754fn consensus_output_blockers(output: &str) -> Vec<String> {
3755    output
3756        .lines()
3757        .filter_map(|line| line.trim().strip_prefix("BLOCKERS:"))
3758        .flat_map(|value| value.split(','))
3759        .map(str::trim)
3760        .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("none"))
3761        .map(ToString::to_string)
3762        .collect()
3763}
3764
3765fn consensus_output_takeover_for(output: &str) -> Vec<String> {
3766    output
3767        .lines()
3768        .filter_map(|line| line.trim().strip_prefix("TAKEOVER_FOR:"))
3769        .flat_map(|value| value.split(','))
3770        .map(str::trim)
3771        .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("none"))
3772        .map(ToString::to_string)
3773        .collect()
3774}
3775
3776fn run_iteration(
3777    resolved: &ResolvedArgs,
3778    query: &str,
3779    workflow: &Workflow,
3780    iteration: usize,
3781    previous_iteration: &[EngineResult],
3782    progress: Option<ProgressSink>,
3783    cancel: Option<Arc<AtomicBool>>,
3784) -> Vec<EngineResult> {
3785    if workflow.handshake {
3786        return run_handshake_iteration(
3787            resolved,
3788            query,
3789            workflow,
3790            iteration,
3791            previous_iteration,
3792            progress,
3793            cancel,
3794        );
3795    }
3796
3797    let ordered = execution_order(
3798        &resolved.members,
3799        workflow.planner.as_deref(),
3800        workflow.lead.as_deref(),
3801    );
3802    if workflow.handoff && planner_mode_runs_serial(workflow) {
3803        let mut results = Vec::new();
3804        for member in ordered {
3805            let role = role_for(&member, workflow);
3806            let team_size = effective_team_size(workflow, &member);
3807            let prompt = build_member_prompt(MemberPromptInput {
3808                query,
3809                role: &role,
3810                workflow,
3811                iteration,
3812                team_size,
3813                max_member_chars: resolved.raw.max_member_chars,
3814                max_prompt_chars: resolved.raw.max_prompt_chars,
3815                previous_iteration,
3816                handoff_results: &results,
3817                plan_output: "",
3818            });
3819            let options = engine_options(
3820                resolved,
3821                EngineOptionsInput {
3822                    member: &member,
3823                    prompt,
3824                    role: &role,
3825                    iteration,
3826                    workflow,
3827                    progress: progress.clone(),
3828                    cancel: cancel.clone(),
3829                },
3830            );
3831            results.push(run_engine(&member, options));
3832        }
3833        return results;
3834    }
3835
3836    let planner = workflow
3837        .planner
3838        .as_ref()
3839        .filter(|planner| ordered.contains(planner))
3840        .cloned();
3841    let mut results = Vec::new();
3842    let mut plan_output = String::new();
3843    let blocking_planner = workflow.planner_mode == PLANNER_MODE_BLOCKING;
3844    if let Some(planner_name) = planner.as_ref().filter(|_| blocking_planner) {
3845        let role = role_for(planner_name, workflow);
3846        let team_size = effective_team_size(workflow, planner_name);
3847        let prompt = build_member_prompt(MemberPromptInput {
3848            query,
3849            role: &role,
3850            workflow,
3851            iteration,
3852            team_size,
3853            max_member_chars: resolved.raw.max_member_chars,
3854            max_prompt_chars: resolved.raw.max_prompt_chars,
3855            previous_iteration,
3856            handoff_results: &[],
3857            plan_output: "",
3858        });
3859        let options = engine_options(
3860            resolved,
3861            EngineOptionsInput {
3862                member: planner_name,
3863                prompt,
3864                role: &role,
3865                iteration,
3866                workflow,
3867                progress: progress.clone(),
3868                cancel: cancel.clone(),
3869            },
3870        );
3871        let result = run_engine(planner_name, options);
3872        if result.status == "ok" {
3873            plan_output = result.output.clone();
3874        }
3875        results.push(result);
3876    }
3877
3878    let (tx, rx) = mpsc::channel();
3879    let executors = ordered
3880        .into_iter()
3881        .filter(|member| !(blocking_planner && Some(member) == planner.as_ref()))
3882        .collect::<Vec<_>>();
3883    let resolved = Arc::new(resolved.clone());
3884    let workflow = Arc::new(workflow.clone());
3885    let previous_iteration = Arc::new(previous_iteration.to_vec());
3886    let plan_output = Arc::new(plan_output);
3887    let progress = Arc::new(progress);
3888    let cancel = Arc::new(cancel);
3889    for member in executors.clone() {
3890        let tx = tx.clone();
3891        let resolved = Arc::clone(&resolved);
3892        let workflow = Arc::clone(&workflow);
3893        let query = query.to_string();
3894        let previous_iteration = Arc::clone(&previous_iteration);
3895        let plan_output = Arc::clone(&plan_output);
3896        let progress = Arc::clone(&progress);
3897        let cancel = Arc::clone(&cancel);
3898        thread::spawn(move || {
3899            let role = role_for(&member, &workflow);
3900            let team_size = effective_team_size(&workflow, &member);
3901            let prompt = build_member_prompt(MemberPromptInput {
3902                query: &query,
3903                role: &role,
3904                workflow: &workflow,
3905                iteration,
3906                team_size,
3907                max_member_chars: resolved.raw.max_member_chars,
3908                max_prompt_chars: resolved.raw.max_prompt_chars,
3909                previous_iteration: previous_iteration.as_slice(),
3910                handoff_results: &[],
3911                plan_output: &plan_output,
3912            });
3913            let options = engine_options(
3914                &resolved,
3915                EngineOptionsInput {
3916                    member: &member,
3917                    prompt,
3918                    role: &role,
3919                    iteration,
3920                    workflow: &workflow,
3921                    progress: (*progress).clone(),
3922                    cancel: (*cancel).clone(),
3923                },
3924            );
3925            let _ = tx.send(run_engine(&member, options));
3926        });
3927    }
3928    drop(tx);
3929    let mut executor_results = rx.into_iter().collect::<Vec<_>>();
3930    executor_results.sort_by_key(|result| {
3931        executors
3932            .iter()
3933            .position(|member| member == &result.name)
3934            .unwrap_or(usize::MAX)
3935    });
3936    results.extend(executor_results);
3937    results
3938}
3939
3940fn run_handshake_iteration(
3941    resolved: &ResolvedArgs,
3942    query: &str,
3943    workflow: &Workflow,
3944    iteration: usize,
3945    previous_iteration: &[EngineResult],
3946    progress: Option<ProgressSink>,
3947    cancel: Option<Arc<AtomicBool>>,
3948) -> Vec<EngineResult> {
3949    let mut results = Vec::new();
3950    for slot in &workflow.handshake_agents {
3951        let prompt = build_member_prompt(MemberPromptInput {
3952            query,
3953            role: &slot.role,
3954            workflow,
3955            iteration,
3956            team_size: slot.team_size,
3957            max_member_chars: resolved.raw.max_member_chars,
3958            max_prompt_chars: resolved.raw.max_prompt_chars,
3959            previous_iteration,
3960            handoff_results: &results,
3961            plan_output: "",
3962        });
3963        let mut options = engine_options(
3964            resolved,
3965            EngineOptionsInput {
3966                member: &slot.provider,
3967                prompt,
3968                role: &slot.role,
3969                iteration,
3970                workflow,
3971                progress: progress.clone(),
3972                cancel: cancel.clone(),
3973            },
3974        );
3975        options.team_size = slot.team_size;
3976        results.push(run_engine(&slot.provider, options));
3977    }
3978    results
3979}
3980
3981fn planner_mode_runs_serial(workflow: &Workflow) -> bool {
3982    matches!(
3983        workflow.planner_mode.as_str(),
3984        PLANNER_MODE_BLOCKING | PLANNER_MODE_REVIEW_CHAIN | PLANNER_MODE_HANDSHAKE
3985    )
3986}
3987
3988fn engine_options(resolved: &ResolvedArgs, input: EngineOptionsInput<'_>) -> EngineRunOptions {
3989    EngineRunOptions {
3990        prompt: limit_provider_prompt(&input.prompt, resolved.raw.max_prompt_chars),
3991        max_prompt_chars: resolved.raw.max_prompt_chars,
3992        max_member_chars: resolved.raw.max_member_chars,
3993        cwd: resolved.cwd.clone(),
3994        timeout_ms: resolved.raw.timeout * 1000,
3995        effort: provider_effort(resolved, input.member),
3996        model: provider_model(resolved, input.member),
3997        permission: provider_permission(resolved, input.member),
3998        auth: provider_auth(resolved, input.member),
3999        capability: provider_capability(resolved, input.member),
4000        role: input.role.to_string(),
4001        iteration: input.iteration,
4002        total_iterations: input.workflow.iterations,
4003        team_size: effective_team_size(input.workflow, input.member),
4004        is_sub_agent: false,
4005        live: resolved.raw.verbose || input.progress.is_some(),
4006        progress: input.progress,
4007        cancel: input.cancel,
4008    }
4009}
4010
4011fn provider_effort(resolved: &ResolvedArgs, member: &str) -> Option<String> {
4012    provider_option(
4013        member,
4014        &resolved.raw.codex_effort,
4015        &resolved.raw.claude_effort,
4016        &resolved.raw.gemini_effort,
4017    )
4018    .or_else(|| {
4019        resolved
4020            .raw
4021            .effort
4022            .map(|effort| effort.as_str().to_string())
4023    })
4024}
4025
4026fn provider_model(resolved: &ResolvedArgs, member: &str) -> Option<String> {
4027    provider_option(
4028        member,
4029        &resolved.raw.codex_model,
4030        &resolved.raw.claude_model,
4031        &resolved.raw.gemini_model,
4032    )
4033}
4034
4035fn provider_permission(resolved: &ResolvedArgs, member: &str) -> Option<String> {
4036    match Engine::parse(member) {
4037        Some(Engine::Codex) => Some(resolved.raw.codex_sandbox.clone()),
4038        Some(Engine::Claude) => Some(resolved.raw.claude_permission_mode.clone()),
4039        Some(Engine::Gemini) => Some(resolved.raw.gemini_approval_mode.clone()),
4040        None => None,
4041    }
4042}
4043
4044fn provider_auth(resolved: &ResolvedArgs, member: &str) -> String {
4045    match Engine::parse(member) {
4046        Some(Engine::Codex) => resolved.raw.codex_auth.clone(),
4047        Some(Engine::Claude) => resolved.raw.claude_auth.clone(),
4048        Some(Engine::Gemini) => resolved.raw.gemini_auth.clone(),
4049        None => DEFAULT_AUTH_MODE.to_string(),
4050    }
4051}
4052
4053fn provider_capability(resolved: &ResolvedArgs, member: &str) -> ProviderCapability {
4054    match Engine::parse(member).expect("provider capabilities use validated engines") {
4055        Engine::Codex => ProviderCapability {
4056            mode: inferred_capability_mode(
4057                &resolved.raw.codex_capabilities,
4058                !resolved.raw.codex_config.is_empty() || resolved.raw.codex_mcp_profile.is_some(),
4059            ),
4060            config: resolved.raw.codex_config.clone(),
4061            mcp_profile: resolved.raw.codex_mcp_profile.clone(),
4062            mcp_config: vec![],
4063            allowed_tools: vec![],
4064            disallowed_tools: vec![],
4065            tools: vec![],
4066            agent: None,
4067            agents_json: None,
4068            plugin_dirs: vec![],
4069            strict_mcp_config: false,
4070            disable_slash_commands: false,
4071            settings: None,
4072            tools_profile: vec![],
4073            allowed_mcp_servers: vec![],
4074            policy: vec![],
4075            admin_policy: vec![],
4076        },
4077        Engine::Claude => ProviderCapability {
4078            mode: inferred_capability_mode(
4079                &resolved.raw.claude_capabilities,
4080                !resolved.raw.claude_mcp_config.is_empty()
4081                    || !resolved.raw.claude_allowed_tools.is_empty()
4082                    || !resolved.raw.claude_disallowed_tools.is_empty()
4083                    || !resolved.raw.claude_tools.is_empty()
4084                    || resolved.raw.claude_agent.is_some()
4085                    || resolved.raw.claude_agents_json.is_some()
4086                    || !resolved.raw.claude_plugin_dir.is_empty()
4087                    || resolved.raw.claude_strict_mcp_config
4088                    || resolved.raw.claude_disable_slash_commands,
4089            ),
4090            config: vec![],
4091            mcp_profile: None,
4092            mcp_config: resolved.raw.claude_mcp_config.clone(),
4093            allowed_tools: resolved.raw.claude_allowed_tools.clone(),
4094            disallowed_tools: resolved.raw.claude_disallowed_tools.clone(),
4095            tools: resolved.raw.claude_tools.clone(),
4096            agent: resolved.raw.claude_agent.clone(),
4097            agents_json: resolved.raw.claude_agents_json.clone(),
4098            plugin_dirs: resolved.raw.claude_plugin_dir.clone(),
4099            strict_mcp_config: resolved.raw.claude_strict_mcp_config,
4100            disable_slash_commands: resolved.raw.claude_disable_slash_commands,
4101            settings: None,
4102            tools_profile: vec![],
4103            allowed_mcp_servers: vec![],
4104            policy: vec![],
4105            admin_policy: vec![],
4106        },
4107        Engine::Gemini => ProviderCapability {
4108            mode: inferred_capability_mode(
4109                &resolved.raw.gemini_capabilities,
4110                resolved.raw.gemini_settings.is_some()
4111                    || !resolved.raw.gemini_tools_profile.is_empty()
4112                    || !resolved.raw.gemini_allowed_mcp_servers.is_empty()
4113                    || !resolved.raw.gemini_policy.is_empty()
4114                    || !resolved.raw.gemini_admin_policy.is_empty(),
4115            ),
4116            config: vec![],
4117            mcp_profile: None,
4118            mcp_config: vec![],
4119            allowed_tools: vec![],
4120            disallowed_tools: vec![],
4121            tools: vec![],
4122            agent: None,
4123            agents_json: None,
4124            plugin_dirs: vec![],
4125            strict_mcp_config: false,
4126            disable_slash_commands: false,
4127            settings: resolved.raw.gemini_settings.clone(),
4128            tools_profile: resolved.raw.gemini_tools_profile.clone(),
4129            allowed_mcp_servers: resolved.raw.gemini_allowed_mcp_servers.clone(),
4130            policy: resolved.raw.gemini_policy.clone(),
4131            admin_policy: resolved.raw.gemini_admin_policy.clone(),
4132        },
4133    }
4134}
4135
4136fn inferred_capability_mode(configured: &str, has_override_flags: bool) -> String {
4137    if configured == CAPABILITY_INHERIT && has_override_flags {
4138        CAPABILITY_OVERRIDE.to_string()
4139    } else {
4140        configured.to_string()
4141    }
4142}
4143
4144fn provider_option(
4145    member: &str,
4146    codex: &Option<String>,
4147    claude: &Option<String>,
4148    gemini: &Option<String>,
4149) -> Option<String> {
4150    match Engine::parse(member) {
4151        Some(Engine::Codex) => codex.clone(),
4152        Some(Engine::Claude) => claude.clone(),
4153        Some(Engine::Gemini) => gemini.clone(),
4154        None => None,
4155    }
4156}
4157
4158fn build_sub_agent_prompt(original: &str, role: &str, index: usize, total: usize) -> String {
4159    format!(
4160        "You are sub-agent {index} of {total} for an Amon Hen provider assigned role `{role}`.\n\
4161         Work independently on a useful slice of the task. Inspect, reason, or verify as needed, \
4162         then return concise findings, risks, and concrete recommendations for the provider lead.\n\n\
4163         Original provider prompt:\n{original}"
4164    )
4165}
4166
4167fn build_team_lead_prompt(
4168    original: &str,
4169    sub_agents: &[EngineResult],
4170    max_member_chars: usize,
4171) -> String {
4172    let handoff = sub_agents
4173        .iter()
4174        .map(|agent| {
4175            let raw = if agent.output.trim().is_empty() {
4176                agent.detail.trim()
4177            } else {
4178                agent.output.trim()
4179            };
4180            let output = bounded_context_text(raw, max_member_chars, "sub-agent handoff");
4181            format!("### {} [{}]\n{}", agent.role, agent.status, output)
4182        })
4183        .collect::<Vec<_>>()
4184        .join("\n\n");
4185    format!(
4186        "You are the provider lead. Use the sub-agent handoffs below, resolve disagreements, \
4187         and produce the final provider response for the original Amon Hen role.\n\n\
4188         Sub-agent handoffs:\n{handoff}\n\nOriginal provider prompt:\n{original}"
4189    )
4190}
4191
4192fn run_engine(name: &str, options: EngineRunOptions) -> EngineResult {
4193    if let Some(result) = missing_engine_if_unavailable(name, &options) {
4194        return result;
4195    }
4196    if options.team_size > 0 && options.role != "summary" && !options.is_sub_agent {
4197        return run_engine_team(name, options);
4198    }
4199    run_engine_single(name, options, vec![])
4200}
4201
4202fn missing_engine_if_unavailable(name: &str, options: &EngineRunOptions) -> Option<EngineResult> {
4203    let bin = resolve_binary(name);
4204    if command_available(&bin) {
4205        return None;
4206    }
4207    let detail = format!(
4208        "Provider CLI `{bin}` was not found in PATH. Install the {name} CLI on this machine or set {} to the executable path.",
4209        Engine::parse(name)
4210            .map(Engine::binary_env_var)
4211            .unwrap_or("AMON_HEN_PROVIDER_BIN")
4212    );
4213    let message = format!("[amon-hen] {name} missing: {detail}");
4214    emit_runtime_event(
4215        &options.progress,
4216        options.live && options.progress.is_none(),
4217        progress_event_with_context(
4218            Some(name),
4219            Some(&options.role),
4220            ProgressStage::Done,
4221            Some("missing"),
4222            Some(options.iteration),
4223            Some(options.total_iterations),
4224            options.is_sub_agent,
4225            Some(0),
4226            Some(token_usage(&options.prompt, "")),
4227            vec![],
4228            message,
4229        ),
4230    );
4231    Some(EngineResult {
4232        name: name.to_string(),
4233        bin: Some(bin.clone()),
4234        status: "missing".to_string(),
4235        duration_ms: 0,
4236        detail: detail.clone(),
4237        exit_code: None,
4238        stdout: String::new(),
4239        stderr: detail,
4240        output: String::new(),
4241        command: bin,
4242        token_usage: token_usage(&options.prompt, ""),
4243        tool_calls: vec![],
4244        sub_agents: vec![],
4245        role: options.role.clone(),
4246        iteration: options.iteration,
4247        total_iterations: options.total_iterations,
4248        team_size: options.team_size,
4249    })
4250}
4251
4252fn command_available(command: &str) -> bool {
4253    let path = Path::new(command);
4254    if path.components().count() > 1 || path.is_absolute() {
4255        return path.is_file();
4256    }
4257    let Some(paths) = std::env::var_os("PATH") else {
4258        return false;
4259    };
4260    std::env::split_paths(&paths).any(|dir| dir.join(command).is_file())
4261}
4262
4263fn run_engine_team(name: &str, options: EngineRunOptions) -> EngineResult {
4264    let team_size = options.team_size;
4265    let (tx, rx) = mpsc::channel();
4266    for index in 1..=team_size {
4267        let tx = tx.clone();
4268        let name = name.to_string();
4269        let mut sub_options = options.clone();
4270        sub_options.team_size = 0;
4271        sub_options.is_sub_agent = true;
4272        sub_options.role = format!("{}:sub-agent-{index}", options.role);
4273        sub_options.prompt = limit_provider_prompt(
4274            &build_sub_agent_prompt(&options.prompt, &options.role, index, team_size),
4275            options.max_prompt_chars,
4276        );
4277        thread::spawn(move || {
4278            let _ = tx.send((index, run_engine_single(&name, sub_options, vec![])));
4279        });
4280    }
4281    drop(tx);
4282
4283    let mut indexed = rx.into_iter().collect::<Vec<_>>();
4284    indexed.sort_by_key(|(index, _)| *index);
4285    let sub_agents = indexed
4286        .into_iter()
4287        .map(|(_, result)| result)
4288        .collect::<Vec<_>>();
4289    let mut lead_options = options.clone();
4290    lead_options.prompt = limit_provider_prompt(
4291        &build_team_lead_prompt(&options.prompt, &sub_agents, options.max_member_chars),
4292        options.max_prompt_chars,
4293    );
4294    lead_options.is_sub_agent = true;
4295    run_engine_single(name, lead_options, sub_agents)
4296}
4297
4298fn run_engine_single(
4299    name: &str,
4300    options: EngineRunOptions,
4301    sub_agents: Vec<EngineResult>,
4302) -> EngineResult {
4303    let started = Instant::now();
4304    let bin = resolve_binary(name);
4305    let live = options.live;
4306    let role_is_sub_agent = options.role.contains(":sub-agent-");
4307    let start_message = format!(
4308        "[amon-hen] start {} role={} iteration={}/{}{}",
4309        name,
4310        options.role,
4311        options.iteration,
4312        options.total_iterations,
4313        if role_is_sub_agent { " sub-agent" } else { "" }
4314    );
4315    emit_runtime_event(
4316        &options.progress,
4317        live && options.progress.is_none(),
4318        progress_event_with_context(
4319            Some(name),
4320            Some(&options.role),
4321            ProgressStage::Start,
4322            Some("running"),
4323            Some(options.iteration),
4324            Some(options.total_iterations),
4325            role_is_sub_agent,
4326            None,
4327            None,
4328            vec![],
4329            start_message,
4330        ),
4331    );
4332    let result = match Engine::parse(name) {
4333        Some(Engine::Codex) => run_codex(&bin, &options),
4334        Some(Engine::Claude) => run_claude(&bin, &options),
4335        Some(Engine::Gemini) => run_gemini(&bin, &options),
4336        None => CommandResult {
4337            command: name.to_string(),
4338            args: vec![],
4339            code: None,
4340            stdout: String::new(),
4341            stderr: String::new(),
4342            timed_out: false,
4343            cancelled: false,
4344            error: Some(format!("Unknown engine: {name}")),
4345            timeout_ms: options.timeout_ms,
4346            duration_ms: started.elapsed().as_millis(),
4347        },
4348    };
4349    let engine_progress = options.progress.clone();
4350    let engine = finalize_engine(
4351        name,
4352        &bin,
4353        started.elapsed().as_millis(),
4354        result,
4355        options,
4356        sub_agents,
4357    );
4358    if live {
4359        let failure_detail = if engine.status == "ok" {
4360            String::new()
4361        } else {
4362            format!(
4363                " detail={}",
4364                truncate(&sanitize_status_detail(&engine.detail), 220)
4365            )
4366        };
4367        let done_message = format!(
4368            "[amon-hen] done {} role={} status={} elapsed={:.1}s tokens={} tools={} sub-agents={}{}",
4369            engine.name,
4370            engine.role,
4371            engine.status,
4372            engine.duration_ms as f64 / 1000.0,
4373            engine.token_usage.total,
4374            engine.tool_calls.len(),
4375            engine.sub_agents.len(),
4376            failure_detail
4377        );
4378        emit_runtime_event(
4379            &engine_progress,
4380            live && engine_progress.is_none(),
4381            progress_event_with_context(
4382                Some(&engine.name),
4383                Some(&engine.role),
4384                ProgressStage::Done,
4385                Some(&engine.status),
4386                Some(engine.iteration),
4387                Some(engine.total_iterations),
4388                engine.role.contains(":sub-agent-"),
4389                Some(engine.duration_ms),
4390                Some(engine.token_usage.clone()),
4391                engine.tool_calls.clone(),
4392                done_message,
4393            ),
4394        );
4395    }
4396    engine
4397}
4398
4399fn push_arg(args: &mut Vec<String>, flag: &str, value: impl Into<String>) {
4400    args.push(flag.to_string());
4401    args.push(value.into());
4402}
4403
4404fn push_optional_arg(args: &mut Vec<String>, flag: &str, value: &Option<String>) {
4405    if let Some(value) = value {
4406        push_arg(args, flag, value.clone());
4407    }
4408}
4409
4410fn push_repeated_flag(args: &mut Vec<String>, flag: &str, values: &[String]) {
4411    if !values.is_empty() {
4412        args.push(flag.to_string());
4413        args.extend(values.iter().cloned());
4414    }
4415}
4416
4417fn push_each_arg(args: &mut Vec<String>, flag: &str, values: &[String]) {
4418    for value in values {
4419        push_arg(args, flag, value.clone());
4420    }
4421}
4422
4423fn run_codex(bin: &str, options: &EngineRunOptions) -> CommandResult {
4424    let temp = match tempfile::tempdir() {
4425        Ok(temp) => temp,
4426        Err(error) => {
4427            return CommandResult {
4428                command: bin.to_string(),
4429                args: vec![],
4430                code: None,
4431                stdout: String::new(),
4432                stderr: String::new(),
4433                timed_out: false,
4434                cancelled: false,
4435                error: Some(error.to_string()),
4436                timeout_ms: options.timeout_ms,
4437                duration_ms: 0,
4438            }
4439        }
4440    };
4441    let output_path = temp.path().join("last-message.txt");
4442    let mut args = vec!["exec".to_string()];
4443    push_optional_arg(&mut args, "--model", &options.model);
4444    if let Some(effort) = &options.effort {
4445        push_arg(&mut args, "-c", format!("model_reasoning_effort={effort}"));
4446    }
4447    if options.capability.mode == CAPABILITY_OVERRIDE {
4448        for config in &options.capability.config {
4449            push_arg(&mut args, "-c", config.clone());
4450        }
4451        push_optional_arg(&mut args, "--profile", &options.capability.mcp_profile);
4452    }
4453    args.extend([
4454        "--skip-git-repo-check".to_string(),
4455        "--sandbox".to_string(),
4456        options
4457            .permission
4458            .clone()
4459            .unwrap_or_else(|| "read-only".to_string()),
4460        "--ephemeral".to_string(),
4461        "--json".to_string(),
4462        "-o".to_string(),
4463        output_path.display().to_string(),
4464        "-".to_string(),
4465    ]);
4466    let mut result = run_command(
4467        CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
4468            .stdin_text(Some(&options.prompt))
4469            .progress(command_progress_with_input(
4470                live_label("codex", options).as_deref(),
4471                options.progress.clone(),
4472                estimate_tokens(&options.prompt),
4473            ))
4474            .cancel(options.cancel.clone()),
4475    );
4476    if let Ok(output) = fs::read_to_string(output_path) {
4477        if !output.trim().is_empty() {
4478            result.stdout = output;
4479        }
4480    }
4481    result
4482}
4483
4484fn run_claude(bin: &str, options: &EngineRunOptions) -> CommandResult {
4485    let mut args = Vec::new();
4486    if should_use_claude_bare_mode(&options.auth) {
4487        args.push("--bare".to_string());
4488    }
4489    args.extend([
4490        "-p".to_string(),
4491        "--permission-mode".to_string(),
4492        options
4493            .permission
4494            .clone()
4495            .unwrap_or_else(|| "plan".to_string()),
4496        "--verbose".to_string(),
4497        "--output-format".to_string(),
4498        "stream-json".to_string(),
4499        "--include-partial-messages".to_string(),
4500        "--no-session-persistence".to_string(),
4501    ]);
4502    push_optional_arg(&mut args, "--model", &options.model);
4503    if let Some(effort) = options
4504        .effort
4505        .clone()
4506        .or_else(|| std::env::var("CLAUDE_CODE_EFFORT_LEVEL").ok())
4507    {
4508        push_arg(&mut args, "--effort", effort);
4509    }
4510    if options.capability.mode == CAPABILITY_OVERRIDE {
4511        push_repeated_flag(&mut args, "--mcp-config", &options.capability.mcp_config);
4512        push_repeated_flag(
4513            &mut args,
4514            "--allowedTools",
4515            &options.capability.allowed_tools,
4516        );
4517        push_repeated_flag(
4518            &mut args,
4519            "--disallowedTools",
4520            &options.capability.disallowed_tools,
4521        );
4522        push_repeated_flag(&mut args, "--tools", &options.capability.tools);
4523        push_optional_arg(&mut args, "--agent", &options.capability.agent);
4524        push_optional_arg(&mut args, "--agents", &options.capability.agents_json);
4525        push_each_arg(&mut args, "--plugin-dir", &options.capability.plugin_dirs);
4526        if options.capability.strict_mcp_config {
4527            args.push("--strict-mcp-config".to_string());
4528        }
4529        if options.capability.disable_slash_commands {
4530            args.push("--disable-slash-commands".to_string());
4531        }
4532    }
4533    run_command(
4534        CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
4535            .stdin_text(Some(&options.prompt))
4536            .progress(command_progress_with_input(
4537                live_label("claude", options).as_deref(),
4538                options.progress.clone(),
4539                estimate_tokens(&options.prompt),
4540            ))
4541            .cancel(options.cancel.clone()),
4542    )
4543}
4544
4545fn run_gemini(bin: &str, options: &EngineRunOptions) -> CommandResult {
4546    let mut args = Vec::new();
4547    push_optional_arg(&mut args, "--model", &options.model);
4548    if options.capability.mode == CAPABILITY_OVERRIDE {
4549        push_repeated_flag(&mut args, "--extensions", &options.capability.tools_profile);
4550        push_repeated_flag(
4551            &mut args,
4552            "--allowed-mcp-server-names",
4553            &options.capability.allowed_mcp_servers,
4554        );
4555        push_repeated_flag(&mut args, "--policy", &options.capability.policy);
4556        push_repeated_flag(
4557            &mut args,
4558            "--admin-policy",
4559            &options.capability.admin_policy,
4560        );
4561    }
4562    args.extend([
4563        "-p".to_string(),
4564        String::new(),
4565        "--skip-trust".to_string(),
4566        "--approval-mode".to_string(),
4567        options
4568            .permission
4569            .clone()
4570            .unwrap_or_else(|| "plan".to_string()),
4571        "--output-format".to_string(),
4572        "stream-json".to_string(),
4573    ]);
4574    let mut envs = HashMap::new();
4575    let effort_settings = prepare_gemini_settings(options);
4576    if let Some(path) = effort_settings.as_ref() {
4577        envs.insert(
4578            "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
4579            path.path.display().to_string(),
4580        );
4581    } else if options.capability.mode == CAPABILITY_OVERRIDE {
4582        if let Some(settings) = &options.capability.settings {
4583            envs.insert(
4584                "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
4585                settings.clone(),
4586            );
4587        }
4588    }
4589    run_command(
4590        CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
4591            .envs(envs)
4592            .stdin_text(Some(&options.prompt))
4593            .progress(command_progress_with_input(
4594                live_label("gemini", options).as_deref(),
4595                options.progress.clone(),
4596                estimate_tokens(&options.prompt),
4597            ))
4598            .cancel(options.cancel.clone()),
4599    )
4600}
4601
4602fn live_label(name: &str, options: &EngineRunOptions) -> Option<String> {
4603    options.live.then(|| {
4604        format!(
4605            "{} {} iteration {}/{}",
4606            name, options.role, options.iteration, options.total_iterations
4607        )
4608    })
4609}
4610
4611fn provider_from_live_label(label: &str) -> Option<&str> {
4612    let provider = label.split_whitespace().next()?;
4613    ENGINES.contains(&provider).then_some(provider)
4614}
4615
4616fn role_from_live_label(label: &str) -> Option<&str> {
4617    if provider_from_live_label(label).is_some() {
4618        return label.split_whitespace().nth(1);
4619    }
4620    None
4621}
4622
4623fn iteration_from_live_label(label: &str) -> Option<usize> {
4624    iteration_pair_from_live_label(label).map(|(iteration, _)| iteration)
4625}
4626
4627fn total_iterations_from_live_label(label: &str) -> Option<usize> {
4628    iteration_pair_from_live_label(label).map(|(_, total)| total)
4629}
4630
4631fn iteration_pair_from_live_label(label: &str) -> Option<(usize, usize)> {
4632    let pair = label.split_whitespace().last()?;
4633    let (iteration, total) = pair.split_once('/')?;
4634    Some((iteration.parse().ok()?, total.parse().ok()?))
4635}
4636
4637fn prepare_gemini_settings(options: &EngineRunOptions) -> Option<TempSettings> {
4638    let effort = options.effort.as_deref()?;
4639    let budget = match effort {
4640        "low" => 1024,
4641        "medium" => 8192,
4642        "high" => 24576,
4643        _ => return None,
4644    };
4645    let dir = tempfile::tempdir().ok()?;
4646    let path = dir.path().join("settings.json");
4647    let mut settings = if options.capability.mode == CAPABILITY_OVERRIDE {
4648        options
4649            .capability
4650            .settings
4651            .as_ref()
4652            .and_then(|path| fs::read_to_string(path).ok())
4653            .and_then(|text| serde_json::from_str::<Value>(&text).ok())
4654            .unwrap_or_else(|| Value::Object(Default::default()))
4655    } else {
4656        Value::Object(Default::default())
4657    };
4658    if let Value::Object(object) = &mut settings {
4659        object.insert("thinkingBudget".to_string(), Value::from(budget));
4660    }
4661    let _ = fs::write(&path, serde_json::to_vec(&settings).ok()?);
4662    Some(TempSettings { _dir: dir, path })
4663}
4664
4665fn should_use_claude_bare_mode(auth: &str) -> bool {
4666    if auth == "api-key" {
4667        return true;
4668    }
4669    if matches!(auth, "social-login" | "oauth" | "keychain") {
4670        return false;
4671    }
4672    if std::env::var("CLAUDE_CODE_OAUTH_TOKEN")
4673        .ok()
4674        .filter(|value| !value.trim().is_empty())
4675        .is_some()
4676    {
4677        return false;
4678    }
4679    std::env::var("ANTHROPIC_API_KEY")
4680        .ok()
4681        .filter(|value| !value.trim().is_empty())
4682        .is_some()
4683}
4684
4685fn configure_process_group(command: &mut Command) {
4686    #[cfg(unix)]
4687    {
4688        use std::os::unix::process::CommandExt;
4689        command.process_group(0);
4690    }
4691}
4692
4693fn terminate_child_tree(child: &mut Child) {
4694    #[cfg(unix)]
4695    {
4696        let pgid = -(child.id() as i32);
4697        unsafe {
4698            libc::kill(pgid, libc::SIGTERM);
4699        }
4700        for _ in 0..20 {
4701            if child.try_wait().ok().flatten().is_some() {
4702                return;
4703            }
4704            thread::sleep(Duration::from_millis(25));
4705        }
4706        unsafe {
4707            libc::kill(pgid, libc::SIGKILL);
4708        }
4709    }
4710    let _ = child.kill();
4711}
4712
4713fn run_command(request: CommandRequest<'_>) -> CommandResult {
4714    let CommandRequest {
4715        command,
4716        args,
4717        cwd,
4718        stdin_text,
4719        timeout_ms,
4720        envs,
4721        progress,
4722        cancel,
4723    } = request;
4724    let started = Instant::now();
4725    if let Some(progress) = &progress {
4726        emit_runtime_event(
4727            &progress.sink,
4728            progress.sink.is_none(),
4729            progress_event_with_context(
4730                provider_from_live_label(&progress.label),
4731                role_from_live_label(&progress.label),
4732                ProgressStage::Spawn,
4733                Some("running"),
4734                iteration_from_live_label(&progress.label),
4735                total_iterations_from_live_label(&progress.label),
4736                role_from_live_label(&progress.label)
4737                    .is_some_and(|role| role.contains(":sub-agent-")),
4738                None,
4739                None,
4740                vec![],
4741                format!("[amon-hen] spawn {}", progress.label),
4742            ),
4743        );
4744    }
4745    let mut process = Command::new(command);
4746    process
4747        .args(args)
4748        .current_dir(cwd)
4749        .envs(envs)
4750        .stdin(Stdio::piped())
4751        .stdout(Stdio::piped())
4752        .stderr(Stdio::piped());
4753    configure_process_group(&mut process);
4754    let mut child = match process.spawn() {
4755        Ok(child) => child,
4756        Err(error) => {
4757            let status = spawn_error_status(&error);
4758            if let Some(progress) = &progress {
4759                emit_runtime_event(
4760                    &progress.sink,
4761                    progress.sink.is_none(),
4762                    progress_event_with_context(
4763                        provider_from_live_label(&progress.label),
4764                        role_from_live_label(&progress.label),
4765                        ProgressStage::Done,
4766                        Some(status),
4767                        iteration_from_live_label(&progress.label),
4768                        total_iterations_from_live_label(&progress.label),
4769                        role_from_live_label(&progress.label)
4770                            .is_some_and(|role| role.contains(":sub-agent-")),
4771                        Some(started.elapsed().as_millis()),
4772                        None,
4773                        vec![],
4774                        format!("[amon-hen] spawn failed {}: {error}", progress.label),
4775                    ),
4776                );
4777            }
4778            return CommandResult {
4779                command: command.to_string(),
4780                args: args.to_vec(),
4781                code: None,
4782                stdout: String::new(),
4783                stderr: String::new(),
4784                timed_out: false,
4785                cancelled: false,
4786                error: Some(error.to_string()),
4787                timeout_ms,
4788                duration_ms: started.elapsed().as_millis(),
4789            };
4790        }
4791    };
4792
4793    let stdout = child
4794        .stdout
4795        .take()
4796        .map(|pipe| read_pipe(pipe, progress.clone(), "stdout"));
4797    let stderr = child
4798        .stderr
4799        .take()
4800        .map(|pipe| read_pipe(pipe, progress.clone(), "stderr"));
4801    let mut stdin_writer = child.stdin.take().and_then(|mut stdin| {
4802        stdin_text.map(|text| {
4803            let text = text.to_string();
4804            thread::spawn(move || {
4805                let _ = stdin.write_all(text.as_bytes());
4806            })
4807        })
4808    });
4809    let timeout = Duration::from_millis(timeout_ms);
4810    let mut next_live_tick = Duration::from_secs(10);
4811    let mut timed_out = false;
4812    let mut cancelled = false;
4813    let code;
4814    loop {
4815        match child.try_wait() {
4816            Ok(Some(status)) => {
4817                code = status.code();
4818                break;
4819            }
4820            Ok(None) => {
4821                if cancel
4822                    .as_ref()
4823                    .is_some_and(|cancel| cancel.load(Ordering::Relaxed))
4824                {
4825                    cancelled = true;
4826                    terminate_child_tree(&mut child);
4827                    let status = child.wait().ok();
4828                    code = status.and_then(|status| status.code());
4829                    break;
4830                }
4831                if timeout_ms > 0 && started.elapsed() >= timeout {
4832                    timed_out = true;
4833                    terminate_child_tree(&mut child);
4834                    let status = child.wait().ok();
4835                    code = status.and_then(|status| status.code());
4836                    break;
4837                }
4838                if let Some(progress) = &progress {
4839                    let elapsed = started.elapsed();
4840                    if elapsed >= next_live_tick {
4841                        let timeout_detail = if timeout_ms > 0 {
4842                            format!("/{}s", timeout_ms / 1000)
4843                        } else {
4844                            String::new()
4845                        };
4846                        emit_runtime_event(
4847                            &progress.sink,
4848                            progress.sink.is_none(),
4849                            progress_event_with_context(
4850                                provider_from_live_label(&progress.label),
4851                                role_from_live_label(&progress.label),
4852                                ProgressStage::Heartbeat,
4853                                Some("running"),
4854                                iteration_from_live_label(&progress.label),
4855                                total_iterations_from_live_label(&progress.label),
4856                                role_from_live_label(&progress.label)
4857                                    .is_some_and(|role| role.contains(":sub-agent-")),
4858                                Some(elapsed.as_millis()),
4859                                None,
4860                                vec![],
4861                                format!(
4862                                    "[amon-hen] running {} for {}s{}",
4863                                    progress.label,
4864                                    elapsed.as_secs(),
4865                                    timeout_detail
4866                                ),
4867                            ),
4868                        );
4869                        next_live_tick += Duration::from_secs(10);
4870                    }
4871                }
4872                thread::sleep(Duration::from_millis(25));
4873            }
4874            Err(error) => {
4875                if let Some(handle) = stdin_writer.take() {
4876                    let _ = handle.join();
4877                }
4878                return CommandResult {
4879                    command: command.to_string(),
4880                    args: args.to_vec(),
4881                    code: None,
4882                    stdout: String::new(),
4883                    stderr: String::new(),
4884                    timed_out,
4885                    cancelled,
4886                    error: Some(error.to_string()),
4887                    timeout_ms,
4888                    duration_ms: started.elapsed().as_millis(),
4889                };
4890            }
4891        }
4892    }
4893
4894    if let Some(handle) = stdin_writer {
4895        let _ = handle.join();
4896    }
4897
4898    CommandResult {
4899        command: command.to_string(),
4900        args: args.to_vec(),
4901        code,
4902        stdout: stdout
4903            .and_then(|handle| handle.join().ok())
4904            .unwrap_or_default(),
4905        stderr: stderr
4906            .and_then(|handle| handle.join().ok())
4907            .unwrap_or_default(),
4908        timed_out,
4909        cancelled,
4910        error: cancelled.then(|| "cancelled".to_string()),
4911        timeout_ms,
4912        duration_ms: started.elapsed().as_millis(),
4913    }
4914}
4915
4916fn read_pipe<R>(
4917    mut pipe: R,
4918    progress: Option<CommandProgress>,
4919    stream: &'static str,
4920) -> thread::JoinHandle<String>
4921where
4922    R: Read + Send + 'static,
4923{
4924    thread::spawn(move || {
4925        let mut text = String::new();
4926        let mut pending = String::new();
4927        let mut buffer = [0u8; 4096];
4928        let mut last_stream_event = Instant::now() - PROVIDER_STREAM_EVENT_MIN_INTERVAL;
4929        let mut stream_state = ProviderStreamState::new();
4930        loop {
4931            let read = match pipe.read(&mut buffer) {
4932                Ok(0) => break,
4933                Ok(read) => read,
4934                Err(_) => break,
4935            };
4936            let chunk = String::from_utf8_lossy(&buffer[..read]);
4937            text.push_str(&chunk);
4938            pending.push_str(&chunk);
4939            while let Some(newline) = pending.find('\n') {
4940                let mut line = pending.drain(..=newline).collect::<String>();
4941                if line.ends_with('\n') {
4942                    line.pop();
4943                }
4944                if line.ends_with('\r') {
4945                    line.pop();
4946                }
4947                if stream_state.maybe_emit_assistant_delta(
4948                    &progress,
4949                    stream,
4950                    &line,
4951                    &text,
4952                    &mut last_stream_event,
4953                ) {
4954                    continue;
4955                }
4956                maybe_emit_provider_stream_line(
4957                    &progress,
4958                    stream,
4959                    &line,
4960                    &text,
4961                    &mut last_stream_event,
4962                );
4963            }
4964            if !chunk.contains('\n') {
4965                maybe_emit_provider_stream_chunk(
4966                    &progress,
4967                    stream,
4968                    &chunk,
4969                    &text,
4970                    &mut last_stream_event,
4971                );
4972            }
4973        }
4974        stream_state.flush(&progress, stream, &text);
4975        if !pending.trim().is_empty() {
4976            emit_provider_stream_line(&progress, stream, &pending, &text);
4977        }
4978        text
4979    })
4980}
4981
4982struct ProviderStreamState {
4983    assistant_text: String,
4984    emitted_assistant_bytes: usize,
4985}
4986
4987impl ProviderStreamState {
4988    fn new() -> Self {
4989        Self {
4990            assistant_text: String::new(),
4991            emitted_assistant_bytes: 0,
4992        }
4993    }
4994
4995    fn maybe_emit_assistant_delta(
4996        &mut self,
4997        progress: &Option<CommandProgress>,
4998        stream: &str,
4999        line: &str,
5000        accumulated: &str,
5001        last_stream_event: &mut Instant,
5002    ) -> bool {
5003        let Some(progress) = progress else {
5004            return false;
5005        };
5006        let Some(provider) = provider_from_live_label(&progress.label) else {
5007            return false;
5008        };
5009        let Some(delta) = provider_assistant_text_delta(provider, line) else {
5010            return false;
5011        };
5012        if delta.is_empty() {
5013            return true;
5014        }
5015        self.assistant_text.push_str(&delta);
5016        let now = Instant::now();
5017        let bytes_since_emit = self
5018            .assistant_text
5019            .len()
5020            .saturating_sub(self.emitted_assistant_bytes);
5021        if bytes_since_emit >= 96
5022            || now.duration_since(*last_stream_event) >= PROVIDER_STREAM_EVENT_MIN_INTERVAL
5023        {
5024            self.emit_snapshot(progress, stream, accumulated);
5025            *last_stream_event = now;
5026        }
5027        true
5028    }
5029
5030    fn flush(&mut self, progress: &Option<CommandProgress>, stream: &str, accumulated: &str) {
5031        let Some(progress) = progress else {
5032            return;
5033        };
5034        if self.assistant_text.len() > self.emitted_assistant_bytes {
5035            self.emit_snapshot(progress, stream, accumulated);
5036        }
5037    }
5038
5039    fn emit_snapshot(&mut self, progress: &CommandProgress, stream: &str, accumulated: &str) {
5040        let visible = format!(
5041            "assistant live: {}",
5042            sanitize_status_detail(&tail_chars(&self.assistant_text, 900))
5043        );
5044        emit_provider_stream_visible(progress, stream, accumulated, &visible, Vec::new());
5045        self.emitted_assistant_bytes = self.assistant_text.len();
5046    }
5047}
5048
5049fn maybe_emit_provider_stream_chunk(
5050    progress: &Option<CommandProgress>,
5051    stream: &str,
5052    chunk: &str,
5053    accumulated: &str,
5054    last_stream_event: &mut Instant,
5055) {
5056    if progress
5057        .as_ref()
5058        .and_then(|progress| provider_from_live_label(&progress.label))
5059        .is_some()
5060    {
5061        return;
5062    }
5063    let clipped = chunk.trim();
5064    if clipped.len() >= 24 {
5065        maybe_emit_provider_stream_line(progress, stream, clipped, accumulated, last_stream_event);
5066    }
5067}
5068
5069fn maybe_emit_provider_stream_line(
5070    progress: &Option<CommandProgress>,
5071    stream: &str,
5072    line: &str,
5073    accumulated: &str,
5074    last_stream_event: &mut Instant,
5075) {
5076    let now = Instant::now();
5077    let important = provider_stream_line_is_important(line);
5078    if important || now.duration_since(*last_stream_event) >= PROVIDER_STREAM_EVENT_MIN_INTERVAL {
5079        emit_provider_stream_line(progress, stream, line, accumulated);
5080        *last_stream_event = now;
5081    }
5082}
5083
5084fn provider_stream_line_is_important(line: &str) -> bool {
5085    let line = line.to_ascii_lowercase();
5086    [
5087        "tool_use",
5088        "tool_call",
5089        "functioncall",
5090        "function_call",
5091        "command_execution",
5092        "item.completed",
5093        "\"result\"",
5094        "\"error\"",
5095        "error",
5096        "failed",
5097        "permission",
5098    ]
5099    .iter()
5100    .any(|needle| line.contains(needle))
5101}
5102
5103fn emit_provider_stream_line(
5104    progress: &Option<CommandProgress>,
5105    stream: &str,
5106    line: &str,
5107    accumulated: &str,
5108) {
5109    let Some(progress) = progress else {
5110        return;
5111    };
5112    let line = line.trim();
5113    if line.is_empty() {
5114        return;
5115    }
5116    let provider = provider_from_live_label(&progress.label);
5117    let tool_calls = provider
5118        .map(|provider| extract_tool_usage(provider, line, ""))
5119        .unwrap_or_default();
5120    let visible = match provider_visible_stream(provider, line, &tool_calls) {
5121        StreamDisplay::Visible(visible) => visible,
5122        StreamDisplay::Suppress => return,
5123    };
5124    emit_provider_stream_visible(progress, stream, accumulated, &visible, tool_calls);
5125}
5126
5127fn emit_provider_stream_visible(
5128    progress: &CommandProgress,
5129    stream: &str,
5130    accumulated: &str,
5131    visible: &str,
5132    tool_calls: Vec<ToolUsage>,
5133) {
5134    let provider = provider_from_live_label(&progress.label);
5135    let role = role_from_live_label(&progress.label);
5136    let output_tokens = estimate_tokens(accumulated);
5137    let token_usage = provider.map(|_| TokenUsage {
5138        input: progress.input_tokens,
5139        output: output_tokens,
5140        total: progress.input_tokens + output_tokens,
5141        estimated: true,
5142        source: "live-stream-estimate".to_string(),
5143    });
5144    emit_runtime_event(
5145        &progress.sink,
5146        progress.sink.is_none(),
5147        progress_event_with_context(
5148            provider,
5149            role,
5150            ProgressStage::Heartbeat,
5151            Some("streaming"),
5152            iteration_from_live_label(&progress.label),
5153            total_iterations_from_live_label(&progress.label),
5154            role.is_some_and(|role| role.contains(":sub-agent-")),
5155            None,
5156            token_usage,
5157            tool_calls,
5158            format!(
5159                "[amon-hen] stream {} {stream}: {}",
5160                progress.label,
5161                truncate(visible, 220)
5162            ),
5163        ),
5164    );
5165}
5166
5167#[derive(Debug, Eq, PartialEq)]
5168enum StreamDisplay {
5169    Visible(String),
5170    Suppress,
5171}
5172
5173fn provider_visible_stream(
5174    provider: Option<&str>,
5175    line: &str,
5176    tool_calls: &[ToolUsage],
5177) -> StreamDisplay {
5178    let Some(provider) = provider else {
5179        return StreamDisplay::Visible(sanitize_status_detail(line));
5180    };
5181    let Ok(value) = serde_json::from_str::<Value>(line) else {
5182        if looks_like_json_line(line) {
5183            return StreamDisplay::Suppress;
5184        }
5185        return StreamDisplay::Visible(sanitize_status_detail(line));
5186    };
5187    let visible = match provider {
5188        "claude" => claude_visible_stream(&value),
5189        "gemini" => gemini_visible_stream(&value),
5190        "codex" => codex_visible_stream(&value),
5191        _ => None,
5192    }
5193    .or_else(|| tool_calls.first().map(visible_tool_summary));
5194    visible
5195        .filter(|text| !text.trim().is_empty())
5196        .map(StreamDisplay::Visible)
5197        .unwrap_or(StreamDisplay::Suppress)
5198}
5199
5200fn provider_assistant_text_delta(provider: &str, line: &str) -> Option<String> {
5201    let value = serde_json::from_str::<Value>(line).ok()?;
5202    match provider {
5203        "claude" => claude_assistant_text_delta(&value),
5204        _ => None,
5205    }
5206}
5207
5208fn claude_assistant_text_delta(value: &Value) -> Option<String> {
5209    if value.get("type").and_then(Value::as_str) == Some("stream_event") {
5210        return value.get("event").and_then(claude_assistant_text_delta);
5211    }
5212    match value.get("type").and_then(Value::as_str) {
5213        Some("content_block_delta") => {
5214            let delta = value.get("delta")?;
5215            if delta.get("type").and_then(Value::as_str) == Some("text_delta") {
5216                return delta
5217                    .get("text")
5218                    .and_then(Value::as_str)
5219                    .map(str::to_string);
5220            }
5221        }
5222        Some("message_delta" | "message_start" | "message_stop" | "content_block_stop") => {}
5223        _ => {
5224            if let Some(text) = value.pointer("/delta/text").and_then(Value::as_str) {
5225                return Some(text.to_string());
5226            }
5227        }
5228    }
5229    None
5230}
5231
5232fn tail_chars(text: &str, max_chars: usize) -> String {
5233    byte_index_for_tail_chars(text, max_chars)
5234        .map(|index| format!("...{}", &text[index..]))
5235        .unwrap_or_else(|| text.to_string())
5236}
5237
5238fn visible_tool_summary(tool: &ToolUsage) -> String {
5239    let detail = sanitize_status_detail(&tool.detail);
5240    if detail.is_empty() {
5241        format!("tool {}: {}", tool.status, tool.name)
5242    } else {
5243        format!("tool {}: {} {}", tool.status, tool.name, detail)
5244    }
5245}
5246
5247fn claude_visible_stream(value: &Value) -> Option<String> {
5248    if value.get("type").and_then(Value::as_str) == Some("stream_event") {
5249        return value.get("event").and_then(claude_visible_stream);
5250    }
5251    match value.get("type").and_then(Value::as_str) {
5252        Some("content_block_delta") => return claude_visible_delta(value.get("delta")?),
5253        Some("content_block_start") => {
5254            return value
5255                .get("content_block")
5256                .and_then(visible_content_block)
5257                .map(|text| sanitize_status_detail(&text));
5258        }
5259        Some("content_block_stop" | "message_start" | "message_delta" | "message_stop") => {
5260            return None;
5261        }
5262        _ => {}
5263    }
5264    if let Some(result) = non_empty_str(value.get("result")) {
5265        return Some(format!("result: {}", sanitize_status_detail(result)));
5266    }
5267    if let Some(content) = value.pointer("/message/content").and_then(Value::as_array) {
5268        if let Some(text) = visible_content_blocks(content) {
5269            return Some(text);
5270        }
5271    }
5272    if let Some(delta) = value.pointer("/delta/text").and_then(Value::as_str) {
5273        return Some(format!("assistant: {}", sanitize_status_detail(delta)));
5274    }
5275    if let Some(block) = value.pointer("/content_block") {
5276        if let Some(text) = visible_content_block(block) {
5277            return Some(text);
5278        }
5279    }
5280    if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
5281        return Some(format!("error: {}", sanitize_status_detail(error)));
5282    }
5283    None
5284}
5285
5286fn claude_visible_delta(delta: &Value) -> Option<String> {
5287    match delta.get("type").and_then(Value::as_str) {
5288        Some("text_delta") => delta
5289            .get("text")
5290            .and_then(Value::as_str)
5291            .filter(|text| !text.trim().is_empty())
5292            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
5293        Some("input_json_delta" | "thinking_delta" | "signature_delta") => None,
5294        _ => delta
5295            .get("text")
5296            .and_then(Value::as_str)
5297            .filter(|text| !text.trim().is_empty())
5298            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
5299    }
5300}
5301
5302fn claude_output_text(value: &Value) -> Option<String> {
5303    if value.get("type").and_then(Value::as_str) == Some("stream_event") {
5304        return value.get("event").and_then(claude_output_text);
5305    }
5306    match value.get("type").and_then(Value::as_str) {
5307        Some("content_block_delta") => {
5308            let delta = value.get("delta")?;
5309            return match delta.get("type").and_then(Value::as_str) {
5310                Some("text_delta") => non_empty_str(delta.get("text")).map(str::to_string),
5311                _ => None,
5312            };
5313        }
5314        Some("content_block_start") => {
5315            let block = value.get("content_block")?;
5316            if matches!(
5317                block.get("type").and_then(Value::as_str),
5318                Some("text" | "output_text")
5319            ) {
5320                return non_empty_str(block.get("text")).map(str::to_string);
5321            }
5322        }
5323        _ => {}
5324    }
5325    if let Some(result) = non_empty_str(value.get("result")) {
5326        return Some(result.to_string());
5327    }
5328    value
5329        .pointer("/message/content")
5330        .and_then(Value::as_array)
5331        .map(|content| {
5332            content
5333                .iter()
5334                .filter_map(|block| {
5335                    matches!(
5336                        block.get("type").and_then(Value::as_str),
5337                        Some("text" | "output_text")
5338                    )
5339                    .then(|| non_empty_str(block.get("text")))
5340                    .flatten()
5341                })
5342                .collect::<String>()
5343        })
5344        .filter(|text| !text.trim().is_empty())
5345}
5346
5347fn gemini_visible_stream(value: &Value) -> Option<String> {
5348    match value.get("type").and_then(Value::as_str) {
5349        Some("message") => {
5350            if value.get("role").and_then(Value::as_str) != Some("assistant") {
5351                return None;
5352            }
5353            return non_empty_str(value.get("content"))
5354                .map(|text| format!("assistant: {}", sanitize_status_detail(text)));
5355        }
5356        Some("tool_use") => return Some(format!("tool: {}", function_call_summary(value))),
5357        Some("tool_result") => {
5358            let status = value
5359                .get("status")
5360                .and_then(Value::as_str)
5361                .unwrap_or("observed");
5362            let detail = value
5363                .get("output")
5364                .or_else(|| value.pointer("/error/message"))
5365                .and_then(Value::as_str)
5366                .map(sanitize_status_detail)
5367                .unwrap_or_default();
5368            let tool_id = value
5369                .get("tool_id")
5370                .and_then(Value::as_str)
5371                .unwrap_or("tool");
5372            if detail.is_empty() {
5373                return Some(format!("tool result {status}: {tool_id}"));
5374            }
5375            return Some(format!(
5376                "tool result {status}: {tool_id} -> {}",
5377                truncate(&detail, 140)
5378            ));
5379        }
5380        Some("error") => {
5381            if let Some(message) = non_empty_str(value.get("message")) {
5382                return Some(format!("error: {}", sanitize_status_detail(message)));
5383            }
5384        }
5385        Some("result") => {
5386            if value.get("status").and_then(Value::as_str) == Some("error") {
5387                if let Some(message) = value.pointer("/error/message").and_then(Value::as_str) {
5388                    return Some(format!("error: {}", sanitize_status_detail(message)));
5389                }
5390                return Some("error: Gemini returned an error result.".to_string());
5391            }
5392            return None;
5393        }
5394        Some("init") => return None,
5395        _ => {}
5396    }
5397    if let Some(response) = non_empty_str(value.get("response")) {
5398        return Some(format!("assistant: {}", sanitize_status_detail(response)));
5399    }
5400    if let Some(text) = non_empty_str(value.get("text")) {
5401        return Some(format!("assistant: {}", sanitize_status_detail(text)));
5402    }
5403    if let Some(text) = value
5404        .pointer("/candidates/0/content/parts/0/text")
5405        .and_then(Value::as_str)
5406        .filter(|text| !text.trim().is_empty())
5407    {
5408        return Some(format!("assistant: {}", sanitize_status_detail(text)));
5409    }
5410    if let Some(call) = value
5411        .pointer("/functionCall")
5412        .or_else(|| value.pointer("/candidates/0/content/parts/0/functionCall"))
5413    {
5414        return Some(format!("tool: {}", function_call_summary(call)));
5415    }
5416    if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
5417        return Some(format!("error: {}", sanitize_status_detail(error)));
5418    }
5419    None
5420}
5421
5422fn codex_visible_stream(value: &Value) -> Option<String> {
5423    if let Some(message) = non_empty_str(value.get("message")) {
5424        return Some(format!("assistant: {}", sanitize_status_detail(message)));
5425    }
5426    if let Some(text) = non_empty_str(value.get("text")) {
5427        return Some(format!("assistant: {}", sanitize_status_detail(text)));
5428    }
5429    if let Some(item) = value.get("item") {
5430        return codex_visible_item(item);
5431    }
5432    if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
5433        return Some(format!("error: {}", sanitize_status_detail(error)));
5434    }
5435    None
5436}
5437
5438fn codex_visible_item(item: &Value) -> Option<String> {
5439    let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
5440    match item_type {
5441        "message" => codex_message_text(item),
5442        "command_execution" => {
5443            let command = item
5444                .get("command")
5445                .and_then(Value::as_str)
5446                .map(sanitize_status_detail)
5447                .unwrap_or_else(|| "command".to_string());
5448            let output = item
5449                .get("aggregated_output")
5450                .or_else(|| item.get("output"))
5451                .and_then(Value::as_str)
5452                .map(sanitize_status_detail)
5453                .unwrap_or_default();
5454            if output.trim().is_empty() {
5455                Some(format!("tool: shell {command}"))
5456            } else {
5457                Some(format!(
5458                    "tool: shell {command} -> {}",
5459                    truncate(&output, 140)
5460                ))
5461            }
5462        }
5463        "function_call" | "tool_call" => Some(format!("tool: {}", function_call_summary(item))),
5464        "error" => item
5465            .get("message")
5466            .and_then(Value::as_str)
5467            .map(|message| format!("error: {}", sanitize_status_detail(message))),
5468        "reasoning" => item
5469            .get("summary")
5470            .and_then(Value::as_str)
5471            .filter(|summary| !summary.trim().is_empty())
5472            .map(|summary| format!("reasoning summary: {}", sanitize_status_detail(summary))),
5473        _ => None,
5474    }
5475}
5476
5477fn codex_message_text(item: &Value) -> Option<String> {
5478    if let Some(text) = non_empty_str(item.get("text")) {
5479        return Some(sanitize_status_detail(text));
5480    }
5481    item.get("content")
5482        .and_then(Value::as_array)
5483        .and_then(|blocks| visible_content_blocks(blocks))
5484}
5485
5486fn visible_content_blocks(blocks: &[Value]) -> Option<String> {
5487    let parts = blocks
5488        .iter()
5489        .filter_map(visible_content_block)
5490        .collect::<Vec<_>>();
5491    (!parts.is_empty()).then(|| parts.join(" | "))
5492}
5493
5494fn visible_content_block(block: &Value) -> Option<String> {
5495    match block.get("type").and_then(Value::as_str) {
5496        Some("text") | Some("output_text") => non_empty_str(block.get("text"))
5497            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
5498        Some("tool_use") => {
5499            let name = block.get("name").and_then(Value::as_str).unwrap_or("tool");
5500            let input = block
5501                .get("input")
5502                .map(short_json_detail)
5503                .unwrap_or_default();
5504            if input.is_empty() {
5505                Some(format!("tool: {name}"))
5506            } else {
5507                Some(format!("tool: {name} {}", truncate(&input, 140)))
5508            }
5509        }
5510        Some("function_call") | Some("tool_call") => {
5511            Some(format!("tool: {}", function_call_summary(block)))
5512        }
5513        _ => non_empty_str(block.get("text"))
5514            .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
5515    }
5516}
5517
5518fn function_call_summary(value: &Value) -> String {
5519    let name = value
5520        .get("name")
5521        .or_else(|| value.get("tool"))
5522        .or_else(|| value.get("tool_name"))
5523        .or_else(|| value.get("function"))
5524        .and_then(Value::as_str)
5525        .unwrap_or("tool");
5526    let args = value
5527        .get("args")
5528        .or_else(|| value.get("arguments"))
5529        .or_else(|| value.get("parameters"))
5530        .or_else(|| value.get("input"))
5531        .map(short_json_detail)
5532        .unwrap_or_default();
5533    if args.is_empty() {
5534        name.to_string()
5535    } else {
5536        format!("{name} {}", truncate(&args, 140))
5537    }
5538}
5539
5540fn non_empty_str(value: Option<&Value>) -> Option<&str> {
5541    value
5542        .and_then(Value::as_str)
5543        .map(str::trim)
5544        .filter(|text| !text.is_empty())
5545}
5546
5547fn finalize_engine(
5548    name: &str,
5549    bin: &str,
5550    duration_ms: u128,
5551    command_result: CommandResult,
5552    options: EngineRunOptions,
5553    sub_agents: Vec<EngineResult>,
5554) -> EngineResult {
5555    let output = match Engine::parse(name) {
5556        Some(Engine::Claude) => parse_claude_output(&command_result.stdout),
5557        Some(Engine::Gemini) => parse_gemini_output(&command_result.stdout),
5558        Some(Engine::Codex) => parse_codex_output(&command_result.stdout),
5559        None => command_result.stdout.trim().to_string(),
5560    };
5561    let status = if command_result.cancelled {
5562        "cancelled"
5563    } else if let Some(error) = command_result.error.as_deref() {
5564        spawn_failure_status(error)
5565    } else if command_result.timed_out {
5566        "timeout"
5567    } else if command_result.code != Some(0) || output.trim().is_empty() {
5568        "error"
5569    } else {
5570        "ok"
5571    };
5572    let detail = if status == "ok" {
5573        String::new()
5574    } else if command_result.cancelled {
5575        "Cancelled by Studio/user request.".to_string()
5576    } else if command_result.timed_out {
5577        format!("Timed out after {}s.", command_result.timeout_ms / 1000)
5578    } else {
5579        compact_failure(&command_result)
5580    };
5581    let usage = aggregate_token_usage(
5582        extract_token_usage(&command_result.stdout, &options.prompt, &output)
5583            .unwrap_or_else(|| token_usage(&options.prompt, &output)),
5584        &sub_agents,
5585    );
5586    let tool_calls = extract_tool_usage(name, &command_result.stdout, &command_result.stderr);
5587    EngineResult {
5588        name: name.to_string(),
5589        bin: Some(bin.to_string()),
5590        status: status.to_string(),
5591        duration_ms,
5592        detail,
5593        exit_code: command_result.code,
5594        stdout: command_result.stdout,
5595        stderr: command_result.stderr,
5596        output,
5597        command: format_command(&command_result.command, &command_result.args),
5598        token_usage: usage,
5599        tool_calls,
5600        sub_agents,
5601        role: options.role,
5602        iteration: options.iteration,
5603        total_iterations: options.total_iterations,
5604        team_size: options.team_size,
5605    }
5606}
5607
5608fn compact_failure(result: &CommandResult) -> String {
5609    if let Some(error) = &result.error {
5610        return error.clone();
5611    }
5612    if !result.stderr.trim().is_empty() {
5613        return result.stderr.trim().to_string();
5614    }
5615    if let Some(detail) = provider_failure_detail(&result.stdout) {
5616        return detail;
5617    }
5618    if !result.stdout.trim().is_empty() {
5619        return result.stdout.trim().to_string();
5620    }
5621    result
5622        .code
5623        .map(|code| format!("Exited with code {code}."))
5624        .unwrap_or_else(|| "Command failed.".to_string())
5625}
5626
5627fn provider_failure_detail(stdout: &str) -> Option<String> {
5628    for value in json_values(stdout).into_iter().rev() {
5629        if let Some(message) = value
5630            .pointer("/error/message")
5631            .or_else(|| value.get("message"))
5632            .and_then(Value::as_str)
5633            .map(sanitize_status_detail)
5634            .filter(|message| !message.trim().is_empty())
5635        {
5636            return Some(message);
5637        }
5638        if value.get("type").and_then(Value::as_str) == Some("result")
5639            && value.get("status").and_then(Value::as_str) == Some("error")
5640        {
5641            return Some("Provider returned an error result.".to_string());
5642        }
5643    }
5644    None
5645}
5646
5647fn command_telemetry(result: &CommandResult) -> CommandTelemetry {
5648    let status = if result.cancelled {
5649        "cancelled"
5650    } else if let Some(error) = result.error.as_deref() {
5651        spawn_failure_status(error)
5652    } else if result.timed_out {
5653        "timeout"
5654    } else if result.code == Some(0) {
5655        "ok"
5656    } else {
5657        "error"
5658    };
5659    CommandTelemetry {
5660        command: format_command(&result.command, &result.args),
5661        status: status.to_string(),
5662        detail: truncate(&sanitize_status_detail(&compact_failure(result)), 600),
5663        exit_code: result.code,
5664        duration_ms: result.duration_ms,
5665        stdout_chars: result.stdout.len(),
5666        stderr_chars: result.stderr.len(),
5667        timed_out: result.timed_out,
5668    }
5669}
5670
5671fn spawn_error_status(error: &io::Error) -> &'static str {
5672    if error.kind() == io::ErrorKind::NotFound {
5673        "missing"
5674    } else {
5675        "error"
5676    }
5677}
5678
5679fn spawn_failure_status(error: &str) -> &'static str {
5680    let lowered = error.to_ascii_lowercase();
5681    if lowered.contains("no such file")
5682        || lowered.contains("not found")
5683        || lowered.contains("cannot find")
5684    {
5685        "missing"
5686    } else {
5687        "error"
5688    }
5689}
5690
5691fn parse_codex_output(stdout: &str) -> String {
5692    stdout.trim().to_string()
5693}
5694
5695fn parse_claude_output(stdout: &str) -> String {
5696    let trimmed = stdout.trim();
5697    if trimmed.is_empty() {
5698        return String::new();
5699    }
5700    if let Some(value) = extract_json(trimmed) {
5701        if let Some(result) = value.get("result").and_then(Value::as_str) {
5702            return result.trim().to_string();
5703        }
5704    }
5705    let mut latest = String::new();
5706    let mut streamed_text = String::new();
5707    for line in trimmed.lines() {
5708        let Ok(value) = serde_json::from_str::<Value>(line) else {
5709            continue;
5710        };
5711        if value.get("type").and_then(Value::as_str) == Some("result") {
5712            if let Some(result) = value.get("result").and_then(Value::as_str) {
5713                latest = result.trim().to_string();
5714            }
5715            continue;
5716        }
5717        if let Some(text) = claude_output_text(&value) {
5718            if value.get("type").and_then(Value::as_str) == Some("stream_event") {
5719                streamed_text.push_str(&text);
5720                latest = streamed_text.trim().to_string();
5721            } else if !text.trim().is_empty() {
5722                latest = text.trim().to_string();
5723            }
5724        }
5725    }
5726    if latest.is_empty() {
5727        trimmed.to_string()
5728    } else {
5729        latest
5730    }
5731}
5732
5733fn parse_gemini_output(stdout: &str) -> String {
5734    let trimmed = stdout.trim();
5735    if trimmed.is_empty() {
5736        return String::new();
5737    }
5738    if let Some(value) = extract_json(trimmed) {
5739        if let Some(response) = value.get("response").and_then(Value::as_str) {
5740            return response.trim().to_string();
5741        }
5742        if let Some(text) = value.get("text").and_then(Value::as_str) {
5743            return text.trim().to_string();
5744        }
5745    }
5746    let mut assistant = String::new();
5747    let mut latest_error = String::new();
5748    for value in json_values(trimmed) {
5749        match value.get("type").and_then(Value::as_str) {
5750            Some("message") if value.get("role").and_then(Value::as_str) == Some("assistant") => {
5751                if let Some(content) = value.get("content").and_then(Value::as_str) {
5752                    assistant.push_str(content);
5753                }
5754            }
5755            Some("error") => {
5756                if let Some(message) = value.get("message").and_then(Value::as_str) {
5757                    latest_error = sanitize_status_detail(message);
5758                }
5759            }
5760            Some("result") if value.get("status").and_then(Value::as_str) == Some("error") => {
5761                if let Some(message) = value.pointer("/error/message").and_then(Value::as_str) {
5762                    latest_error = sanitize_status_detail(message);
5763                }
5764            }
5765            _ => {}
5766        }
5767    }
5768    if !assistant.trim().is_empty() {
5769        assistant.trim().to_string()
5770    } else if !latest_error.trim().is_empty() {
5771        latest_error
5772    } else {
5773        trimmed.to_string()
5774    }
5775}
5776
5777fn extract_json(text: &str) -> Option<Value> {
5778    serde_json::from_str::<Value>(text).ok().or_else(|| {
5779        let start = text.find('{')?;
5780        let end = text.rfind('}')?;
5781        serde_json::from_str::<Value>(&text[start..=end]).ok()
5782    })
5783}
5784
5785fn build_member_prompt(input: MemberPromptInput<'_>) -> String {
5786    let mut sections = vec![
5787        "You are one member of a multi-model Amon Hen run.".to_string(),
5788        format!(
5789            "Amon Hen workflow: iteration {iteration} of {}.",
5790            input.workflow.iterations,
5791            iteration = input.iteration
5792        ),
5793        input
5794            .workflow
5795            .lead
5796            .as_ref()
5797            .map(|lead| format!("Lead model: {lead}."))
5798            .unwrap_or_else(|| "Lead model: auto.".to_string()),
5799        input
5800            .workflow
5801            .planner
5802            .as_ref()
5803            .map(|planner| format!("Planner model: {planner}."))
5804            .unwrap_or_else(|| "Planner model: none.".to_string()),
5805        format!("Your assigned role: {}.", input.role),
5806        role_instruction(input.role).to_string(),
5807        "Answer the user query directly.".to_string(),
5808        "Do not introduce yourself.".to_string(),
5809        "Do not describe your tools, environment, or capabilities unless the user explicitly asks."
5810            .to_string(),
5811        "Be concise unless the user asks for depth.".to_string(),
5812    ];
5813    if input.team_size > 0 {
5814        sections.push(format!(
5815            "Team work: you may coordinate up to {} internal sub-agents or subtasks inside your own CLI if that helps.",
5816            input.team_size
5817        ));
5818    }
5819    if input.workflow.handoff {
5820        sections.push(
5821            "Handoff mode is enabled. Treat earlier Amon Hen outputs as working context."
5822                .to_string(),
5823        );
5824    }
5825    if input.workflow.planner_mode == PLANNER_MODE_REVIEW_CHAIN {
5826        sections.push(
5827            "Review-chain mode is enabled. Each Amon Hen member runs after the previous member. \
5828             Review prior agent output and the current repo state before changing anything, preserve accepted work, \
5829             call out conflicts, and only make deliberate deltas that survive that review."
5830                .to_string(),
5831        );
5832    }
5833    if input.workflow.handshake {
5834        sections.push(
5835            "Three-way handshake mode is enabled. The planner, executor, and reviewer are distinct Amon Hen agent roles even when they use the same underlying provider/model. Treat prior handoffs as agent-to-agent context, not your own earlier answer."
5836                .to_string(),
5837        );
5838    }
5839    if input.workflow.owner_map == OWNER_MAP_STRICT {
5840        sections.push(
5841            "Strict ownership is enabled. Before editing, state the files or scopes you own. \
5842             Keep changes inside that scope, hand off unclear overlap for review, and block rather than overwrite another agent's work."
5843                .to_string(),
5844        );
5845    }
5846    if !input.plan_output.trim().is_empty() {
5847        sections.push(format!(
5848            "Planner handoff:\n{}",
5849            bounded_context_text(
5850                input.plan_output.trim(),
5851                input.max_member_chars,
5852                "planner handoff"
5853            )
5854        ));
5855    }
5856    let context = input
5857        .previous_iteration
5858        .iter()
5859        .chain(input.handoff_results.iter())
5860        .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
5861        .map(|result| {
5862            let output =
5863                bounded_context_text(result.output.trim(), input.max_member_chars, "handoff");
5864            format!("### {} ({})\n{}", result.name, result.role, output)
5865        })
5866        .collect::<Vec<_>>()
5867        .join("\n\n");
5868    if !context.is_empty() {
5869        sections.push(format!("Earlier Amon Hen handoffs:\n{context}"));
5870    }
5871    sections.push(format!("Current user query:\n{}", input.query.trim()));
5872    limit_provider_prompt(&sections.join("\n\n"), input.max_prompt_chars)
5873}
5874
5875fn build_summary_prompt(
5876    query: &str,
5877    responses: &[EngineResult],
5878    workflow: &Workflow,
5879    max_member_chars: usize,
5880) -> String {
5881    let blocks = responses
5882        .iter()
5883        .map(|response| {
5884            let output = truncate(&response.output, max_member_chars);
5885            format!("### {}\n{}", response.name, output.trim())
5886        })
5887        .collect::<Vec<_>>()
5888        .join("\n\n");
5889    format!(
5890        "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{}",
5891        workflow.iterations,
5892        if workflow.iterations == 1 { "" } else { "s" },
5893        if workflow.handoff { "handoff enabled" } else { "parallel consultation" },
5894        workflow.lead.as_deref().unwrap_or("auto"),
5895        workflow.planner.as_deref().unwrap_or("none"),
5896        query.trim(),
5897        blocks
5898    )
5899}
5900
5901fn role_instruction(role: &str) -> &'static str {
5902    match role {
5903        "planner" => "Plan the work: identify the approach, risks, checkpoints, and useful handoffs for the executors.",
5904        "executor" => "Execute the work: use the planner handoff and current evidence, make the strongest safe implementation attempt, and leave reviewable output.",
5905        "reviewer" => "Review the planner and executor handoffs plus the current state. Verify tests, risks, regressions, ownership, and whether the implementation should be accepted, revised, or blocked.",
5906        "lead" => "Lead the work: make the strongest direct attempt while watching for conflicts you may need to resolve in synthesis.",
5907        "lead+planner" => "Plan and lead the work: produce a practical plan, then make the strongest direct attempt from that plan.",
5908        _ => "Execute the work: use any plan or handoff context, then produce your independent best answer.",
5909    }
5910}
5911
5912fn execution_order(members: &[String], planner: Option<&str>, lead: Option<&str>) -> Vec<String> {
5913    let mut ordered = Vec::new();
5914    if let Some(planner) = planner {
5915        if members.iter().any(|member| member == planner) {
5916            ordered.push(planner.to_string());
5917        }
5918    }
5919    ordered.extend(
5920        members
5921            .iter()
5922            .filter(|member| Some(member.as_str()) != planner && Some(member.as_str()) != lead)
5923            .cloned(),
5924    );
5925    if lead != planner {
5926        if let Some(lead) = lead {
5927            if members.iter().any(|member| member == lead) {
5928                ordered.push(lead.to_string());
5929            }
5930        }
5931    }
5932    ordered
5933}
5934
5935fn role_for(member: &str, workflow: &Workflow) -> String {
5936    let is_lead = workflow.lead.as_deref() == Some(member);
5937    let is_planner = workflow.planner.as_deref() == Some(member);
5938    match (is_lead, is_planner) {
5939        (true, true) => "lead+planner",
5940        (true, false) => "lead",
5941        (false, true) => "planner",
5942        (false, false) => "executor",
5943    }
5944    .to_string()
5945}
5946
5947fn pick_summarizer(resolved: &ResolvedArgs, successes: &[EngineResult]) -> String {
5948    if resolved.raw.summarizer != "auto" {
5949        return resolved.raw.summarizer.clone();
5950    }
5951    if let Some(lead) = &resolved.raw.lead {
5952        if successes.iter().any(|result| &result.name == lead) {
5953            return lead.clone();
5954        }
5955    }
5956    DEFAULT_SUMMARIZER_ORDER
5957        .iter()
5958        .find(|name| successes.iter().any(|result| result.name == **name))
5959        .unwrap_or(&successes[0].name.as_str())
5960        .to_string()
5961}
5962
5963fn resolve_binary(name: &str) -> String {
5964    let Some(engine) = Engine::parse(name) else {
5965        return name.to_string();
5966    };
5967    std::env::var(engine.binary_env_var())
5968        .ok()
5969        .filter(|value| !value.trim().is_empty())
5970        .unwrap_or_else(|| engine.as_str().to_string())
5971}
5972
5973fn token_usage(prompt: &str, output: &str) -> TokenUsage {
5974    let input = estimate_tokens(prompt);
5975    let output = estimate_tokens(output);
5976    TokenUsage {
5977        input,
5978        output,
5979        total: input + output,
5980        estimated: true,
5981        source: "estimate".to_string(),
5982    }
5983}
5984
5985fn aggregate_token_usage(mut usage: TokenUsage, sub_agents: &[EngineResult]) -> TokenUsage {
5986    if sub_agents.is_empty() {
5987        return usage;
5988    }
5989    for agent in sub_agents {
5990        usage.input += agent.token_usage.input;
5991        usage.output += agent.token_usage.output;
5992        usage.total += agent.token_usage.total;
5993        usage.estimated |= agent.token_usage.estimated;
5994    }
5995    usage.source = format!("{}+sub-agents", usage.source);
5996    usage
5997}
5998
5999fn aggregate_results_token_usage<'a>(
6000    results: impl IntoIterator<Item = &'a EngineResult>,
6001) -> TokenUsage {
6002    let mut usage = TokenUsage {
6003        input: 0,
6004        output: 0,
6005        total: 0,
6006        estimated: false,
6007        source: "aggregate".to_string(),
6008    };
6009    for result in results {
6010        usage.input += result.token_usage.input;
6011        usage.output += result.token_usage.output;
6012        usage.total += result.token_usage.total;
6013        usage.estimated |= result.token_usage.estimated;
6014    }
6015    usage
6016}
6017
6018fn extract_token_usage(stdout: &str, prompt: &str, output: &str) -> Option<TokenUsage> {
6019    let mut usage = TokenAccumulator::default();
6020    for value in json_values(stdout) {
6021        collect_token_counts(&value, &mut usage);
6022    }
6023    if usage.input.is_none() && usage.output.is_none() && usage.total.is_none() {
6024        return None;
6025    }
6026    let input = usage.input.unwrap_or_else(|| estimate_tokens(prompt));
6027    let output = usage.output.unwrap_or_else(|| estimate_tokens(output));
6028    let total = usage.total.unwrap_or(input + output);
6029    Some(TokenUsage {
6030        input,
6031        output,
6032        total,
6033        estimated: usage.input.is_none() || usage.output.is_none(),
6034        source: "provider".to_string(),
6035    })
6036}
6037
6038#[derive(Default)]
6039struct TokenAccumulator {
6040    input: Option<usize>,
6041    output: Option<usize>,
6042    total: Option<usize>,
6043}
6044
6045fn collect_token_counts(value: &Value, usage: &mut TokenAccumulator) {
6046    match value {
6047        Value::Object(map) => {
6048            for (key, value) in map {
6049                let normalized = key.replace(['_', '-'], "").to_ascii_lowercase();
6050                if let Some(count) = value.as_u64().map(|value| value as usize) {
6051                    match normalized.as_str() {
6052                        "inputtokens" | "inputtokencount" | "prompttokens" | "prompttokencount" => {
6053                            usage.input = Some(count)
6054                        }
6055                        "outputtokens"
6056                        | "outputtokencount"
6057                        | "completiontokens"
6058                        | "candidatestokencount" => usage.output = Some(count),
6059                        "totaltokens" | "totaltokencount" => usage.total = Some(count),
6060                        _ => {}
6061                    }
6062                }
6063                collect_token_counts(value, usage);
6064            }
6065        }
6066        Value::Array(values) => {
6067            for value in values {
6068                collect_token_counts(value, usage);
6069            }
6070        }
6071        _ => {}
6072    }
6073}
6074
6075fn extract_tool_usage(provider: &str, stdout: &str, stderr: &str) -> Vec<ToolUsage> {
6076    let mut tools = Vec::new();
6077    for value in json_values(stdout) {
6078        collect_tool_usage(&value, &mut tools);
6079    }
6080    for line in stdout.lines().chain(stderr.lines()) {
6081        if let Some(tool) = line_tool_usage(provider, line) {
6082            tools.push(tool);
6083        }
6084    }
6085    dedupe_tools(tools)
6086}
6087
6088fn collect_tool_usage(value: &Value, tools: &mut Vec<ToolUsage>) {
6089    match value {
6090        Value::Object(map) => {
6091            let type_name = map.get("type").and_then(Value::as_str).unwrap_or_default();
6092            let name = map
6093                .get("name")
6094                .or_else(|| map.get("tool"))
6095                .or_else(|| map.get("tool_name"))
6096                .or_else(|| map.get("toolName"))
6097                .and_then(Value::as_str);
6098            if let Some(name) = name.filter(|_| {
6099                type_name.contains("tool")
6100                    || map.contains_key("input")
6101                    || map.contains_key("arguments")
6102                    || map.contains_key("toolCall")
6103            }) {
6104                tools.push(ToolUsage {
6105                    name: name.to_string(),
6106                    kind: if type_name.is_empty() {
6107                        "tool"
6108                    } else {
6109                        type_name
6110                    }
6111                    .to_string(),
6112                    status: "observed".to_string(),
6113                    detail: short_json_detail(value),
6114                });
6115            }
6116            for value in map.values() {
6117                collect_tool_usage(value, tools);
6118            }
6119        }
6120        Value::Array(values) => {
6121            for value in values {
6122                collect_tool_usage(value, tools);
6123            }
6124        }
6125        _ => {}
6126    }
6127}
6128
6129fn line_tool_usage(provider: &str, line: &str) -> Option<ToolUsage> {
6130    let trimmed = line.trim();
6131    if looks_like_json_line(trimmed) {
6132        return None;
6133    }
6134    let lowered = trimmed.to_ascii_lowercase();
6135    let marker = ["running shell:", "tool:", "tool_use", "mcp:", "command:"]
6136        .iter()
6137        .find(|marker| lowered.contains(**marker))?;
6138    Some(ToolUsage {
6139        name: marker.trim_end_matches(':').to_string(),
6140        kind: provider.to_string(),
6141        status: "observed".to_string(),
6142        detail: truncate(trimmed, 240),
6143    })
6144}
6145
6146fn short_json_detail(value: &Value) -> String {
6147    serde_json::to_string(value)
6148        .map(|text| truncate(&text, 240))
6149        .unwrap_or_default()
6150}
6151
6152fn dedupe_tools(tools: Vec<ToolUsage>) -> Vec<ToolUsage> {
6153    let mut seen = HashSet::new();
6154    tools
6155        .into_iter()
6156        .filter(|tool| seen.insert(format!("{}:{}:{}", tool.kind, tool.name, tool.detail)))
6157        .collect()
6158}
6159
6160fn json_values(text: &str) -> Vec<Value> {
6161    let trimmed = text.trim();
6162    if trimmed.is_empty() {
6163        return vec![];
6164    }
6165    let mut values = Vec::new();
6166    if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
6167        values.push(value);
6168        return values;
6169    }
6170    for line in trimmed.lines() {
6171        if let Ok(value) = serde_json::from_str::<Value>(line.trim()) {
6172            values.push(value);
6173        }
6174    }
6175    values
6176}
6177
6178fn looks_like_json_line(line: &str) -> bool {
6179    let trimmed = line.trim_start();
6180    trimmed.starts_with('{') || trimmed.starts_with('[')
6181}
6182
6183fn estimate_tokens(text: &str) -> usize {
6184    if text.trim().is_empty() {
6185        0
6186    } else {
6187        text.len().div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN)
6188    }
6189}
6190
6191fn format_command(command: &str, args: &[String]) -> String {
6192    std::iter::once(command.to_string())
6193        .chain(args.iter().map(|arg| shell_quote(arg)))
6194        .collect::<Vec<_>>()
6195        .join(" ")
6196}
6197
6198fn shell_quote(value: &str) -> String {
6199    if value
6200        .chars()
6201        .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
6202    {
6203        value.to_string()
6204    } else {
6205        serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
6206    }
6207}
6208
6209fn truncate(text: &str, max_chars: usize) -> String {
6210    if let Some(index) = byte_index_after_chars(text, max_chars) {
6211        let mut truncated = String::with_capacity(index + "\n...[truncated]".len());
6212        truncated.push_str(&text[..index]);
6213        truncated.push_str("\n...[truncated]");
6214        truncated
6215    } else {
6216        text.to_string()
6217    }
6218}
6219
6220fn bounded_context_text(text: &str, max_chars: usize, label: &str) -> String {
6221    let total = text.chars().count();
6222    if total <= max_chars {
6223        return text.to_string();
6224    }
6225    if max_chars < 96 {
6226        return truncate(text, max_chars);
6227    }
6228    let notice = format!(
6229        "\n\n[amon-hen: {label} truncated from {total} chars to {max_chars} chars to stay under provider prompt limits.]\n\n"
6230    );
6231    let notice_len = notice.chars().count();
6232    if notice_len >= max_chars {
6233        return take_chars(text, max_chars);
6234    }
6235    let keep = max_chars - notice_len;
6236    let head = keep.saturating_mul(2) / 3;
6237    let tail = keep.saturating_sub(head);
6238    format!(
6239        "{}{}{}",
6240        take_chars(text, head),
6241        notice,
6242        take_tail_chars(text, tail)
6243    )
6244}
6245
6246fn limit_provider_prompt(prompt: &str, max_chars: usize) -> String {
6247    let max_chars = max_chars.min(PROVIDER_PROMPT_HARD_LIMIT_CHARS);
6248    let total = prompt.chars().count();
6249    if total <= max_chars {
6250        return prompt.to_string();
6251    }
6252    bounded_context_text(prompt, max_chars, "provider prompt")
6253}
6254
6255fn take_chars(text: &str, max_chars: usize) -> String {
6256    byte_index_after_chars(text, max_chars)
6257        .map(|index| text[..index].to_string())
6258        .unwrap_or_else(|| text.to_string())
6259}
6260
6261fn take_tail_chars(text: &str, max_chars: usize) -> String {
6262    byte_index_for_tail_chars(text, max_chars)
6263        .map(|index| text[index..].to_string())
6264        .unwrap_or_else(|| text.to_string())
6265}
6266
6267fn byte_index_after_chars(text: &str, max_chars: usize) -> Option<usize> {
6268    if max_chars == 0 {
6269        return (!text.is_empty()).then_some(0);
6270    }
6271    for (count, (index, _)) in text.char_indices().enumerate() {
6272        if count == max_chars {
6273            return Some(index);
6274        }
6275    }
6276    None
6277}
6278
6279fn byte_index_for_tail_chars(text: &str, max_chars: usize) -> Option<usize> {
6280    if max_chars == 0 {
6281        return (!text.is_empty()).then_some(text.len());
6282    }
6283    for (count, (index, _)) in text.char_indices().rev().enumerate() {
6284        if count + 1 == max_chars {
6285            return Some(index);
6286        }
6287    }
6288    None
6289}
6290
6291fn should_show_banner(raw: &CliArgs) -> bool {
6292    !raw.no_banner && !raw.headless && !raw.json && !raw.json_stream && !raw.plain
6293}
6294
6295fn linear_delivery_requested(raw: &CliArgs) -> bool {
6296    raw.deliver_linear
6297        || raw.linear_until_complete
6298        || raw.linear_watch
6299        || !raw.linear_issue.is_empty()
6300        || raw
6301            .linear_query
6302            .as_ref()
6303            .is_some_and(|value| !value.trim().is_empty())
6304        || !raw.linear_project.is_empty()
6305        || !raw.linear_epic.is_empty()
6306}
6307
6308fn render_banner() -> &'static str {
6309    "    _                         _   _            \n   / \\   _ __ ___   ___  _ __ | | | | ___ _ __  \n  / _ \\ | '_ ` _ \\ / _ \\| '_ \\| |_| |/ _ \\ '_ \\ \n / ___ \\| | | | | | (_) | | | |  _  |  __/ | | |\n/_/   \\_\\_| |_| |_|\\___/|_| |_|_| |_|\\___|_| |_|"
6310}
6311
6312fn render_human_result(result: &AmonHenResult, verbose: bool) -> String {
6313    let mut lines = Vec::new();
6314    if verbose {
6315        lines.push(format!(
6316            "Amon Hen consulted: {}",
6317            result.members_requested.join(", ")
6318        ));
6319        for command in &result.prompt_commands {
6320            lines.push(format!(
6321                "cmd [{}] {} ({:.1}s)",
6322                command.status,
6323                command.command,
6324                command.duration_ms as f64 / 1000.0
6325            ));
6326        }
6327        for (index, member) in result.members.iter().enumerate() {
6328            lines.push(format!(
6329                "{}. [{}] {} ({:.1}s, tokens:{}, tools:{}, sub-agents:{}){}",
6330                index + 1,
6331                member.status,
6332                member.name,
6333                member.duration_ms as f64 / 1000.0,
6334                member.token_usage.total,
6335                member.tool_calls.len(),
6336                member.sub_agents.len(),
6337                if member.detail.is_empty() { "" } else { ": " }
6338            ));
6339            if !member.detail.is_empty() {
6340                lines.push(format!("   {}", member.detail));
6341            }
6342            for sub_agent in &member.sub_agents {
6343                lines.push(format!(
6344                    "   - {} [{}] tokens:{} tools:{}",
6345                    sub_agent.role,
6346                    sub_agent.status,
6347                    sub_agent.token_usage.total,
6348                    sub_agent.tool_calls.len()
6349                ));
6350            }
6351            if member.status == "ok" {
6352                lines.push(indent(&member.output, "   "));
6353            }
6354        }
6355        if let Some(consensus) = &result.consensus {
6356            lines.push("----------- consensus -----------".to_string());
6357            lines.push(format!(
6358                "consensus [{}] iteration:{} rounds:{} reviewers:{}",
6359                consensus.status,
6360                consensus.iteration,
6361                consensus.rounds.len(),
6362                consensus.config.reviewers.join(",")
6363            ));
6364            for blocker in &consensus.blockers {
6365                lines.push(format!("   blocker: {blocker}"));
6366            }
6367        }
6368        lines.push("----------- synthesis -----------".to_string());
6369    }
6370    if let Some(consensus) = &result.consensus {
6371        if consensus.config.mode == CONSENSUS_REQUIRED && !consensus.approved {
6372            lines.push(format!(
6373                "Consensus blocked after {} round(s): {}",
6374                consensus.rounds.len(),
6375                if consensus.blockers.is_empty() {
6376                    "no reviewer consensus".to_string()
6377                } else {
6378                    consensus.blockers.join("; ")
6379                }
6380            ));
6381        }
6382    }
6383    if result.summary.status == "ok" {
6384        lines.push(result.summary.output.trim().to_string());
6385    } else {
6386        lines.push(format!("Synthesis failed: {}", result.summary.detail));
6387    }
6388    lines.join("\n")
6389}
6390
6391fn indent(text: &str, prefix: &str) -> String {
6392    text.lines()
6393        .map(|line| format!("{prefix}{line}"))
6394        .collect::<Vec<_>>()
6395        .join("\n")
6396}
6397
6398fn is_success(result: &AmonHenResult) -> bool {
6399    let consensus_ok = result
6400        .consensus
6401        .as_ref()
6402        .map(|consensus| consensus.config.mode != CONSENSUS_REQUIRED || consensus.approved)
6403        .unwrap_or(true);
6404    result.members.iter().any(|member| member.status == "ok")
6405        && result.summary.status == "ok"
6406        && consensus_ok
6407}
6408
6409#[cfg(test)]
6410mod tests {
6411    use super::*;
6412    use std::sync::Mutex as TestMutex;
6413
6414    static ENV_LOCK: TestMutex<()> = TestMutex::new(());
6415
6416    struct ProviderEnvGuard {
6417        saved: Vec<(&'static str, Option<OsString>)>,
6418    }
6419
6420    impl ProviderEnvGuard {
6421        fn install_echo_bins() -> Option<Self> {
6422            let echo = Path::new("/bin/echo");
6423            if !echo.is_file() {
6424                return None;
6425            }
6426            let vars = [
6427                "AMON_HEN_CODEX_BIN",
6428                "AMON_HEN_CLAUDE_BIN",
6429                "AMON_HEN_GEMINI_BIN",
6430            ];
6431            let saved = vars
6432                .into_iter()
6433                .map(|name| (name, std::env::var_os(name)))
6434                .collect::<Vec<_>>();
6435            for (name, _) in &saved {
6436                unsafe {
6437                    std::env::set_var(name, echo);
6438                }
6439            }
6440            Some(Self { saved })
6441        }
6442    }
6443
6444    impl Drop for ProviderEnvGuard {
6445        fn drop(&mut self) {
6446            for (name, value) in &self.saved {
6447                unsafe {
6448                    if let Some(value) = value {
6449                        std::env::set_var(name, value);
6450                    } else {
6451                        std::env::remove_var(name);
6452                    }
6453                }
6454            }
6455        }
6456    }
6457
6458    #[test]
6459    fn help_flags_use_success_exit_code() {
6460        let long_help = CliArgs::try_parse_from(["amon-hen", "--help"]).unwrap_err();
6461        let short_help = CliArgs::try_parse_from(["amon-hen", "-h"]).unwrap_err();
6462
6463        assert_eq!(long_help.kind(), ErrorKind::DisplayHelp);
6464        assert_eq!(short_help.kind(), ErrorKind::DisplayHelp);
6465        assert_eq!(parse_error_exit_code(long_help.kind()), 0);
6466        assert_eq!(parse_error_exit_code(short_help.kind()), 0);
6467    }
6468
6469    #[test]
6470    fn rendered_help_is_grouped_and_compact() {
6471        let help = render_cli_help();
6472
6473        assert!(help.contains("Core run:"));
6474        assert!(help.contains("Models, effort, and permissions:"));
6475        assert!(help.contains("Auth and provider capabilities:"));
6476        assert!(help.contains("Linear delivery:"));
6477        assert!(help.contains("--members codex,claude,gemini"));
6478        assert!(help.contains("--claude-permission-mode MODE"));
6479        assert!(help.contains("--gemini-approval-mode MODE"));
6480        assert!(help.contains("--resume RUN_DIR"));
6481        assert!(help.contains("--check-update"));
6482        assert!(help.contains("--update"));
6483        assert!(help.contains("blocking|parallel|review-chain|handshake"));
6484        assert!(help.contains("--consensus off|required"));
6485        assert!(help.contains("--failure-policy continue|takeover"));
6486        assert!(help.contains("--owner-map flexible|strict"));
6487        assert!(!help.contains("\n\n\n"));
6488    }
6489
6490    #[test]
6491    fn parses_resume_run_directory() {
6492        let args = CliArgs::try_parse_from([
6493            "amon-hen",
6494            "--studio",
6495            "--resume",
6496            ".amon-hen/runs/example",
6497            "Continue the run",
6498        ])
6499        .unwrap();
6500
6501        assert_eq!(args.resume, Some(PathBuf::from(".amon-hen/runs/example")));
6502        assert_eq!(args.prompt, vec!["Continue the run"]);
6503    }
6504
6505    #[test]
6506    fn update_flags_parse_without_prompt() {
6507        let check = CliArgs::try_parse_from(["amon-hen", "--check-update"]).unwrap();
6508        let update = CliArgs::try_parse_from(["amon-hen", "--update"]).unwrap();
6509        let disabled = CliArgs::try_parse_from(["amon-hen", "--no-update-check", "hello"]).unwrap();
6510
6511        assert!(check.check_update);
6512        assert!(update.update);
6513        assert!(disabled.no_update_check);
6514    }
6515
6516    #[test]
6517    fn version_comparison_handles_multi_digit_patches() {
6518        assert_eq!(version_cmp("0.1.25", "0.1.9"), std::cmp::Ordering::Greater);
6519        assert_eq!(version_cmp("v1.2.0", "1.2"), std::cmp::Ordering::Equal);
6520        assert_eq!(version_cmp("0.2.0", "0.10.0"), std::cmp::Ordering::Less);
6521    }
6522
6523    #[test]
6524    fn update_changelog_extracts_versions_between_current_and_latest() {
6525        let changelog = "\
6526# Changelog
6527
6528## 0.1.27
6529- latest
6530
6531## 0.1.26
6532- middle
6533
6534## 0.1.25
6535- current
6536";
6537        let extracted = extract_changelog_range(changelog, "0.1.25", "0.1.27").unwrap();
6538
6539        assert!(extracted.contains("## 0.1.27"));
6540        assert!(extracted.contains("- latest"));
6541        assert!(extracted.contains("## 0.1.26"));
6542        assert!(!extracted.contains("## 0.1.25"));
6543    }
6544
6545    #[test]
6546    fn parses_members_and_provider_flags() {
6547        let args = CliArgs::try_parse_from([
6548            "amon-hen",
6549            "--members",
6550            "codex,claude",
6551            "--planner",
6552            "codex",
6553            "--planner-mode",
6554            "parallel",
6555            "--handoff",
6556            "--lead",
6557            "claude",
6558            "--codex-effort",
6559            "xhigh",
6560            "--claude-capabilities",
6561            "override",
6562            "--claude-allowed-tools",
6563            "Read,Bash(git:*)",
6564            "--claude-tools",
6565            "Read,Edit",
6566            "--claude-agent",
6567            "reviewer",
6568            "--gemini-allowed-mcp-servers",
6569            "linear,github",
6570            "ship it",
6571        ])
6572        .unwrap();
6573        let resolved = resolve_args(args).unwrap();
6574        assert_eq!(resolved.members, vec!["codex", "claude"]);
6575        assert_eq!(resolved.raw.planner_mode, PLANNER_MODE_PARALLEL);
6576        assert_eq!(resolved.raw.codex_effort.as_deref(), Some("xhigh"));
6577        assert_eq!(
6578            resolved.raw.claude_allowed_tools,
6579            vec!["Read", "Bash(git:*)"]
6580        );
6581        assert_eq!(resolved.raw.claude_tools, vec!["Read", "Edit"]);
6582        assert_eq!(resolved.raw.claude_agent.as_deref(), Some("reviewer"));
6583        assert_eq!(
6584            resolved.raw.gemini_allowed_mcp_servers,
6585            vec!["linear", "github"]
6586        );
6587    }
6588
6589    #[test]
6590    fn parses_consensus_gate_flags() {
6591        let args = CliArgs::try_parse_from([
6592            "amon-hen",
6593            "--members",
6594            "codex,claude,gemini",
6595            "--consensus",
6596            "required",
6597            "--consensus-reviewers",
6598            "codex,claude,gemini",
6599            "--failure-policy",
6600            "takeover",
6601            "--review-rounds",
6602            "3",
6603            "--require-final-diff-review",
6604            "--require-tests",
6605            "--require-secret-scan",
6606            "--require-clean-git-diff",
6607            "--stop-when",
6608            "consensus",
6609            "--owner-map",
6610            "strict",
6611            "ship it",
6612        ])
6613        .unwrap();
6614        let resolved = resolve_args(args).unwrap();
6615        let config = consensus_config(&resolved);
6616
6617        assert_eq!(config.mode, CONSENSUS_REQUIRED);
6618        assert_eq!(config.reviewers, vec!["codex", "claude", "gemini"]);
6619        assert_eq!(config.failure_policy, FAILURE_POLICY_TAKEOVER);
6620        assert_eq!(config.review_rounds, 3);
6621        assert!(config.require_final_diff_review);
6622        assert!(config.require_tests);
6623        assert!(config.require_secret_scan);
6624        assert!(config.require_clean_git_diff);
6625        assert_eq!(config.stop_when, STOP_WHEN_CONSENSUS);
6626        assert_eq!(config.owner_map, OWNER_MAP_STRICT);
6627    }
6628
6629    #[test]
6630    fn parses_machine_readable_consensus_review_footer() {
6631        let output = "Reviewed diff and tests.\nCONSENSUS: approve\nBLOCKERS: none\nTAKEOVER_FOR: gemini, codex";
6632
6633        assert!(consensus_output_approves(output));
6634        assert!(consensus_output_blockers(output).is_empty());
6635        assert_eq!(
6636            consensus_output_takeover_for(output),
6637            vec!["gemini".to_string(), "codex".to_string()]
6638        );
6639    }
6640
6641    #[test]
6642    fn required_consensus_blocks_success_until_approved() {
6643        let mut result = test_amon_hen_result(vec![test_engine_result("codex", "planner", 1)], 1);
6644        result.consensus = Some(ConsensusResult {
6645            status: "blocked".to_string(),
6646            iteration: 1,
6647            approved: false,
6648            config: ConsensusConfig {
6649                mode: CONSENSUS_REQUIRED.to_string(),
6650                reviewers: vec!["codex".to_string()],
6651                failure_policy: FAILURE_POLICY_TAKEOVER.to_string(),
6652                review_rounds: 1,
6653                require_final_diff_review: true,
6654                require_tests: true,
6655                require_secret_scan: true,
6656                require_clean_git_diff: true,
6657                stop_when: STOP_WHEN_CONSENSUS.to_string(),
6658                owner_map: OWNER_MAP_STRICT.to_string(),
6659            },
6660            rounds: vec![],
6661            blockers: vec!["reviewer did not approve".to_string()],
6662            takeover_required: false,
6663            takeover_by: vec![],
6664        });
6665
6666        assert!(!is_success(&result));
6667        result.consensus.as_mut().unwrap().approved = true;
6668        result.consensus.as_mut().unwrap().status = "approved".to_string();
6669        assert!(is_success(&result));
6670    }
6671
6672    #[test]
6673    fn planner_parallel_mode_keeps_executors_unblocked() {
6674        let args = CliArgs::try_parse_from([
6675            "amon-hen",
6676            "--members",
6677            "codex,claude,gemini",
6678            "--planner",
6679            "claude",
6680            "--planner-mode",
6681            "parallel",
6682            "--handoff",
6683            "--lead",
6684            "claude",
6685            "ship it",
6686        ])
6687        .unwrap();
6688        let resolved = resolve_args(args).unwrap();
6689        let workflow = build_workflow(&resolved);
6690        let ordered = execution_order(
6691            &resolved.members,
6692            workflow.planner.as_deref(),
6693            workflow.lead.as_deref(),
6694        );
6695        let planner = workflow
6696            .planner
6697            .as_ref()
6698            .filter(|planner| ordered.contains(planner))
6699            .cloned();
6700        let blocking_planner = workflow.planner_mode == PLANNER_MODE_BLOCKING;
6701        let executors = ordered
6702            .into_iter()
6703            .filter(|member| !(blocking_planner && Some(member) == planner.as_ref()))
6704            .collect::<Vec<_>>();
6705
6706        assert_eq!(workflow.planner_mode, PLANNER_MODE_PARALLEL);
6707        assert!(workflow.handoff);
6708        assert_eq!(executors, vec!["claude", "codex", "gemini"]);
6709        assert_eq!(role_for("claude", &workflow), "lead+planner");
6710        assert_eq!(role_for("codex", &workflow), "executor");
6711        assert_eq!(role_for("gemini", &workflow), "executor");
6712    }
6713
6714    #[test]
6715    fn planner_review_chain_mode_serializes_agent_review() {
6716        let args = CliArgs::try_parse_from([
6717            "amon-hen",
6718            "--members",
6719            "codex,claude,gemini",
6720            "--planner",
6721            "claude",
6722            "--planner-mode",
6723            "review-chain",
6724            "--lead",
6725            "claude",
6726            "ship it",
6727        ])
6728        .unwrap();
6729        let resolved = resolve_args(args).unwrap();
6730        let workflow = build_workflow(&resolved);
6731        let ordered = execution_order(
6732            &resolved.members,
6733            workflow.planner.as_deref(),
6734            workflow.lead.as_deref(),
6735        );
6736        let prior = vec![EngineResult {
6737            name: "codex".to_string(),
6738            bin: Some("codex".to_string()),
6739            status: "ok".to_string(),
6740            duration_ms: 1,
6741            detail: String::new(),
6742            exit_code: Some(0),
6743            stdout: String::new(),
6744            stderr: String::new(),
6745            output: "codex patch notes".to_string(),
6746            command: "codex".to_string(),
6747            token_usage: token_usage("a", "b"),
6748            tool_calls: vec![],
6749            sub_agents: vec![],
6750            role: "executor".to_string(),
6751            iteration: 1,
6752            total_iterations: 1,
6753            team_size: 0,
6754        }];
6755        let prompt = build_member_prompt(MemberPromptInput {
6756            query: "ship it",
6757            role: "executor",
6758            workflow: &workflow,
6759            iteration: 1,
6760            team_size: 0,
6761            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
6762            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
6763            previous_iteration: &[],
6764            handoff_results: &prior,
6765            plan_output: "",
6766        });
6767
6768        assert_eq!(workflow.planner_mode, PLANNER_MODE_REVIEW_CHAIN);
6769        assert!(
6770            workflow.handoff,
6771            "review-chain should imply handoff context"
6772        );
6773        assert!(planner_mode_runs_serial(&workflow));
6774        assert_eq!(ordered, vec!["claude", "codex", "gemini"]);
6775        assert!(prompt.contains("Review-chain mode is enabled"));
6776        assert!(prompt.contains("### codex (executor)"));
6777        assert!(prompt.contains("codex patch notes"));
6778    }
6779
6780    #[test]
6781    fn parses_historical_linear_flags_in_rust() {
6782        let args = CliArgs::try_parse_from([
6783            "amon-hen",
6784            "--banner",
6785            "--auth-open-browser",
6786            "--members",
6787            "codex,claude,gemini",
6788            "--linear-project",
6789            "ENG",
6790            "--linear-endpoint",
6791            "https://linear.example/graphql",
6792            "--linear-auth",
6793            "oauth",
6794            "--linear-max-polls",
6795            "2",
6796            "--linear-max-concurrency",
6797            "3",
6798            "--linear-workflow-file",
6799            "docs/linear-workflow.md",
6800            "--linear-workspace-strategy",
6801            "copy",
6802            "--linear-completion-gate",
6803            "review-or-ci",
6804            "--delivery-phases",
6805            "plan,implement,verify,ship",
6806            "deliver it",
6807        ])
6808        .unwrap();
6809        let resolved = resolve_args(args).unwrap();
6810
6811        assert!(linear_delivery_requested(&resolved.raw));
6812        assert_eq!(
6813            resolved.raw.linear_endpoint.as_deref(),
6814            Some("https://linear.example/graphql")
6815        );
6816        assert_eq!(resolved.raw.linear_auth, "oauth");
6817        assert_eq!(resolved.raw.linear_max_polls, Some(2));
6818        assert_eq!(resolved.raw.linear_max_concurrency, 3);
6819        assert_eq!(
6820            resolved.raw.linear_workflow_file.as_deref(),
6821            Some(Path::new("docs/linear-workflow.md"))
6822        );
6823        assert_eq!(resolved.raw.linear_workspace_strategy, "copy");
6824        assert_eq!(resolved.raw.linear_completion_gate, "review-or-ci");
6825        assert_eq!(
6826            resolved.raw.delivery_phases,
6827            vec!["plan", "implement", "verify", "ship"]
6828        );
6829    }
6830
6831    #[test]
6832    fn cli_role_flag_matrix_reaches_provider_options_and_prompts() {
6833        let args = CliArgs::try_parse_from([
6834            "amon-hen",
6835            "--members",
6836            "codex,claude,gemini",
6837            "--planner",
6838            "codex",
6839            "--lead",
6840            "claude",
6841            "--handoff",
6842            "--iterations",
6843            "3",
6844            "--team-work",
6845            "2",
6846            "--codex-sub-agents",
6847            "4",
6848            "--claude-sub-agents",
6849            "1",
6850            "--gemini-sub-agents",
6851            "0",
6852            "--codex-model",
6853            "gpt-5.2",
6854            "--codex-effort",
6855            "xhigh",
6856            "--codex-sandbox",
6857            "danger-full-access",
6858            "--codex-config",
6859            "sandbox_workspace_write.network_access=true",
6860            "--codex-mcp-profile",
6861            "repo",
6862            "--claude-model",
6863            "sonnet",
6864            "--claude-effort",
6865            "max",
6866            "--claude-permission-mode",
6867            "bypassPermissions",
6868            "--claude-allowed-tools",
6869            "Read,Edit",
6870            "--claude-tools",
6871            "Bash",
6872            "--claude-agent",
6873            "lead-reviewer",
6874            "--gemini-model",
6875            "gemini-2.5-pro",
6876            "--gemini-effort",
6877            "high",
6878            "--gemini-approval-mode",
6879            "auto_edit",
6880            "--gemini-settings",
6881            "gemini-settings.json",
6882            "--gemini-tools-profile",
6883            "repo,ci",
6884            "--gemini-allowed-mcp-servers",
6885            "linear",
6886            "Fix the regression",
6887        ])
6888        .unwrap();
6889        let resolved = resolve_args(args).unwrap();
6890        let workflow = build_workflow(&resolved);
6891        let previous = vec![EngineResult {
6892            name: "gemini".to_string(),
6893            bin: Some("gemini".to_string()),
6894            status: "ok".to_string(),
6895            duration_ms: 1,
6896            detail: String::new(),
6897            exit_code: Some(0),
6898            stdout: String::new(),
6899            stderr: String::new(),
6900            output: "previous iteration context".to_string(),
6901            command: "gemini".to_string(),
6902            token_usage: token_usage("a", "b"),
6903            tool_calls: vec![],
6904            sub_agents: vec![],
6905            role: "executor".to_string(),
6906            iteration: 1,
6907            total_iterations: 3,
6908            team_size: 0,
6909        }];
6910        let handoff = vec![EngineResult {
6911            name: "codex".to_string(),
6912            bin: Some("codex".to_string()),
6913            status: "ok".to_string(),
6914            duration_ms: 1,
6915            detail: String::new(),
6916            exit_code: Some(0),
6917            stdout: String::new(),
6918            stderr: String::new(),
6919            output: "planner handoff context".to_string(),
6920            command: "codex".to_string(),
6921            token_usage: token_usage("a", "b"),
6922            tool_calls: vec![],
6923            sub_agents: vec![],
6924            role: "planner".to_string(),
6925            iteration: 2,
6926            total_iterations: 3,
6927            team_size: 4,
6928        }];
6929
6930        assert_eq!(workflow.iterations, 3);
6931        assert!(workflow.handoff);
6932        assert_eq!(workflow.team_work, 2);
6933        assert_eq!(workflow.teams.get("codex"), Some(&4));
6934        assert_eq!(workflow.teams.get("claude"), Some(&1));
6935        assert_eq!(workflow.teams.get("gemini"), Some(&0));
6936        assert_eq!(role_for("codex", &workflow), "planner");
6937        assert_eq!(role_for("claude", &workflow), "lead");
6938        assert_eq!(role_for("gemini", &workflow), "executor");
6939
6940        let codex_prompt = build_member_prompt(MemberPromptInput {
6941            query: "Fix the regression",
6942            role: "planner",
6943            workflow: &workflow,
6944            iteration: 2,
6945            team_size: effective_team_size(&workflow, "codex"),
6946            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
6947            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
6948            previous_iteration: &previous,
6949            handoff_results: &handoff,
6950            plan_output: "plan output",
6951        });
6952        assert!(codex_prompt.contains("Amon Hen workflow: iteration 2 of 3."));
6953        assert!(codex_prompt.contains("Lead model: claude."));
6954        assert!(codex_prompt.contains("Planner model: codex."));
6955        assert!(codex_prompt.contains("Your assigned role: planner."));
6956        assert!(codex_prompt.contains("Handoff mode is enabled."));
6957        assert!(codex_prompt.contains("Team work: you may coordinate up to 4 internal sub-agents"));
6958        assert!(codex_prompt.contains("Planner handoff:\nplan output"));
6959        assert!(codex_prompt.contains("previous iteration context"));
6960        assert!(codex_prompt.contains("planner handoff context"));
6961
6962        let codex_options = engine_options(
6963            &resolved,
6964            EngineOptionsInput {
6965                member: "codex",
6966                prompt: codex_prompt,
6967                role: "planner",
6968                iteration: 2,
6969                workflow: &workflow,
6970                progress: None,
6971                cancel: None,
6972            },
6973        );
6974        assert_eq!(codex_options.model.as_deref(), Some("gpt-5.2"));
6975        assert_eq!(codex_options.effort.as_deref(), Some("xhigh"));
6976        assert_eq!(
6977            codex_options.permission.as_deref(),
6978            Some("danger-full-access")
6979        );
6980        assert_eq!(codex_options.role, "planner");
6981        assert_eq!(codex_options.iteration, 2);
6982        assert_eq!(codex_options.total_iterations, 3);
6983        assert_eq!(codex_options.team_size, 4);
6984        assert_eq!(codex_options.capability.mode, CAPABILITY_OVERRIDE);
6985        assert_eq!(
6986            codex_options.capability.config,
6987            vec!["sandbox_workspace_write.network_access=true"]
6988        );
6989        assert_eq!(
6990            codex_options.capability.mcp_profile.as_deref(),
6991            Some("repo")
6992        );
6993
6994        let claude_options = engine_options(
6995            &resolved,
6996            EngineOptionsInput {
6997                member: "claude",
6998                prompt: build_member_prompt(MemberPromptInput {
6999                    query: "Fix the regression",
7000                    role: "lead",
7001                    workflow: &workflow,
7002                    iteration: 2,
7003                    team_size: effective_team_size(&workflow, "claude"),
7004                    max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7005                    max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7006                    previous_iteration: &[],
7007                    handoff_results: &[],
7008                    plan_output: "",
7009                }),
7010                role: "lead",
7011                iteration: 2,
7012                workflow: &workflow,
7013                progress: None,
7014                cancel: None,
7015            },
7016        );
7017        assert_eq!(claude_options.model.as_deref(), Some("sonnet"));
7018        assert_eq!(claude_options.effort.as_deref(), Some("max"));
7019        assert_eq!(
7020            claude_options.permission.as_deref(),
7021            Some("bypassPermissions")
7022        );
7023        assert_eq!(claude_options.role, "lead");
7024        assert_eq!(claude_options.team_size, 1);
7025        assert_eq!(claude_options.capability.mode, CAPABILITY_OVERRIDE);
7026        assert_eq!(
7027            claude_options.capability.allowed_tools,
7028            vec!["Read", "Edit"]
7029        );
7030        assert_eq!(claude_options.capability.tools, vec!["Bash"]);
7031        assert_eq!(
7032            claude_options.capability.agent.as_deref(),
7033            Some("lead-reviewer")
7034        );
7035
7036        let gemini_options = engine_options(
7037            &resolved,
7038            EngineOptionsInput {
7039                member: "gemini",
7040                prompt: build_member_prompt(MemberPromptInput {
7041                    query: "Fix the regression",
7042                    role: "executor",
7043                    workflow: &workflow,
7044                    iteration: 2,
7045                    team_size: effective_team_size(&workflow, "gemini"),
7046                    max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7047                    max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7048                    previous_iteration: &[],
7049                    handoff_results: &[],
7050                    plan_output: "",
7051                }),
7052                role: "executor",
7053                iteration: 2,
7054                workflow: &workflow,
7055                progress: None,
7056                cancel: None,
7057            },
7058        );
7059        assert_eq!(gemini_options.model.as_deref(), Some("gemini-2.5-pro"));
7060        assert_eq!(gemini_options.effort.as_deref(), Some("high"));
7061        assert_eq!(gemini_options.permission.as_deref(), Some("auto_edit"));
7062        assert_eq!(gemini_options.role, "executor");
7063        assert_eq!(gemini_options.team_size, 0);
7064        assert_eq!(gemini_options.capability.mode, CAPABILITY_OVERRIDE);
7065        assert_eq!(
7066            gemini_options.capability.settings.as_deref(),
7067            Some("gemini-settings.json")
7068        );
7069        assert_eq!(gemini_options.capability.tools_profile, vec!["repo", "ci"]);
7070        assert_eq!(
7071            gemini_options.capability.allowed_mcp_servers,
7072            vec!["linear"]
7073        );
7074    }
7075
7076    #[test]
7077    fn provider_command_args_include_role_matrix_settings() {
7078        let cwd = std::env::current_dir().unwrap();
7079        let codex_options = EngineRunOptions {
7080            prompt: "codex prompt".to_string(),
7081            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7082            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7083            cwd: cwd.clone(),
7084            timeout_ms: 1,
7085            effort: Some("high".to_string()),
7086            model: Some("gpt-5.2".to_string()),
7087            permission: Some("workspace-write".to_string()),
7088            auth: DEFAULT_AUTH_MODE.to_string(),
7089            capability: ProviderCapability {
7090                mode: CAPABILITY_OVERRIDE.to_string(),
7091                config: vec!["tools.web_search=true".to_string()],
7092                mcp_profile: Some("repo".to_string()),
7093                mcp_config: vec![],
7094                allowed_tools: vec![],
7095                disallowed_tools: vec![],
7096                tools: vec![],
7097                agent: None,
7098                agents_json: None,
7099                plugin_dirs: vec![],
7100                strict_mcp_config: false,
7101                disable_slash_commands: false,
7102                settings: None,
7103                tools_profile: vec![],
7104                allowed_mcp_servers: vec![],
7105                policy: vec![],
7106                admin_policy: vec![],
7107            },
7108            role: "planner".to_string(),
7109            iteration: 2,
7110            total_iterations: 3,
7111            team_size: 4,
7112            is_sub_agent: false,
7113            live: false,
7114            progress: None,
7115            cancel: None,
7116        };
7117        let codex = run_codex("definitely-missing-codex-test-bin", &codex_options);
7118        assert_eq!(codex.args[0], "exec");
7119        assert!(codex
7120            .args
7121            .windows(2)
7122            .any(|pair| pair == ["--model", "gpt-5.2"]));
7123        assert!(codex
7124            .args
7125            .windows(2)
7126            .any(|pair| pair == ["-c", "model_reasoning_effort=high"]));
7127        assert!(codex
7128            .args
7129            .windows(2)
7130            .any(|pair| pair == ["-c", "tools.web_search=true"]));
7131        assert!(codex
7132            .args
7133            .windows(2)
7134            .any(|pair| pair == ["--profile", "repo"]));
7135        assert!(codex
7136            .args
7137            .windows(2)
7138            .any(|pair| pair == ["--sandbox", "workspace-write"]));
7139
7140        let claude_options = EngineRunOptions {
7141            prompt: "claude prompt".to_string(),
7142            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7143            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7144            cwd: cwd.clone(),
7145            timeout_ms: 1,
7146            effort: Some("max".to_string()),
7147            model: Some("sonnet".to_string()),
7148            permission: Some("bypassPermissions".to_string()),
7149            auth: "social-login".to_string(),
7150            capability: ProviderCapability {
7151                mode: CAPABILITY_OVERRIDE.to_string(),
7152                config: vec![],
7153                mcp_profile: None,
7154                mcp_config: vec!["mcp.json".to_string()],
7155                allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
7156                disallowed_tools: vec!["WebFetch".to_string()],
7157                tools: vec!["Bash".to_string()],
7158                agent: Some("lead-reviewer".to_string()),
7159                agents_json: Some("agents.json".to_string()),
7160                plugin_dirs: vec!["plugins/a".to_string(), "plugins/b".to_string()],
7161                strict_mcp_config: true,
7162                disable_slash_commands: true,
7163                settings: None,
7164                tools_profile: vec![],
7165                allowed_mcp_servers: vec![],
7166                policy: vec![],
7167                admin_policy: vec![],
7168            },
7169            role: "lead".to_string(),
7170            iteration: 2,
7171            total_iterations: 3,
7172            team_size: 1,
7173            is_sub_agent: false,
7174            live: false,
7175            progress: None,
7176            cancel: None,
7177        };
7178        let claude = run_claude("definitely-missing-claude-test-bin", &claude_options);
7179        assert!(!claude.args.iter().any(|arg| arg == "--bare"));
7180        assert!(claude
7181            .args
7182            .windows(2)
7183            .any(|pair| pair == ["--permission-mode", "bypassPermissions"]));
7184        assert!(claude
7185            .args
7186            .windows(2)
7187            .any(|pair| pair == ["--model", "sonnet"]));
7188        assert!(claude
7189            .args
7190            .windows(2)
7191            .any(|pair| pair == ["--effort", "max"]));
7192        assert!(claude
7193            .args
7194            .windows(3)
7195            .any(|triple| triple == ["--allowedTools", "Read", "Edit"]));
7196        assert!(claude
7197            .args
7198            .windows(2)
7199            .any(|pair| pair == ["--agent", "lead-reviewer"]));
7200        assert!(claude.args.iter().any(|arg| arg == "--strict-mcp-config"));
7201        assert!(claude
7202            .args
7203            .iter()
7204            .any(|arg| arg == "--disable-slash-commands"));
7205
7206        let gemini_options = EngineRunOptions {
7207            prompt: "gemini prompt".to_string(),
7208            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7209            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7210            cwd,
7211            timeout_ms: 1,
7212            effort: Some("high".to_string()),
7213            model: Some("gemini-2.5-pro".to_string()),
7214            permission: Some("auto_edit".to_string()),
7215            auth: DEFAULT_AUTH_MODE.to_string(),
7216            capability: ProviderCapability {
7217                mode: CAPABILITY_OVERRIDE.to_string(),
7218                config: vec![],
7219                mcp_profile: None,
7220                mcp_config: vec![],
7221                allowed_tools: vec![],
7222                disallowed_tools: vec![],
7223                tools: vec![],
7224                agent: None,
7225                agents_json: None,
7226                plugin_dirs: vec![],
7227                strict_mcp_config: false,
7228                disable_slash_commands: false,
7229                settings: None,
7230                tools_profile: vec!["repo".to_string(), "ci".to_string()],
7231                allowed_mcp_servers: vec!["linear".to_string()],
7232                policy: vec!["default".to_string()],
7233                admin_policy: vec!["locked".to_string()],
7234            },
7235            role: "executor".to_string(),
7236            iteration: 2,
7237            total_iterations: 3,
7238            team_size: 0,
7239            is_sub_agent: false,
7240            live: false,
7241            progress: None,
7242            cancel: None,
7243        };
7244        let gemini = run_gemini("definitely-missing-gemini-test-bin", &gemini_options);
7245        assert!(gemini
7246            .args
7247            .windows(2)
7248            .any(|pair| pair == ["--model", "gemini-2.5-pro"]));
7249        assert!(gemini
7250            .args
7251            .windows(3)
7252            .any(|triple| triple == ["--extensions", "repo", "ci"]));
7253        assert!(gemini
7254            .args
7255            .windows(2)
7256            .any(|pair| pair == ["--allowed-mcp-server-names", "linear"]));
7257        assert!(gemini
7258            .args
7259            .windows(2)
7260            .any(|pair| pair == ["--policy", "default"]));
7261        assert!(gemini
7262            .args
7263            .windows(2)
7264            .any(|pair| pair == ["--admin-policy", "locked"]));
7265        assert!(gemini.args.windows(2).any(|pair| pair == ["-p", ""]));
7266        assert!(gemini
7267            .args
7268            .windows(2)
7269            .any(|pair| pair == ["--approval-mode", "auto_edit"]));
7270    }
7271
7272    #[test]
7273    #[cfg(unix)]
7274    fn gemini_prompt_is_sent_on_stdin_to_avoid_arg_limit() {
7275        use std::os::unix::fs::PermissionsExt;
7276
7277        let temp = tempfile::tempdir().unwrap();
7278        let fake_gemini = temp.path().join("gemini-fake");
7279        fs::write(
7280            &fake_gemini,
7281            "#!/bin/sh\nprintf 'ARGS:'\nfor arg in \"$@\"; do printf '<%s>' \"$arg\"; done\nprintf '\\nSTDIN:'\ncat\n",
7282        )
7283        .unwrap();
7284        let mut permissions = fs::metadata(&fake_gemini).unwrap().permissions();
7285        permissions.set_mode(0o755);
7286        fs::set_permissions(&fake_gemini, permissions).unwrap();
7287
7288        let prompt = "x".repeat(300_000);
7289        let options = EngineRunOptions {
7290            prompt: prompt.clone(),
7291            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7292            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7293            cwd: std::env::current_dir().unwrap(),
7294            timeout_ms: 5_000,
7295            effort: None,
7296            model: Some("gemini-test".to_string()),
7297            permission: None,
7298            auth: DEFAULT_AUTH_MODE.to_string(),
7299            capability: ProviderCapability {
7300                mode: CAPABILITY_INHERIT.to_string(),
7301                config: vec![],
7302                mcp_profile: None,
7303                mcp_config: vec![],
7304                allowed_tools: vec![],
7305                disallowed_tools: vec![],
7306                tools: vec![],
7307                agent: None,
7308                agents_json: None,
7309                plugin_dirs: vec![],
7310                strict_mcp_config: false,
7311                disable_slash_commands: false,
7312                settings: None,
7313                tools_profile: vec![],
7314                allowed_mcp_servers: vec![],
7315                policy: vec![],
7316                admin_policy: vec![],
7317            },
7318            role: "executor".to_string(),
7319            iteration: 1,
7320            total_iterations: 1,
7321            team_size: 0,
7322            is_sub_agent: false,
7323            live: false,
7324            progress: None,
7325            cancel: None,
7326        };
7327        let result = run_gemini(fake_gemini.to_str().unwrap(), &options);
7328
7329        assert_eq!(result.code, Some(0));
7330        assert!(result.args.iter().any(|arg| arg == "-p"));
7331        assert!(!result.args.iter().any(|arg| arg == &prompt));
7332        assert!(result.stdout.contains("ARGS:<--model><gemini-test><-p><>"));
7333        assert!(result.stdout.contains("<--approval-mode><plan>"));
7334        assert!(result.stdout.contains("<--output-format><stream-json>"));
7335        assert!(result.stdout.ends_with(&prompt));
7336    }
7337
7338    #[test]
7339    fn spawn_failure_status_distinguishes_missing_from_other_spawn_errors() {
7340        assert_eq!(spawn_failure_status("No such file or directory"), "missing");
7341        assert_eq!(
7342            spawn_failure_status("Argument list too long (os error 7)"),
7343            "error"
7344        );
7345    }
7346
7347    #[test]
7348    fn builds_member_prompt_with_roles_and_handoff() {
7349        let workflow = Workflow {
7350            handoff: true,
7351            lead: Some("claude".to_string()),
7352            planner: Some("codex".to_string()),
7353            planner_mode: PLANNER_MODE_BLOCKING.to_string(),
7354            owner_map: OWNER_MAP_STRICT.to_string(),
7355            handshake: false,
7356            handshake_agents: vec![],
7357            iterations: 2,
7358            team_work: 1,
7359            teams: HashMap::new(),
7360        };
7361        let prompt = build_member_prompt(MemberPromptInput {
7362            query: "Fix the bug",
7363            role: "planner",
7364            workflow: &workflow,
7365            iteration: 1,
7366            team_size: 1,
7367            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7368            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7369            previous_iteration: &[],
7370            handoff_results: &[],
7371            plan_output: "",
7372        });
7373        assert!(prompt.contains("Amon Hen workflow: iteration 1 of 2."));
7374        assert!(prompt.contains("Lead model: claude."));
7375        assert!(prompt.contains("Planner model: codex."));
7376        assert!(prompt.contains("Your assigned role: planner."));
7377        assert!(prompt.contains("Strict ownership is enabled."));
7378        assert!(prompt.contains("Current user query:"));
7379    }
7380
7381    #[test]
7382    fn member_prompt_respects_prompt_budget_and_handoff_caps() {
7383        let workflow = Workflow {
7384            handoff: true,
7385            lead: Some("claude".to_string()),
7386            planner: Some("codex".to_string()),
7387            planner_mode: PLANNER_MODE_REVIEW_CHAIN.to_string(),
7388            owner_map: OWNER_MAP_FLEXIBLE.to_string(),
7389            handshake: false,
7390            handshake_agents: vec![],
7391            iterations: 10,
7392            team_work: 3,
7393            teams: HashMap::new(),
7394        };
7395        let prior = vec![EngineResult {
7396            name: "codex".to_string(),
7397            bin: Some("codex".to_string()),
7398            status: "ok".to_string(),
7399            duration_ms: 1,
7400            detail: String::new(),
7401            exit_code: Some(0),
7402            stdout: String::new(),
7403            stderr: String::new(),
7404            output: "prior-output ".repeat(20_000),
7405            command: "codex".to_string(),
7406            token_usage: token_usage("prompt", "output"),
7407            tool_calls: vec![],
7408            sub_agents: vec![],
7409            role: "planner".to_string(),
7410            iteration: 3,
7411            total_iterations: 10,
7412            team_size: 3,
7413        }];
7414
7415        let prompt = build_member_prompt(MemberPromptInput {
7416            query: &"main-query ".repeat(20_000),
7417            role: "lead",
7418            workflow: &workflow,
7419            iteration: 4,
7420            team_size: 0,
7421            max_member_chars: 2_000,
7422            max_prompt_chars: 16_384,
7423            previous_iteration: &prior,
7424            handoff_results: &[],
7425            plan_output: &"plan-output ".repeat(20_000),
7426        });
7427
7428        assert!(prompt.chars().count() <= 16_384);
7429        assert!(prompt.contains("planner handoff truncated"));
7430        assert!(prompt.contains("handoff truncated"));
7431        assert!(prompt.contains("provider prompt truncated"));
7432    }
7433
7434    #[test]
7435    fn team_prompts_are_rebudgeted_before_provider_launch() {
7436        let options = EngineRunOptions {
7437            prompt: "original ".repeat(30_000),
7438            max_prompt_chars: 16_384,
7439            max_member_chars: 1_000,
7440            cwd: std::env::current_dir().unwrap(),
7441            timeout_ms: 1_000,
7442            effort: None,
7443            model: None,
7444            permission: None,
7445            auth: DEFAULT_AUTH_MODE.to_string(),
7446            capability: ProviderCapability {
7447                mode: CAPABILITY_INHERIT.to_string(),
7448                config: vec![],
7449                mcp_profile: None,
7450                mcp_config: vec![],
7451                allowed_tools: vec![],
7452                disallowed_tools: vec![],
7453                tools: vec![],
7454                agent: None,
7455                agents_json: None,
7456                plugin_dirs: vec![],
7457                strict_mcp_config: false,
7458                disable_slash_commands: false,
7459                settings: None,
7460                tools_profile: vec![],
7461                allowed_mcp_servers: vec![],
7462                policy: vec![],
7463                admin_policy: vec![],
7464            },
7465            role: "planner".to_string(),
7466            iteration: 1,
7467            total_iterations: 1,
7468            team_size: 2,
7469            is_sub_agent: false,
7470            live: false,
7471            progress: None,
7472            cancel: None,
7473        };
7474        let sub_prompt = limit_provider_prompt(
7475            &build_sub_agent_prompt(&options.prompt, &options.role, 1, 2),
7476            options.max_prompt_chars,
7477        );
7478        assert!(sub_prompt.chars().count() <= options.max_prompt_chars);
7479
7480        let sub_agent = EngineResult {
7481            name: "codex".to_string(),
7482            bin: Some("codex".to_string()),
7483            status: "ok".to_string(),
7484            duration_ms: 1,
7485            detail: String::new(),
7486            exit_code: Some(0),
7487            stdout: String::new(),
7488            stderr: String::new(),
7489            output: "sub-agent-output ".repeat(20_000),
7490            command: "codex".to_string(),
7491            token_usage: token_usage("prompt", "output"),
7492            tool_calls: vec![],
7493            sub_agents: vec![],
7494            role: "planner:sub-agent-1".to_string(),
7495            iteration: 1,
7496            total_iterations: 1,
7497            team_size: 0,
7498        };
7499        let lead_prompt = limit_provider_prompt(
7500            &build_team_lead_prompt(&options.prompt, &[sub_agent], options.max_member_chars),
7501            options.max_prompt_chars,
7502        );
7503        assert!(lead_prompt.chars().count() <= options.max_prompt_chars);
7504        assert!(lead_prompt.contains("sub-agent handoff truncated"));
7505    }
7506
7507    #[test]
7508    fn parses_claude_stream_json_result() {
7509        let text = r#"{"type":"system","subtype":"status","status":"requesting"}
7510{"type":"result","result":"done"}"#;
7511        assert_eq!(parse_claude_output(text), "done");
7512    }
7513
7514    #[test]
7515    fn parses_gemini_json_response() {
7516        assert_eq!(parse_gemini_output(r#"{"response":"hello"}"#), "hello");
7517    }
7518
7519    #[test]
7520    fn parses_gemini_stream_json_response_and_error() {
7521        let stream = r#"{"type":"init","model":"gemini-3.1-pro-preview"}
7522{"type":"message","role":"assistant","content":"Reviewing gate evidence ","delta":true}
7523{"type":"message","role":"assistant","content":"and readiness blockers.","delta":true}
7524{"type":"result","status":"success","stats":{"total_tokens":30,"input_tokens":20,"output_tokens":10}}"#;
7525        assert_eq!(
7526            parse_gemini_output(stream),
7527            "Reviewing gate evidence and readiness blockers."
7528        );
7529
7530        let error = r#"{"type":"result","status":"error","error":{"type":"QuotaError","message":"You exceeded your current quota."},"stats":{"total_tokens":101400,"input_tokens":101400,"output_tokens":0}}"#;
7531        assert_eq!(
7532            parse_gemini_output(error),
7533            "You exceeded your current quota."
7534        );
7535        assert_eq!(
7536            provider_failure_detail(error).as_deref(),
7537            Some("You exceeded your current quota.")
7538        );
7539    }
7540
7541    #[test]
7542    fn estimates_tokens_with_ceiling_chunks() {
7543        assert_eq!(estimate_tokens(""), 0);
7544        assert_eq!(estimate_tokens("abc"), 1);
7545        assert_eq!(estimate_tokens("abcd"), 1);
7546        assert_eq!(estimate_tokens("abcde"), 2);
7547    }
7548
7549    #[test]
7550    fn status_detail_strips_real_and_escaped_terminal_sequences() {
7551        let escaped =
7552            sanitize_status_detail(r"\u001b[26;107Hstatus \u001b[38;2;246;196;83mready\u001b[0m");
7553        let actual = sanitize_status_detail("\x1b[31mfailed\x1b[0m after check");
7554        let double_escaped = sanitize_status_detail(r"before \\u001b[39mready\\u001b[0m\\nnext");
7555        let bare_escaped = sanitize_status_detail("before u001b[39mreadyu001b[0m next");
7556
7557        assert_eq!(escaped, "status ready");
7558        assert_eq!(actual, "failed after check");
7559        assert_eq!(double_escaped, "before ready next");
7560        assert_eq!(bare_escaped, "before ready next");
7561        assert!(!escaped.contains(r"\u001b"));
7562        assert!(!actual.contains('\x1b'));
7563    }
7564
7565    #[test]
7566    fn extracts_social_login_urls() {
7567        assert_eq!(
7568            extract_auth_urls("Open https://example.com/callback?code=123, then continue"),
7569            vec!["https://example.com/callback?code=123"]
7570        );
7571        assert_eq!(
7572            extract_auth_urls("deeplink: claude://login/complete."),
7573            vec!["claude://login/complete"]
7574        );
7575    }
7576
7577    #[test]
7578    fn parses_provider_token_usage() {
7579        let stdout = r#"{"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}"#;
7580        let usage = extract_token_usage(stdout, "hello", "world").unwrap();
7581        assert_eq!(usage.input, 12);
7582        assert_eq!(usage.output, 8);
7583        assert_eq!(usage.total, 20);
7584        assert!(!usage.estimated);
7585    }
7586
7587    #[test]
7588    fn extracts_tool_usage_from_provider_streams() {
7589        let stdout = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}"#;
7590        let tools = extract_tool_usage("claude", stdout, "");
7591        assert!(tools.iter().any(|tool| tool.name == "Bash"));
7592    }
7593
7594    #[test]
7595    fn provider_stream_json_renders_readable_messages() {
7596        let claude = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Inspecting the truth predicates now."},{"type":"tool_use","name":"Bash","input":{"command":"git status -sb"}}]}}"#;
7597        let codex = r#"{"type":"item.completed","item":{"id":"item_13","type":"command_execution","command":"/bin/bash -lc 'sed -n 1,40p execution/live_router.py'","aggregated_output":"def route_order():\n    pass\n"}}"#;
7598        let gemini = r#"{"candidates":[{"content":{"parts":[{"text":"Gate waterfall evidence is still thin."}]}}]}"#;
7599        let gemini_stream = r#"{"type":"message","role":"assistant","content":"Reviewing the patch as a read-only critic.","delta":true}"#;
7600        let gemini_error = r#"{"type":"result","status":"error","error":{"type":"ModelError","message":"Gemini model failed after the prompt was accepted."}}"#;
7601
7602        assert_eq!(
7603            provider_visible_stream(Some("claude"), claude, &[]),
7604            StreamDisplay::Visible(
7605                "assistant: Inspecting the truth predicates now. | tool: Bash {\"command\":\"git status -sb\"}"
7606                    .to_string()
7607            )
7608        );
7609        let codex_visible = match provider_visible_stream(Some("codex"), codex, &[]) {
7610            StreamDisplay::Visible(text) => text,
7611            StreamDisplay::Suppress => panic!("codex command event should be visible"),
7612        };
7613        assert!(codex_visible.contains("tool: shell"));
7614        assert!(codex_visible.contains("execution/live_router.py"));
7615        assert!(!codex_visible.contains("\"type\":\"item.completed\""));
7616        assert_eq!(
7617            provider_visible_stream(Some("gemini"), gemini, &[]),
7618            StreamDisplay::Visible("assistant: Gate waterfall evidence is still thin.".to_string())
7619        );
7620        assert_eq!(
7621            provider_visible_stream(Some("gemini"), gemini_stream, &[]),
7622            StreamDisplay::Visible(
7623                "assistant: Reviewing the patch as a read-only critic.".to_string()
7624            )
7625        );
7626        assert_eq!(
7627            provider_visible_stream(Some("gemini"), gemini_error, &[]),
7628            StreamDisplay::Visible(
7629                "error: Gemini model failed after the prompt was accepted.".to_string()
7630            )
7631        );
7632    }
7633
7634    #[test]
7635    fn provider_stream_strips_escaped_terminal_sequences() {
7636        let raw = r"\u001b[26;107Hstatus \u001b[38;2;246;196;83mready\u001b[0m";
7637        assert_eq!(
7638            provider_visible_stream(Some("codex"), raw, &[]),
7639            StreamDisplay::Visible("status ready".to_string())
7640        );
7641
7642        let codex = r#"{"type":"item.completed","item":{"id":"item_8","type":"command_execution","command":"/bin/bash -lc 'free -h'","aggregated_output":"\\u001b[26;107Htotal used free\\u001b[0m"}}"#;
7643        let visible = match provider_visible_stream(Some("codex"), codex, &[]) {
7644            StreamDisplay::Visible(text) => text,
7645            StreamDisplay::Suppress => panic!("codex command output should be visible"),
7646        };
7647
7648        assert!(visible.contains("tool: shell"));
7649        assert!(visible.contains("total used free"));
7650        assert!(!visible.contains(r"\u001b"));
7651        assert!(!visible.contains("[26;107H"));
7652    }
7653
7654    #[test]
7655    fn provider_stream_suppresses_partial_provider_chunks() {
7656        let events = Arc::new(Mutex::new(Vec::new()));
7657        let captured = Arc::clone(&events);
7658        let progress = Some(CommandProgress {
7659            label: "codex planner:sub-agent-1 iteration 1/10".to_string(),
7660            sink: Some(Arc::new(move |event| {
7661                captured.lock().unwrap().push(event);
7662            })),
7663            input_tokens: 100,
7664        });
7665        let mut last_stream_event = Instant::now() - PROVIDER_STREAM_EVENT_MIN_INTERVAL;
7666
7667        maybe_emit_provider_stream_chunk(
7668            &progress,
7669            "stdout",
7670            r#"{"type":"item.completed","item":{"type":"command_execution","aggregated_output":"\\u001b[39m"#,
7671            r#"{"type":"item.completed","item":{"type":"command_execution","aggregated_output":"\\u001b[39m"#,
7672            &mut last_stream_event,
7673        );
7674
7675        assert!(
7676            events.lock().unwrap().is_empty(),
7677            "partial provider JSON chunks should not reach Studio"
7678        );
7679    }
7680
7681    #[test]
7682    fn claude_stream_event_json_suppresses_input_shards() {
7683        let input_delta = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":6,"delta":{"type":"input_json_delta","partial_json":"\"fallback_trigger\""},"session_id":"abc"}}"#;
7684        let tool_start = r#"{"type":"stream_event","event":{"type":"content_block_start","index":6,"content_block":{"type":"tool_use","id":"toolu_123","name":"Agent","input":{"description":"Agent E - CLOB/WS Timing","prompt":"Inspect the live VPS timing path"}},"session_id":"abc"}}"#;
7685        let text_delta = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I found the first timing issue."},"session_id":"abc"}}"#;
7686
7687        assert_eq!(
7688            provider_visible_stream(Some("claude"), input_delta, &[]),
7689            StreamDisplay::Suppress
7690        );
7691        assert!(extract_tool_usage("claude", input_delta, "").is_empty());
7692
7693        let visible_tool = match provider_visible_stream(Some("claude"), tool_start, &[]) {
7694            StreamDisplay::Visible(text) => text,
7695            StreamDisplay::Suppress => panic!("tool start should be visible"),
7696        };
7697        assert!(visible_tool.contains("tool: Agent"));
7698        assert!(visible_tool.contains("CLOB/WS Timing"));
7699        assert!(!visible_tool.contains("stream_event"));
7700        assert!(!visible_tool.contains("partial_json"));
7701
7702        assert_eq!(
7703            provider_visible_stream(Some("claude"), text_delta, &[]),
7704            StreamDisplay::Visible("assistant: I found the first timing issue.".to_string())
7705        );
7706        assert_eq!(
7707            parse_claude_output(&format!("{text_delta}\n{text_delta}\n")),
7708            "I found the first timing issue.I found the first timing issue."
7709        );
7710    }
7711
7712    #[test]
7713    fn provider_stream_json_suppresses_session_plumbing() {
7714        let claude_hook = r#"{"type":"system","subtype":"hook_response","hook_name":"SessionStart:startup","output":"noise"}"#;
7715        let codex_session = r#"{"type":"session.started","session_id":"abc"}"#;
7716
7717        assert_eq!(
7718            provider_visible_stream(Some("claude"), claude_hook, &[]),
7719            StreamDisplay::Suppress
7720        );
7721        assert_eq!(
7722            provider_visible_stream(Some("codex"), codex_session, &[]),
7723            StreamDisplay::Suppress
7724        );
7725    }
7726
7727    #[test]
7728    fn builds_real_sub_agent_handoff_prompt() {
7729        let agent = EngineResult {
7730            name: "codex".to_string(),
7731            bin: Some("codex".to_string()),
7732            status: "ok".to_string(),
7733            duration_ms: 1,
7734            detail: String::new(),
7735            exit_code: Some(0),
7736            stdout: String::new(),
7737            stderr: String::new(),
7738            output: "inspect parser".to_string(),
7739            command: "codex exec".to_string(),
7740            token_usage: token_usage("a", "b"),
7741            tool_calls: vec![],
7742            sub_agents: vec![],
7743            role: "executor:sub-agent-1".to_string(),
7744            iteration: 1,
7745            total_iterations: 1,
7746            team_size: 0,
7747        };
7748        let prompt = build_team_lead_prompt("ship it", &[agent], DEFAULT_MAX_MEMBER_CHARS);
7749        assert!(prompt.contains("Sub-agent handoffs"));
7750        assert!(prompt.contains("inspect parser"));
7751        assert!(prompt.contains("Original provider prompt"));
7752    }
7753
7754    #[test]
7755    fn json_stream_events_emit_progress_before_final_result() {
7756        let bus = RuntimeEventBus::new();
7757        let events = Arc::new(Mutex::new(Vec::new()));
7758        let captured = Arc::clone(&events);
7759        bus.add_sink(Arc::new(move |event| {
7760            captured.lock().unwrap().push(event);
7761        }));
7762        let progress = bus.progress_sink();
7763
7764        progress(progress_event_with_context(
7765            None,
7766            None,
7767            ProgressStage::Start,
7768            None,
7769            Some(1),
7770            Some(1),
7771            false,
7772            None,
7773            None,
7774            vec![],
7775            "[amon-hen] iteration 1/1 started",
7776        ));
7777        progress(progress_event_with_context(
7778            Some("codex"),
7779            Some("planner"),
7780            ProgressStage::Done,
7781            Some("ok"),
7782            Some(1),
7783            Some(1),
7784            false,
7785            Some(25),
7786            Some(token_usage("prompt", "answer")),
7787            vec![],
7788            "[amon-hen] done codex role=planner status=ok",
7789        ));
7790        let result = test_amon_hen_result(vec![test_engine_result("codex", "planner", 1)], 1);
7791        bus.emit_result(&result);
7792
7793        let events = events.lock().unwrap();
7794        assert!(events.len() >= 3);
7795        assert!(events[..events.len() - 1]
7796            .iter()
7797            .any(|event| event.progress.stage != ProgressStage::Done));
7798        assert!(events
7799            .iter()
7800            .any(|event| event.progress.kind == RuntimeEventKind::TokenUsage));
7801        assert_eq!(
7802            events.last().unwrap().progress.kind,
7803            RuntimeEventKind::Result
7804        );
7805        assert!(events.last().unwrap().result.is_some());
7806        for event in events.iter() {
7807            serde_json::to_string(event).expect("json-stream event should serialize");
7808        }
7809    }
7810
7811    #[test]
7812    fn runtime_event_bus_preserves_stream_sequence_order_under_fanout() {
7813        let bus = RuntimeEventBus::new();
7814        let events = Arc::new(Mutex::new(Vec::new()));
7815        let captured = Arc::clone(&events);
7816        bus.add_sink(Arc::new(move |event| {
7817            captured.lock().unwrap().push(event.sequence);
7818        }));
7819        let barrier = Arc::new(std::sync::Barrier::new(16));
7820        let mut handles = Vec::new();
7821
7822        for index in 0..16 {
7823            let progress = bus.progress_sink();
7824            let barrier = Arc::clone(&barrier);
7825            handles.push(thread::spawn(move || {
7826                barrier.wait();
7827                progress(progress_event(
7828                    Some("codex"),
7829                    Some("planner"),
7830                    ProgressStage::Heartbeat,
7831                    Some("running"),
7832                    format!("[amon-hen] fanout event {index}"),
7833                ));
7834            }));
7835        }
7836        for handle in handles {
7837            handle.join().unwrap();
7838        }
7839
7840        let sequences = events.lock().unwrap().clone();
7841        assert_eq!(sequences, (1..=16).collect::<Vec<_>>());
7842    }
7843
7844    #[test]
7845    fn multiple_iterations_preserve_timeline_entries() {
7846        let first = vec![test_engine_result("codex", "planner", 1)];
7847        let second = vec![test_engine_result("claude", "lead", 2)];
7848        let timeline = vec![
7849            iteration_record(1, 2, first.clone(), 10, None, None),
7850            iteration_record(
7851                2,
7852                2,
7853                second.clone(),
7854                15,
7855                iteration_handoff_context(&first, DEFAULT_MAX_MEMBER_CHARS),
7856                Some("summary context".to_string()),
7857            ),
7858        ];
7859        let result = test_amon_hen_result_with_iterations(second, timeline);
7860
7861        assert_eq!(result.iterations.len(), 2);
7862        assert_eq!(result.iterations[0].iteration, 1);
7863        assert_eq!(result.iterations[1].iteration, 2);
7864        assert!(result.iterations[1].handoff_context.is_some());
7865        assert_eq!(result.members[0].name, "claude");
7866    }
7867
7868    #[test]
7869    fn sub_agent_roles_remain_correct_in_timeline() {
7870        let mut member = test_engine_result("gemini", "lead", 1);
7871        member.sub_agents = vec![
7872            test_engine_result("gemini", "lead:sub-agent-1", 1),
7873            test_engine_result("gemini", "lead:sub-agent-2", 1),
7874        ];
7875        let timeline = vec![iteration_record(1, 1, vec![member], 20, None, None)];
7876
7877        assert_eq!(
7878            timeline[0]
7879                .sub_agents
7880                .iter()
7881                .map(|agent| agent.role.as_str())
7882                .collect::<Vec<_>>(),
7883            vec!["lead:sub-agent-1", "lead:sub-agent-2"]
7884        );
7885        assert_eq!(timeline[0].members[0].role, "lead");
7886    }
7887
7888    #[test]
7889    fn runtime_role_matrix_reaches_json_result_and_stream_events() {
7890        let _lock = ENV_LOCK.lock().unwrap();
7891        let Some(_env) = ProviderEnvGuard::install_echo_bins() else {
7892            return;
7893        };
7894        let args = CliArgs::try_parse_from([
7895            "amon-hen",
7896            "--members",
7897            "codex,claude,gemini",
7898            "--planner",
7899            "codex",
7900            "--lead",
7901            "claude",
7902            "--handoff",
7903            "--iterations",
7904            "2",
7905            "--team-work",
7906            "1",
7907            "--codex-sub-agents",
7908            "2",
7909            "--claude-sub-agents",
7910            "1",
7911            "--gemini-sub-agents",
7912            "0",
7913            "--timeout",
7914            "5",
7915            "role matrix smoke",
7916        ])
7917        .unwrap();
7918        let resolved = resolve_args(args).unwrap();
7919        let bus = RuntimeEventBus::new();
7920        let events = Arc::new(Mutex::new(Vec::new()));
7921        let captured = Arc::clone(&events);
7922        bus.add_sink(Arc::new(move |event| {
7923            captured.lock().unwrap().push(event);
7924        }));
7925        let prompt_context =
7926            build_prompt_context_with_progress(&resolved, Some(bus.progress_sink())).unwrap();
7927        let result = run_amon_hen_with_progress_and_cancel(
7928            &resolved,
7929            prompt_context.prompt,
7930            prompt_context.commands,
7931            Some(bus.progress_sink()),
7932            None,
7933        );
7934        bus.emit_result(&result);
7935
7936        assert_eq!(result.workflow.iterations, 2);
7937        assert_eq!(result.workflow.teams.get("codex"), Some(&2));
7938        assert_eq!(result.workflow.teams.get("claude"), Some(&1));
7939        assert_eq!(result.workflow.teams.get("gemini"), Some(&0));
7940        assert_eq!(result.iterations.len(), 2);
7941        for iteration in &result.iterations {
7942            assert_eq!(
7943                iteration
7944                    .members
7945                    .iter()
7946                    .map(|member| (member.name.as_str(), member.role.as_str()))
7947                    .collect::<Vec<_>>(),
7948                vec![
7949                    ("codex", "planner"),
7950                    ("gemini", "executor"),
7951                    ("claude", "lead")
7952                ]
7953            );
7954            assert_eq!(
7955                iteration
7956                    .sub_agents
7957                    .iter()
7958                    .map(|agent| (agent.name.as_str(), agent.role.as_str()))
7959                    .collect::<Vec<_>>(),
7960                vec![
7961                    ("codex", "planner:sub-agent-1"),
7962                    ("codex", "planner:sub-agent-2"),
7963                    ("claude", "lead:sub-agent-1")
7964                ]
7965            );
7966        }
7967
7968        let events = events.lock().unwrap();
7969        assert!(events.iter().any(|event| {
7970            event.progress.provider.as_deref() == Some("codex")
7971                && event.progress.role.as_deref() == Some("planner")
7972                && !event.progress.is_sub_agent
7973        }));
7974        assert!(events.iter().any(|event| {
7975            event.progress.provider.as_deref() == Some("claude")
7976                && event.progress.role.as_deref() == Some("lead:sub-agent-1")
7977                && event.progress.is_sub_agent
7978        }));
7979        let final_event = events.last().expect("final result event should exist");
7980        assert_eq!(final_event.progress.kind, RuntimeEventKind::Result);
7981        assert!(final_event.result.is_some());
7982    }
7983
7984    #[test]
7985    fn per_provider_sub_agent_overrides_are_reflected_in_prompts() {
7986        let args = CliArgs::try_parse_from([
7987            "amon-hen",
7988            "--members",
7989            "codex,gemini",
7990            "--team-work",
7991            "2",
7992            "--codex-sub-agents",
7993            "0",
7994            "--gemini-sub-agents",
7995            "3",
7996            "ship",
7997        ])
7998        .unwrap();
7999        let resolved = resolve_args(args).unwrap();
8000        let workflow = build_workflow(&resolved);
8001        let codex_prompt = build_member_prompt(MemberPromptInput {
8002            query: "ship",
8003            role: "executor",
8004            workflow: &workflow,
8005            iteration: 1,
8006            team_size: effective_team_size(&workflow, "codex"),
8007            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
8008            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
8009            previous_iteration: &[],
8010            handoff_results: &[],
8011            plan_output: "",
8012        });
8013        let gemini_prompt = build_member_prompt(MemberPromptInput {
8014            query: "ship",
8015            role: "executor",
8016            workflow: &workflow,
8017            iteration: 1,
8018            team_size: effective_team_size(&workflow, "gemini"),
8019            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
8020            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
8021            previous_iteration: &[],
8022            handoff_results: &[],
8023            plan_output: "",
8024        });
8025
8026        assert!(!codex_prompt.contains("Team work:"));
8027        assert!(gemini_prompt.contains("up to 3 internal sub-agents"));
8028    }
8029
8030    #[test]
8031    fn declarative_handshake_agents_build_role_slots() {
8032        let args = CliArgs::try_parse_from([
8033            "amon-hen",
8034            "--handshake-agents",
8035            "planner=codex:2,executor=claude,reviewer=gemini:1",
8036            "--team-work",
8037            "4",
8038            "ship",
8039        ])
8040        .unwrap();
8041        let resolved = resolve_args(args).unwrap();
8042        let workflow = build_workflow(&resolved);
8043
8044        assert_eq!(resolved.members, vec!["codex", "claude", "gemini"]);
8045        assert!(workflow.handshake);
8046        assert_eq!(
8047            workflow
8048                .handshake_agents
8049                .iter()
8050                .map(|agent| (
8051                    agent.provider.as_str(),
8052                    agent.role.as_str(),
8053                    agent.team_size
8054                ))
8055                .collect::<Vec<_>>(),
8056            vec![
8057                ("codex", "planner", 2),
8058                ("claude", "executor", 4),
8059                ("gemini", "reviewer", 1)
8060            ]
8061        );
8062    }
8063
8064    #[test]
8065    fn single_provider_handshake_runs_distinct_agent_roles() {
8066        let _lock = ENV_LOCK.lock().unwrap();
8067        let Some(_env) = ProviderEnvGuard::install_echo_bins() else {
8068            return;
8069        };
8070        let args = CliArgs::try_parse_from([
8071            "amon-hen",
8072            "--handshake",
8073            "--handshake-provider",
8074            "codex",
8075            "--handshake-sub-agents",
8076            "1",
8077            "--timeout",
8078            "5",
8079            "ship from one model",
8080        ])
8081        .unwrap();
8082        let resolved = resolve_args(args).unwrap();
8083        let workflow = build_workflow(&resolved);
8084
8085        assert_eq!(resolved.members, vec!["codex"]);
8086        assert!(workflow.handshake);
8087        assert_eq!(
8088            workflow
8089                .handshake_agents
8090                .iter()
8091                .map(|agent| (
8092                    agent.provider.as_str(),
8093                    agent.role.as_str(),
8094                    agent.team_size
8095                ))
8096                .collect::<Vec<_>>(),
8097            vec![
8098                ("codex", "planner", 1),
8099                ("codex", "executor", 1),
8100                ("codex", "reviewer", 1)
8101            ]
8102        );
8103
8104        let result = run_amon_hen_with_progress_and_cancel(
8105            &resolved,
8106            resolved.prompt.clone(),
8107            vec![],
8108            None,
8109            None,
8110        );
8111        let iteration = result.iterations.first().expect("handshake iteration");
8112        assert_eq!(
8113            iteration
8114                .members
8115                .iter()
8116                .map(|member| (member.name.as_str(), member.role.as_str()))
8117                .collect::<Vec<_>>(),
8118            vec![
8119                ("codex", "planner"),
8120                ("codex", "executor"),
8121                ("codex", "reviewer")
8122            ]
8123        );
8124        assert!(iteration
8125            .members
8126            .iter()
8127            .all(|member| member.sub_agents.len() == 1));
8128    }
8129
8130    #[test]
8131    fn duplicate_members_are_deduped_in_input_order() {
8132        let args = CliArgs::try_parse_from([
8133            "amon-hen",
8134            "--members",
8135            "codex,claude,codex,gemini,claude",
8136            "hello",
8137        ])
8138        .unwrap();
8139        let resolved = resolve_args(args).unwrap();
8140        assert_eq!(resolved.members, vec!["codex", "claude", "gemini"]);
8141    }
8142
8143    #[test]
8144    fn command_cancel_stops_child_process_promptly() {
8145        if !command_available("sh") {
8146            return;
8147        }
8148        let cwd = std::env::current_dir().unwrap();
8149        let cancel = Arc::new(AtomicBool::new(true));
8150        let started = Instant::now();
8151        let args = ["-c".to_string(), "sleep 30".to_string()];
8152        let result =
8153            run_command(CommandRequest::new("sh", &args, &cwd, 30_000).cancel(Some(cancel)));
8154
8155        assert!(result.cancelled);
8156        assert_eq!(result.error.as_deref(), Some("cancelled"));
8157        assert!(
8158            started.elapsed() < Duration::from_secs(3),
8159            "cancelled command took {:?}",
8160            started.elapsed()
8161        );
8162    }
8163
8164    #[test]
8165    fn command_streams_visible_output_and_live_token_usage() {
8166        if !command_available("sh") {
8167            return;
8168        }
8169        let cwd = std::env::current_dir().unwrap();
8170        let events = Arc::new(Mutex::new(Vec::new()));
8171        let captured = Arc::clone(&events);
8172        let progress: ProgressSink = Arc::new(move |event| {
8173            captured.lock().unwrap().push(event);
8174        });
8175        let args = [
8176            "-c".to_string(),
8177            "printf 'visible-work\\n'; printf '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"name\":\"Bash\"}]}}\\n'"
8178                .to_string(),
8179        ];
8180
8181        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
8182            CommandProgress {
8183                label: "codex planner:sub-agent-1 iteration 1/1".to_string(),
8184                sink: Some(progress),
8185                input_tokens: 10,
8186            },
8187        )));
8188
8189        assert_eq!(result.code, Some(0));
8190        let events = events.lock().unwrap();
8191        assert!(events.iter().any(|event| {
8192            event.message.contains("visible-work")
8193                && event
8194                    .token_usage
8195                    .as_ref()
8196                    .is_some_and(|usage| usage.total > 10)
8197        }));
8198        assert!(events.iter().any(|event| {
8199            event.kind == RuntimeEventKind::ToolUsage
8200                && event.tool_calls.iter().any(|tool| tool.name == "Bash")
8201        }));
8202    }
8203
8204    #[test]
8205    fn command_stream_sampling_preserves_important_tool_events() {
8206        if !command_available("sh") {
8207            return;
8208        }
8209        let cwd = std::env::current_dir().unwrap();
8210        let events = Arc::new(Mutex::new(Vec::new()));
8211        let captured = Arc::clone(&events);
8212        let progress: ProgressSink = Arc::new(move |event| {
8213            captured.lock().unwrap().push(event);
8214        });
8215        let args = [
8216            "-c".to_string(),
8217            "i=0; while [ $i -lt 120 ]; do echo stream-line-$i; i=$((i+1)); done; printf '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"name\":\"Bash\"}]}}\\n'"
8218                .to_string(),
8219        ];
8220
8221        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
8222            CommandProgress {
8223                label: "codex planner iteration 1/1".to_string(),
8224                sink: Some(progress),
8225                input_tokens: 10,
8226            },
8227        )));
8228
8229        assert_eq!(result.code, Some(0));
8230        let events = events.lock().unwrap();
8231        let streaming_events = events
8232            .iter()
8233            .filter(|event| event.status.as_deref() == Some("streaming"))
8234            .count();
8235        assert!(
8236            streaming_events < 20,
8237            "expected sampled streaming events, got {streaming_events}"
8238        );
8239        assert!(events.iter().any(|event| {
8240            event.kind == RuntimeEventKind::ToolUsage
8241                && event.tool_calls.iter().any(|tool| tool.name == "Bash")
8242        }));
8243    }
8244
8245    #[test]
8246    fn command_stream_decodes_provider_json_before_dashboard_events() {
8247        if !command_available("sh") {
8248            return;
8249        }
8250        let cwd = std::env::current_dir().unwrap();
8251        let events = Arc::new(Mutex::new(Vec::new()));
8252        let captured = Arc::clone(&events);
8253        let progress: ProgressSink = Arc::new(move |event| {
8254            captured.lock().unwrap().push(event);
8255        });
8256        let args = [
8257            "-c".to_string(),
8258            "printf '%s\\n' '{\"type\":\"session.started\",\"session_id\":\"abc\"}'; printf '%s\\n' '{\"type\":\"item.completed\",\"item\":{\"id\":\"item_13\",\"type\":\"command_execution\",\"command\":\"/bin/bash -lc sed\",\"aggregated_output\":\"line one\"}}'"
8259                .to_string(),
8260        ];
8261
8262        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
8263            CommandProgress {
8264                label: "codex executor iteration 1/1".to_string(),
8265                sink: Some(progress),
8266                input_tokens: 10,
8267            },
8268        )));
8269
8270        assert_eq!(result.code, Some(0));
8271        let events = events.lock().unwrap();
8272        assert!(!events
8273            .iter()
8274            .any(|event| event.message.contains("session.started")));
8275        assert!(events.iter().any(|event| {
8276            event.message.contains("tool: shell")
8277                && event.message.contains("line one")
8278                && !event.message.contains("\"type\":\"item.completed\"")
8279        }));
8280    }
8281
8282    #[test]
8283    fn claude_text_deltas_stream_as_coalesced_live_answer() {
8284        if !command_available("sh") {
8285            return;
8286        }
8287        let cwd = std::env::current_dir().unwrap();
8288        let events = Arc::new(Mutex::new(Vec::new()));
8289        let captured = Arc::clone(&events);
8290        let progress: ProgressSink = Arc::new(move |event| {
8291            captured.lock().unwrap().push(event);
8292        });
8293        let script = concat!(
8294            "printf '%s\\n' '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Reviewing \"}}}';",
8295            "printf '%s\\n' '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"gates now.\"}}}'"
8296        );
8297        let args = ["-c".to_string(), script.to_string()];
8298
8299        let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
8300            CommandProgress {
8301                label: "claude lead+planner iteration 1/1".to_string(),
8302                sink: Some(progress),
8303                input_tokens: 10,
8304            },
8305        )));
8306
8307        assert_eq!(result.code, Some(0));
8308        let events = events.lock().unwrap();
8309        assert!(events.iter().any(|event| {
8310            event
8311                .message
8312                .contains("assistant live: Reviewing gates now.")
8313                && event.provider.as_deref() == Some("claude")
8314        }));
8315        assert!(!events.iter().any(|event| {
8316            event.message.contains("stdout: assistant: Reviewing ")
8317                || event.message.contains("stdout: assistant: gates now.")
8318        }));
8319    }
8320
8321    #[test]
8322    fn missing_provider_preflight_avoids_team_fanout() {
8323        let args = CliArgs::try_parse_from([
8324            "amon-hen",
8325            "--members",
8326            "codex",
8327            "--team-work",
8328            "5",
8329            "hello",
8330        ])
8331        .unwrap();
8332        let resolved = resolve_args(args).unwrap();
8333        let options = EngineRunOptions {
8334            prompt: "hello".to_string(),
8335            max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
8336            max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
8337            cwd: resolved.cwd.clone(),
8338            timeout_ms: 1_000,
8339            effort: None,
8340            model: None,
8341            permission: None,
8342            auth: DEFAULT_AUTH_MODE.to_string(),
8343            capability: provider_capability(&resolved, "codex"),
8344            role: "planner".to_string(),
8345            iteration: 1,
8346            total_iterations: 1,
8347            team_size: 5,
8348            is_sub_agent: false,
8349            live: false,
8350            progress: None,
8351            cancel: None,
8352        };
8353
8354        let result = run_engine("definitely-missing-amon-hen-test-bin", options);
8355
8356        assert_eq!(result.status, "missing");
8357        assert!(result.sub_agents.is_empty());
8358        assert!(result.detail.contains("not found in PATH"));
8359    }
8360
8361    #[test]
8362    fn system_time_is_available_for_test_environment() {
8363        assert!(std::time::SystemTime::now()
8364            .duration_since(std::time::UNIX_EPOCH)
8365            .is_ok());
8366    }
8367
8368    #[test]
8369    fn cli_hygiene_has_no_legacy_npm_cli_or_council_binary_references() {
8370        let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
8371        let repo_root = crate_dir
8372            .parent()
8373            .and_then(Path::parent)
8374            .expect("crate lives under crates/amon-hen");
8375        assert!(!repo_root.join("package.json").exists());
8376        assert!(!repo_root.join("package-lock.json").exists());
8377        assert!(!repo_root.join("npm").exists());
8378        assert!(!repo_root.join("bin").join("council").exists());
8379
8380        let docs = [
8381            repo_root.join("README.md"),
8382            crate_dir.join("README.md"),
8383            crate_dir.join("CHANGELOG.md"),
8384        ];
8385        for path in docs {
8386            let text = fs::read_to_string(&path).unwrap();
8387            assert!(
8388                !text.contains("`council`") && !text.contains(" council "),
8389                "legacy binary reference remains in {}",
8390                path.display()
8391            );
8392        }
8393
8394        let help = render_cli_help();
8395        assert!(!help.contains("council"));
8396        assert!(help.contains("amon-hen"));
8397    }
8398
8399    fn test_amon_hen_result(members: Vec<EngineResult>, total_iterations: usize) -> AmonHenResult {
8400        let timeline = vec![iteration_record(
8401            total_iterations,
8402            total_iterations,
8403            members.clone(),
8404            25,
8405            None,
8406            Some("summary context".to_string()),
8407        )];
8408        test_amon_hen_result_with_iterations(members, timeline)
8409    }
8410
8411    fn test_amon_hen_result_with_iterations(
8412        members: Vec<EngineResult>,
8413        iterations: Vec<IterationRecord>,
8414    ) -> AmonHenResult {
8415        let mut teams = HashMap::new();
8416        teams.insert("codex".to_string(), 0);
8417        teams.insert("claude".to_string(), 0);
8418        teams.insert("gemini".to_string(), 0);
8419        let workflow = Workflow {
8420            handoff: true,
8421            lead: Some("claude".to_string()),
8422            planner: Some("codex".to_string()),
8423            planner_mode: PLANNER_MODE_BLOCKING.to_string(),
8424            owner_map: OWNER_MAP_FLEXIBLE.to_string(),
8425            handshake: false,
8426            handshake_agents: vec![],
8427            iterations: iterations.len().max(1),
8428            team_work: 0,
8429            teams,
8430        };
8431        AmonHenResult {
8432            query: "ship it".to_string(),
8433            cwd: ".".to_string(),
8434            members_requested: members.iter().map(|member| member.name.clone()).collect(),
8435            summarizer_requested: "auto".to_string(),
8436            workflow,
8437            prompt_commands: vec![],
8438            iterations,
8439            members,
8440            summary: test_engine_result("codex", "summary", 1),
8441            consensus: None,
8442        }
8443    }
8444
8445    fn test_engine_result(name: &str, role: &str, iteration: usize) -> EngineResult {
8446        EngineResult {
8447            name: name.to_string(),
8448            bin: Some(name.to_string()),
8449            status: "ok".to_string(),
8450            duration_ms: 10,
8451            detail: String::new(),
8452            exit_code: Some(0),
8453            stdout: String::new(),
8454            stderr: String::new(),
8455            output: format!("{name} output"),
8456            command: format!("{name} exec"),
8457            token_usage: token_usage("prompt", "output"),
8458            tool_calls: vec![ToolUsage {
8459                name: "tool".to_string(),
8460                kind: name.to_string(),
8461                status: "observed".to_string(),
8462                detail: "detail".to_string(),
8463            }],
8464            sub_agents: vec![],
8465            role: role.to_string(),
8466            iteration,
8467            total_iterations: iteration.max(1),
8468            team_size: 0,
8469        }
8470    }
8471}