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