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