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