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