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