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