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