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