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