1use clap::{error::ErrorKind, ArgAction, Parser, ValueEnum};
2use serde::{Deserialize, 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);
29const UPDATE_CRATE_NAME: &str = "amon-hen";
30const UPDATE_CHECK_URL: &str = "https://crates.io/api/v1/crates/amon-hen";
31const UPDATE_CHANGELOG_URL: &str =
32 "https://raw.githubusercontent.com/Dviros/Amon-Hen/main/crates/amon-hen/CHANGELOG.md";
33const UPDATE_CHECK_TIMEOUT_MS: u64 = 2_500;
34const UPDATE_CACHE_TTL_SECS: u64 = 6 * 60 * 60;
35const UPDATE_COMMAND_ENV: &str = "AMON_HEN_UPDATE_COMMAND";
36
37const ENGINES: [&str; 3] = ["codex", "claude", "gemini"];
38const DEFAULT_SUMMARIZER_ORDER: [&str; 3] = ["codex", "claude", "gemini"];
39const DEFAULT_AUTH_MODE: &str = "auto";
40const CAPABILITY_INHERIT: &str = "inherit";
41const CAPABILITY_OVERRIDE: &str = "override";
42const PLANNER_MODE_BLOCKING: &str = "blocking";
43const PLANNER_MODE_PARALLEL: &str = "parallel";
44const PLANNER_MODE_REVIEW_CHAIN: &str = "review-chain";
45const PLANNER_MODE_HANDSHAKE: &str = "handshake";
46const CONSENSUS_OFF: &str = "off";
47const CONSENSUS_REQUIRED: &str = "required";
48const FAILURE_POLICY_CONTINUE: &str = "continue";
49const FAILURE_POLICY_TAKEOVER: &str = "takeover";
50const STOP_WHEN_ITERATIONS: &str = "iterations";
51const STOP_WHEN_CONSENSUS: &str = "consensus";
52const OWNER_MAP_FLEXIBLE: &str = "flexible";
53const OWNER_MAP_STRICT: &str = "strict";
54const GEMINI_APPROVAL_MODES: [&str; 4] = ["plan", "default", "auto_edit", "yolo"];
55
56#[derive(Debug, Copy, Clone, Eq, PartialEq)]
57enum Engine {
58 Codex,
59 Claude,
60 Gemini,
61}
62
63impl Engine {
64 fn parse(name: &str) -> Option<Self> {
65 match name {
66 "codex" => Some(Self::Codex),
67 "claude" => Some(Self::Claude),
68 "gemini" => Some(Self::Gemini),
69 _ => None,
70 }
71 }
72
73 fn as_str(self) -> &'static str {
74 match self {
75 Self::Codex => "codex",
76 Self::Claude => "claude",
77 Self::Gemini => "gemini",
78 }
79 }
80
81 fn binary_env_var(self) -> &'static str {
82 match self {
83 Self::Codex => "AMON_HEN_CODEX_BIN",
84 Self::Claude => "AMON_HEN_CLAUDE_BIN",
85 Self::Gemini => "AMON_HEN_GEMINI_BIN",
86 }
87 }
88
89 fn allowed_efforts(self) -> &'static [&'static str] {
90 match self {
91 Self::Codex => &["low", "medium", "high", "xhigh", ""],
92 Self::Claude => &["low", "medium", "high", "xhigh", "max"],
93 Self::Gemini => &["low", "medium", "high", ""],
94 }
95 }
96}
97
98#[derive(Parser, Debug, Clone)]
99#[command(
100 name = "amon-hen",
101 version,
102 about = "Ask multiple AI CLIs the same question, then synthesize their answers.",
103 trailing_var_arg = true,
104 disable_version_flag = true
105)]
106pub struct CliArgs {
107 #[arg(short = 'v', long = "version", action = ArgAction::SetTrue)]
108 version: bool,
109 #[arg(long = "check-update", action = ArgAction::SetTrue)]
110 check_update: bool,
111 #[arg(long = "update", action = ArgAction::SetTrue)]
112 update: bool,
113 #[arg(long = "no-update-check", action = ArgAction::SetTrue)]
114 no_update_check: bool,
115
116 #[arg(long, action = ArgAction::SetTrue)]
117 json: bool,
118
119 #[arg(long = "json-stream", alias = "ndjson", action = ArgAction::SetTrue)]
120 json_stream: bool,
121
122 #[arg(long, action = ArgAction::SetTrue)]
123 headless: bool,
124
125 #[arg(long, action = ArgAction::SetTrue)]
126 studio: bool,
127
128 #[arg(long, action = ArgAction::SetTrue)]
129 plain: bool,
130
131 #[arg(long = "no-banner", action = ArgAction::SetTrue)]
132 no_banner: bool,
133 #[arg(long = "banner", hide = true, action = ArgAction::SetTrue)]
134 banner: bool,
135
136 #[arg(short = 'q', long = "quiet", alias = "summary-only", action = ArgAction::SetTrue)]
137 summary_only: bool,
138
139 #[arg(short = 'd', long, action = ArgAction::SetTrue)]
140 verbose: bool,
141
142 #[arg(long, action = ArgAction::SetTrue)]
143 all: bool,
144
145 #[arg(long, action = ArgAction::SetTrue)]
146 codex: bool,
147 #[arg(long = "no-codex", action = ArgAction::SetTrue)]
148 no_codex: bool,
149 #[arg(long, action = ArgAction::SetTrue)]
150 claude: bool,
151 #[arg(long = "no-claude", action = ArgAction::SetTrue)]
152 no_claude: bool,
153 #[arg(long, action = ArgAction::SetTrue)]
154 gemini: bool,
155 #[arg(long = "no-gemini", action = ArgAction::SetTrue)]
156 no_gemini: bool,
157
158 #[arg(long, value_delimiter = ',')]
159 members: Vec<String>,
160
161 #[arg(long, default_value = "auto")]
162 summarizer: String,
163
164 #[arg(long, value_enum)]
165 effort: Option<Effort>,
166
167 #[arg(long = "codex-model")]
168 codex_model: Option<String>,
169 #[arg(long = "claude-model")]
170 claude_model: Option<String>,
171 #[arg(long = "gemini-model")]
172 gemini_model: Option<String>,
173
174 #[arg(long = "codex-effort")]
175 codex_effort: Option<String>,
176 #[arg(long = "claude-effort")]
177 claude_effort: Option<String>,
178 #[arg(long = "gemini-effort")]
179 gemini_effort: Option<String>,
180
181 #[arg(long = "codex-sandbox", default_value = "read-only")]
182 codex_sandbox: String,
183 #[arg(long = "claude-permission-mode", default_value = "plan")]
184 claude_permission_mode: String,
185 #[arg(long = "gemini-approval-mode", default_value = "plan")]
186 gemini_approval_mode: String,
187
188 #[arg(long = "codex-auth", default_value = "auto")]
189 codex_auth: String,
190 #[arg(long = "claude-auth", default_value = "auto")]
191 claude_auth: String,
192 #[arg(long = "gemini-auth", default_value = "auto")]
193 gemini_auth: String,
194
195 #[arg(long = "codex-capabilities", default_value = "inherit")]
196 codex_capabilities: String,
197 #[arg(long = "claude-capabilities", default_value = "inherit")]
198 claude_capabilities: String,
199 #[arg(long = "gemini-capabilities", default_value = "inherit")]
200 gemini_capabilities: String,
201
202 #[arg(long = "codex-config", action = ArgAction::Append)]
203 codex_config: Vec<String>,
204 #[arg(long = "codex-mcp-profile")]
205 codex_mcp_profile: Option<String>,
206 #[arg(long = "claude-mcp-config", action = ArgAction::Append)]
207 claude_mcp_config: Vec<String>,
208 #[arg(long = "claude-allowed-tools", value_delimiter = ',', action = ArgAction::Append)]
209 claude_allowed_tools: Vec<String>,
210 #[arg(long = "claude-disallowed-tools", value_delimiter = ',', action = ArgAction::Append)]
211 claude_disallowed_tools: Vec<String>,
212 #[arg(long = "claude-tools", value_delimiter = ',', action = ArgAction::Append)]
213 claude_tools: Vec<String>,
214 #[arg(long = "claude-agent")]
215 claude_agent: Option<String>,
216 #[arg(long = "claude-agents-json")]
217 claude_agents_json: Option<String>,
218 #[arg(long = "claude-plugin-dir", action = ArgAction::Append)]
219 claude_plugin_dir: Vec<String>,
220 #[arg(long = "claude-strict-mcp-config", action = ArgAction::SetTrue)]
221 claude_strict_mcp_config: bool,
222 #[arg(long = "claude-disable-slash-commands", action = ArgAction::SetTrue)]
223 claude_disable_slash_commands: bool,
224 #[arg(long = "gemini-settings")]
225 gemini_settings: Option<String>,
226 #[arg(long = "gemini-tools-profile", value_delimiter = ',', action = ArgAction::Append)]
227 gemini_tools_profile: Vec<String>,
228 #[arg(long = "gemini-allowed-mcp-servers", value_delimiter = ',', action = ArgAction::Append)]
229 gemini_allowed_mcp_servers: Vec<String>,
230 #[arg(long = "gemini-policy", value_delimiter = ',', action = ArgAction::Append)]
231 gemini_policy: Vec<String>,
232 #[arg(long = "gemini-admin-policy", value_delimiter = ',', action = ArgAction::Append)]
233 gemini_admin_policy: Vec<String>,
234 #[arg(long = "capabilities-status", action = ArgAction::SetTrue)]
235 capabilities_status: bool,
236
237 #[arg(long = "auth-login", action = ArgAction::SetTrue)]
238 auth_login: bool,
239 #[arg(long = "auth-status", action = ArgAction::SetTrue)]
240 auth_status: bool,
241 #[arg(long = "auth-login-providers", value_delimiter = ',')]
242 auth_login_providers: Vec<String>,
243 #[arg(long = "auth-device-code", action = ArgAction::SetTrue)]
244 auth_device_code: bool,
245 #[arg(long = "no-auth-open-browser", action = ArgAction::SetTrue)]
246 no_auth_open_browser: bool,
247 #[arg(long = "auth-open-browser", hide = true, action = ArgAction::SetTrue)]
248 auth_open_browser: bool,
249 #[arg(long = "auth-timeout", default_value_t = 300)]
250 auth_timeout: u64,
251 #[arg(long = "claude-login-mode", default_value = "claudeai")]
252 claude_login_mode: String,
253 #[arg(long = "claude-login-email")]
254 claude_login_email: Option<String>,
255
256 #[arg(long = "file", alias = "tag-file", action = ArgAction::Append)]
257 files: Vec<PathBuf>,
258 #[arg(long = "cmd", alias = "prompt-command", action = ArgAction::Append)]
259 commands: Vec<String>,
260
261 #[arg(long, action = ArgAction::SetTrue)]
262 handoff: bool,
263 #[arg(long)]
264 lead: Option<String>,
265 #[arg(long)]
266 planner: Option<String>,
267 #[arg(long = "planner-mode", default_value = PLANNER_MODE_BLOCKING)]
268 planner_mode: String,
269 #[arg(long, default_value_t = DEFAULT_ITERATIONS)]
270 iterations: usize,
271 #[arg(long = "team-work", alias = "teamwork", alias = "sub-agents", default_value_t = DEFAULT_TEAM_SIZE)]
272 team_work: usize,
273 #[arg(long = "handshake", action = ArgAction::SetTrue)]
274 handshake: bool,
275 #[arg(long = "handshake-provider")]
276 handshake_provider: Option<String>,
277 #[arg(long = "handshake-agent", alias = "handshake-agents", value_delimiter = ',', action = ArgAction::Append)]
278 handshake_agents: Vec<String>,
279 #[arg(long = "handshake-sub-agents", default_value = "auto")]
280 handshake_sub_agents: String,
281 #[arg(long = "codex-sub-agents")]
282 codex_sub_agents: Option<usize>,
283 #[arg(long = "claude-sub-agents")]
284 claude_sub_agents: Option<usize>,
285 #[arg(long = "gemini-sub-agents")]
286 gemini_sub_agents: Option<usize>,
287
288 #[arg(long = "consensus", default_value = CONSENSUS_OFF)]
289 consensus: String,
290 #[arg(long = "consensus-reviewers", value_delimiter = ',')]
291 consensus_reviewers: Vec<String>,
292 #[arg(long = "failure-policy", default_value = FAILURE_POLICY_CONTINUE)]
293 failure_policy: String,
294 #[arg(long = "review-rounds", default_value_t = 1)]
295 review_rounds: usize,
296 #[arg(long = "require-final-diff-review", action = ArgAction::SetTrue)]
297 require_final_diff_review: bool,
298 #[arg(long = "require-tests", action = ArgAction::SetTrue)]
299 require_tests: bool,
300 #[arg(long = "require-secret-scan", action = ArgAction::SetTrue)]
301 require_secret_scan: bool,
302 #[arg(long = "require-clean-git-diff", action = ArgAction::SetTrue)]
303 require_clean_git_diff: bool,
304 #[arg(long = "stop-when", default_value = STOP_WHEN_ITERATIONS)]
305 stop_when: String,
306 #[arg(long = "owner-map", default_value = OWNER_MAP_FLEXIBLE)]
307 owner_map: String,
308
309 #[arg(long = "deliver-linear", alias = "linear", action = ArgAction::SetTrue)]
310 deliver_linear: bool,
311 #[arg(long = "linear-setup", action = ArgAction::SetTrue)]
312 linear_setup: bool,
313 #[arg(long = "linear-status", action = ArgAction::SetTrue)]
314 linear_status: bool,
315 #[arg(long = "linear-watch", action = ArgAction::SetTrue)]
316 linear_watch: bool,
317 #[arg(long = "linear-until-complete", action = ArgAction::SetTrue)]
318 linear_until_complete: bool,
319 #[arg(long = "linear-issue", value_delimiter = ',', action = ArgAction::Append)]
320 linear_issue: Vec<String>,
321 #[arg(long = "linear-query")]
322 linear_query: Option<String>,
323 #[arg(long = "linear-project", value_delimiter = ',', action = ArgAction::Append)]
324 linear_project: Vec<String>,
325 #[arg(long = "linear-epic", value_delimiter = ',', action = ArgAction::Append)]
326 linear_epic: Vec<String>,
327 #[arg(long = "linear-team")]
328 linear_team: Option<String>,
329 #[arg(long = "linear-state")]
330 linear_state: Option<String>,
331 #[arg(long = "linear-assignee")]
332 linear_assignee: Option<String>,
333 #[arg(long = "linear-limit", default_value_t = 3)]
334 linear_limit: usize,
335 #[arg(long = "linear-endpoint")]
336 linear_endpoint: Option<String>,
337 #[arg(long = "linear-auth", default_value = "api-key")]
338 linear_auth: String,
339 #[arg(long = "linear-api-key-env", default_value = "LINEAR_API_KEY")]
340 linear_api_key_env: String,
341 #[arg(long = "linear-oauth-token-env", default_value = "LINEAR_OAUTH_TOKEN")]
342 linear_oauth_token_env: String,
343 #[arg(long = "linear-completion-gate", default_value = "delivered")]
344 linear_completion_gate: String,
345 #[arg(long = "linear-review-state")]
346 linear_review_state: Option<String>,
347 #[arg(long = "linear-ci-timeout", default_value_t = 900)]
348 linear_ci_timeout: u64,
349 #[arg(long = "linear-ci-poll-interval", default_value_t = 30)]
350 linear_ci_poll_interval: u64,
351 #[arg(long = "linear-poll-interval", default_value_t = 60)]
352 linear_poll_interval: u64,
353 #[arg(long = "linear-max-polls")]
354 linear_max_polls: Option<usize>,
355 #[arg(long = "linear-max-concurrency", default_value_t = 1)]
356 linear_max_concurrency: usize,
357 #[arg(long = "linear-max-attempts", default_value_t = 3)]
358 linear_max_attempts: usize,
359 #[arg(long = "linear-retry-base", default_value_t = 60)]
360 linear_retry_base: u64,
361 #[arg(long = "linear-workspace-strategy", default_value = "worktree")]
362 linear_workspace_strategy: String,
363 #[arg(long = "linear-state-file")]
364 linear_state_file: Option<PathBuf>,
365 #[arg(long = "linear-workspace-root")]
366 linear_workspace_root: Option<PathBuf>,
367 #[arg(long = "linear-observability-dir")]
368 linear_observability_dir: Option<PathBuf>,
369 #[arg(long = "linear-workflow-file")]
370 linear_workflow_file: Option<PathBuf>,
371 #[arg(long = "linear-attach-media", value_delimiter = ',', action = ArgAction::Append)]
372 linear_attach_media: Vec<String>,
373 #[arg(long = "linear-attachment-title")]
374 linear_attachment_title: Option<String>,
375 #[arg(long = "no-linear-comments", action = ArgAction::SetTrue)]
376 no_linear_comments: bool,
377 #[arg(long = "linear-update-review-state", action = ArgAction::SetTrue)]
378 linear_update_review_state: bool,
379 #[arg(long = "delivery-phases", value_delimiter = ',')]
380 delivery_phases: Vec<String>,
381
382 #[arg(long, default_value_t = DEFAULT_TIMEOUT_MS / 1000)]
383 timeout: u64,
384 #[arg(long = "max-member-chars", default_value_t = DEFAULT_MAX_MEMBER_CHARS)]
385 max_member_chars: usize,
386 #[arg(long = "max-prompt-chars", default_value_t = DEFAULT_MAX_PROMPT_CHARS)]
387 max_prompt_chars: usize,
388 #[arg(long, default_value = ".")]
389 cwd: PathBuf,
390 #[arg(long = "resume", value_name = "RUN_DIR")]
391 resume: Option<PathBuf>,
392 #[arg(long, default_value = "auto")]
393 color: String,
394 #[arg(long = "no-color", action = ArgAction::SetTrue)]
395 no_color: bool,
396
397 #[arg(value_name = "QUERY")]
398 prompt: Vec<String>,
399}
400
401#[derive(Copy, Clone, Debug, ValueEnum)]
402enum Effort {
403 Low,
404 Medium,
405 High,
406}
407
408impl Effort {
409 fn as_str(self) -> &'static str {
410 match self {
411 Effort::Low => "low",
412 Effort::Medium => "medium",
413 Effort::High => "high",
414 }
415 }
416}
417
418#[derive(Debug, Clone)]
419struct ResolvedArgs {
420 raw: CliArgs,
421 members: Vec<String>,
422 prompt: String,
423 cwd: PathBuf,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct AmonHenResult {
428 query: String,
429 cwd: String,
430 members_requested: Vec<String>,
431 summarizer_requested: String,
432 workflow: Workflow,
433 prompt_commands: Vec<CommandTelemetry>,
434 iterations: Vec<IterationRecord>,
435 members: Vec<EngineResult>,
436 summary: EngineResult,
437 consensus: Option<ConsensusResult>,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
441struct Workflow {
442 handoff: bool,
443 lead: Option<String>,
444 planner: Option<String>,
445 planner_mode: String,
446 owner_map: String,
447 handshake: bool,
448 handshake_agents: Vec<AgentSlot>,
449 iterations: usize,
450 team_work: usize,
451 teams: HashMap<String, usize>,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455struct AgentSlot {
456 id: String,
457 provider: String,
458 role: String,
459 team_size: usize,
460}
461
462#[derive(Debug, Clone)]
463struct HandshakeAgentSpec {
464 role: String,
465 provider: String,
466 sub_agents: Option<usize>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470struct IterationRecord {
471 iteration: usize,
472 total_iterations: usize,
473 status: String,
474 duration_ms: u128,
475 members: Vec<EngineResult>,
476 sub_agents: Vec<EngineResult>,
477 handoff_context: Option<String>,
478 summary_context: Option<String>,
479 consensus: Option<ConsensusResult>,
480 token_usage: TokenUsage,
481 tool_calls: Vec<ToolUsage>,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct EngineResult {
486 name: String,
487 bin: Option<String>,
488 status: String,
489 duration_ms: u128,
490 detail: String,
491 exit_code: Option<i32>,
492 stdout: String,
493 stderr: String,
494 output: String,
495 command: String,
496 token_usage: TokenUsage,
497 tool_calls: Vec<ToolUsage>,
498 sub_agents: Vec<EngineResult>,
499 role: String,
500 iteration: usize,
501 total_iterations: usize,
502 team_size: usize,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize, Default)]
506struct TokenUsage {
507 input: usize,
508 output: usize,
509 total: usize,
510 estimated: bool,
511 source: String,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
515struct ToolUsage {
516 name: String,
517 kind: String,
518 status: String,
519 detail: String,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
523struct CommandTelemetry {
524 command: String,
525 status: String,
526 detail: String,
527 exit_code: Option<i32>,
528 duration_ms: u128,
529 stdout_chars: usize,
530 stderr_chars: usize,
531 timed_out: bool,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize)]
535struct ConsensusConfig {
536 mode: String,
537 reviewers: Vec<String>,
538 failure_policy: String,
539 review_rounds: usize,
540 require_final_diff_review: bool,
541 require_tests: bool,
542 require_secret_scan: bool,
543 require_clean_git_diff: bool,
544 stop_when: String,
545 owner_map: String,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
549struct ConsensusResult {
550 status: String,
551 iteration: usize,
552 approved: bool,
553 config: ConsensusConfig,
554 rounds: Vec<ConsensusRound>,
555 blockers: Vec<String>,
556 takeover_required: bool,
557 takeover_by: Vec<String>,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
561struct ConsensusRound {
562 round: usize,
563 status: String,
564 checks: Vec<ConsensusCheck>,
565 reviews: Vec<ConsensusReview>,
566 blockers: Vec<String>,
567 takeover_by: Vec<String>,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
571struct ConsensusCheck {
572 name: String,
573 command: CommandTelemetry,
574 stdout: String,
575 stderr: String,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
579struct ConsensusReview {
580 provider: String,
581 role: String,
582 status: String,
583 approved: bool,
584 blockers: Vec<String>,
585 takeover_for: Vec<String>,
586 result: EngineResult,
587}
588
589#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)]
590#[serde(rename_all = "snake_case")]
591enum ProgressStage {
592 Context,
593 Start,
594 Spawn,
595 Heartbeat,
596 Done,
597}
598
599#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)]
600#[serde(rename_all = "snake_case")]
601enum RuntimeEventKind {
602 Context,
603 Iteration,
604 Provider,
605 TokenUsage,
606 ToolUsage,
607 Result,
608}
609
610#[derive(Debug, Clone, Serialize)]
611struct ProgressEvent {
612 kind: RuntimeEventKind,
613 provider: Option<String>,
614 role: Option<String>,
615 stage: ProgressStage,
616 status: Option<String>,
617 iteration: Option<usize>,
618 total_iterations: Option<usize>,
619 is_sub_agent: bool,
620 duration_ms: Option<u128>,
621 token_usage: Option<TokenUsage>,
622 tool_calls: Vec<ToolUsage>,
623 message: String,
624}
625
626type ProgressSink = Arc<dyn Fn(ProgressEvent) + Send + Sync + 'static>;
627type RuntimeEventSink = Arc<dyn Fn(RuntimeEvent) + Send + Sync + 'static>;
628
629#[derive(Debug, Clone, Serialize)]
630struct RuntimeEvent {
631 sequence: u64,
632 elapsed_ms: u128,
633 #[serde(flatten)]
634 progress: ProgressEvent,
635 #[serde(skip_serializing_if = "Option::is_none")]
636 result: Option<Box<AmonHenResult>>,
637 #[serde(skip_serializing_if = "Option::is_none", rename = "linearResult")]
638 linear_result: Option<Box<linear_delivery::LinearDeliveryResult>>,
639}
640
641#[derive(Clone)]
642struct RuntimeEventBus {
643 started: Instant,
644 sequence: Arc<AtomicU64>,
645 emit_lock: Arc<Mutex<()>>,
646 sinks: Arc<Mutex<Vec<RuntimeEventSink>>>,
647}
648
649#[derive(Debug)]
650struct PromptContext {
651 prompt: String,
652 commands: Vec<CommandTelemetry>,
653}
654
655#[derive(Debug)]
656struct CommandResult {
657 command: String,
658 args: Vec<String>,
659 code: Option<i32>,
660 stdout: String,
661 stderr: String,
662 timed_out: bool,
663 cancelled: bool,
664 error: Option<String>,
665 timeout_ms: u64,
666 duration_ms: u128,
667}
668
669#[derive(Clone)]
670struct CommandProgress {
671 label: String,
672 sink: Option<ProgressSink>,
673 input_tokens: usize,
674}
675
676struct CommandRequest<'a> {
677 command: &'a str,
678 args: &'a [String],
679 cwd: &'a Path,
680 stdin_text: Option<&'a str>,
681 timeout_ms: u64,
682 envs: HashMap<String, String>,
683 progress: Option<CommandProgress>,
684 cancel: Option<Arc<AtomicBool>>,
685}
686
687impl<'a> CommandRequest<'a> {
688 fn new(command: &'a str, args: &'a [String], cwd: &'a Path, timeout_ms: u64) -> Self {
689 Self {
690 command,
691 args,
692 cwd,
693 stdin_text: None,
694 timeout_ms,
695 envs: HashMap::new(),
696 progress: None,
697 cancel: None,
698 }
699 }
700
701 fn stdin_text(mut self, stdin_text: Option<&'a str>) -> Self {
702 self.stdin_text = stdin_text;
703 self
704 }
705
706 fn envs(mut self, envs: HashMap<String, String>) -> Self {
707 self.envs = envs;
708 self
709 }
710
711 fn progress(mut self, progress: Option<CommandProgress>) -> Self {
712 self.progress = progress;
713 self
714 }
715
716 fn cancel(mut self, cancel: Option<Arc<AtomicBool>>) -> Self {
717 self.cancel = cancel;
718 self
719 }
720}
721
722#[derive(Clone)]
723struct EngineRunOptions {
724 prompt: String,
725 max_prompt_chars: usize,
726 max_member_chars: usize,
727 cwd: PathBuf,
728 timeout_ms: u64,
729 effort: Option<String>,
730 model: Option<String>,
731 permission: Option<String>,
732 auth: String,
733 capability: ProviderCapability,
734 role: String,
735 iteration: usize,
736 total_iterations: usize,
737 team_size: usize,
738 is_sub_agent: bool,
739 live: bool,
740 progress: Option<ProgressSink>,
741 cancel: Option<Arc<AtomicBool>>,
742}
743
744struct EngineOptionsInput<'a> {
745 member: &'a str,
746 prompt: String,
747 role: &'a str,
748 iteration: usize,
749 workflow: &'a Workflow,
750 progress: Option<ProgressSink>,
751 cancel: Option<Arc<AtomicBool>>,
752}
753
754struct MemberPromptInput<'a> {
755 query: &'a str,
756 role: &'a str,
757 workflow: &'a Workflow,
758 iteration: usize,
759 team_size: usize,
760 max_member_chars: usize,
761 max_prompt_chars: usize,
762 previous_iteration: &'a [EngineResult],
763 handoff_results: &'a [EngineResult],
764 plan_output: &'a str,
765}
766
767#[derive(Debug, Clone)]
768struct ProviderCapability {
769 mode: String,
770 config: Vec<String>,
771 mcp_profile: Option<String>,
772 mcp_config: Vec<String>,
773 allowed_tools: Vec<String>,
774 disallowed_tools: Vec<String>,
775 tools: Vec<String>,
776 agent: Option<String>,
777 agents_json: Option<String>,
778 plugin_dirs: Vec<String>,
779 strict_mcp_config: bool,
780 disable_slash_commands: bool,
781 settings: Option<String>,
782 tools_profile: Vec<String>,
783 allowed_mcp_servers: Vec<String>,
784 policy: Vec<String>,
785 admin_policy: Vec<String>,
786}
787
788#[derive(Debug, Clone, Serialize)]
789struct ProviderAuthStatus {
790 provider: String,
791 configured: bool,
792 status: String,
793 source: String,
794 detail: String,
795}
796
797#[derive(Debug, Clone, Serialize)]
798struct ProviderCapabilityStatus {
799 provider: String,
800 mcp: CommandTelemetry,
801 skills: Option<CommandTelemetry>,
802 tools: Option<CommandTelemetry>,
803 detail: String,
804}
805
806#[derive(Debug, Clone, Serialize, Deserialize)]
807struct UpdateInfo {
808 current_version: String,
809 latest_version: String,
810 update_available: bool,
811 changelog: String,
812 source: String,
813}
814
815#[derive(Debug, Clone, Deserialize)]
816struct CratesIoResponse {
817 #[serde(rename = "crate")]
818 krate: CratesIoCrate,
819}
820
821#[derive(Debug, Clone, Deserialize)]
822struct CratesIoCrate {
823 newest_version: String,
824}
825
826#[derive(Debug, Clone, Serialize, Deserialize)]
827struct UpdateCache {
828 checked_at_unix_secs: u64,
829 info: UpdateInfo,
830}
831
832struct TempSettings {
833 _dir: tempfile::TempDir,
834 path: PathBuf,
835}
836
837pub fn run_from_env() -> i32 {
838 let args: Vec<OsString> = std::env::args_os().skip(1).collect();
839 run_with_args(args)
840}
841
842pub fn run_with_args<I, T>(args: I) -> i32
843where
844 I: IntoIterator<Item = T>,
845 T: Into<OsString>,
846{
847 let raw_args: Vec<OsString> = args.into_iter().map(Into::into).collect();
848 if raw_args.first().and_then(|value| value.to_str()) == Some("help") {
849 println!("{}", render_cli_help());
850 return 0;
851 }
852
853 let mut parse_args = vec![OsString::from("amon-hen")];
854 parse_args.extend(raw_args);
855 let parsed = match CliArgs::try_parse_from(parse_args) {
856 Ok(parsed) => parsed,
857 Err(error) => {
858 if error.kind() == ErrorKind::DisplayHelp {
859 println!("{}", render_cli_help());
860 return 0;
861 }
862 if error.kind() == ErrorKind::DisplayVersion {
863 println!("{VERSION}");
864 return 0;
865 }
866 let exit_code = parse_error_exit_code(error.kind());
867 let _ = error.print();
868 return exit_code;
869 }
870 };
871
872 if parsed.version {
873 println!("{VERSION}");
874 return 0;
875 }
876
877 if parsed.check_update {
878 return match check_for_update(&parsed.cwd, false) {
879 Ok(info) => {
880 println!("{}", render_update_check(&info));
881 0
882 }
883 Err(error) => {
884 eprintln!("Update check failed: {error}");
885 1
886 }
887 };
888 }
889
890 if parsed.update {
891 return run_self_update(&parsed.cwd);
892 }
893
894 let mut resolved = match resolve_args(parsed) {
895 Ok(resolved) => resolved,
896 Err(error) => {
897 eprintln!("{error}");
898 return 64;
899 }
900 };
901 if let Err(error) = apply_resume_prompt(&mut resolved) {
902 eprintln!("{error}");
903 return 64;
904 }
905
906 if resolved.raw.studio {
907 return studio::run_studio(&resolved);
908 }
909
910 maybe_print_startup_update_notice(&resolved.raw, &resolved.cwd);
911
912 let event_bus = resolved.raw.json_stream.then(RuntimeEventBus::new);
913 if let Some(bus) = &event_bus {
914 let stdout_lock = Arc::new(Mutex::new(()));
915 bus.add_sink(Arc::new(move |event| {
916 let Ok(line) = serde_json::to_string(&event) else {
917 return;
918 };
919 let _guard = stdout_lock.lock().ok();
920 println!("{line}");
921 let _ = io::stdout().flush();
922 }));
923 }
924 let progress = event_bus.as_ref().map(RuntimeEventBus::progress_sink);
925
926 if resolved.raw.auth_status {
927 println!(
928 "{}",
929 render_auth_statuses(&collect_auth_statuses(&resolved))
930 );
931 if resolved.prompt.trim().is_empty()
932 && !resolved.raw.auth_login
933 && !resolved.raw.deliver_linear
934 && !resolved.raw.capabilities_status
935 {
936 return 0;
937 }
938 }
939
940 if resolved.raw.capabilities_status {
941 println!(
942 "{}",
943 render_provider_capability_statuses(&collect_provider_capability_statuses(&resolved))
944 );
945 if resolved.prompt.trim().is_empty()
946 && !resolved.raw.auth_login
947 && !resolved.raw.deliver_linear
948 {
949 return 0;
950 }
951 }
952
953 if resolved.raw.auth_login {
954 if let Err(error) = run_social_login(&resolved) {
955 eprintln!("{error}");
956 return 1;
957 }
958 if resolved.prompt.trim().is_empty() && !resolved.raw.deliver_linear {
959 return 0;
960 }
961 }
962
963 if resolved.raw.linear_setup || resolved.raw.linear_status {
964 match linear_delivery::get_linear_status(&resolved) {
965 Ok(status) => {
966 println!("{}", linear_delivery::render_linear_status(&status));
967 return 0;
968 }
969 Err(error) => {
970 eprintln!("{error}");
971 return 1;
972 }
973 }
974 }
975
976 if linear_delivery_requested(&resolved.raw) {
977 match linear_delivery::run_linear_delivery_with_progress(&resolved, progress.clone(), None)
978 {
979 Ok(result) => {
980 if let Some(bus) = &event_bus {
981 bus.emit_linear_result(&result);
982 } else if resolved.raw.json {
983 let serialized = serde_json::to_string_pretty(&result);
984 println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
985 } else {
986 println!(
987 "{}",
988 linear_delivery::render_linear_delivery_result(&result)
989 );
990 }
991 return if result.success { 0 } else { 1 };
992 }
993 Err(error) => {
994 eprintln!("{error}");
995 return 1;
996 }
997 }
998 }
999
1000 if resolved.prompt.trim().is_empty() {
1001 eprintln!("No query provided.\n\n{}", render_cli_help());
1002 return 64;
1003 }
1004
1005 let prompt_context = match build_prompt_context_with_progress(&resolved, progress.clone()) {
1006 Ok(context) => context,
1007 Err(error) => {
1008 eprintln!("{error}");
1009 return 1;
1010 }
1011 };
1012
1013 if should_show_banner(&resolved.raw) {
1014 eprintln!("{}", render_banner());
1015 }
1016
1017 let result = run_amon_hen_with_progress(
1018 &resolved,
1019 prompt_context.prompt,
1020 prompt_context.commands,
1021 progress,
1022 );
1023 if let Some(bus) = &event_bus {
1024 bus.emit_result(&result);
1025 } else if resolved.raw.json {
1026 let serialized = serde_json::to_string_pretty(&result);
1027 println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
1028 } else {
1029 println!("{}", render_human_result(&result, resolved.raw.verbose));
1030 }
1031
1032 if is_success(&result) {
1033 0
1034 } else {
1035 1
1036 }
1037}
1038
1039fn maybe_print_startup_update_notice(raw: &CliArgs, cwd: &Path) {
1040 if !should_auto_check_updates(raw) {
1041 return;
1042 }
1043 let Ok(info) = check_for_update(cwd, true) else {
1044 return;
1045 };
1046 if info.update_available {
1047 eprintln!("{}", render_startup_update_notice(&info));
1048 }
1049}
1050
1051fn should_auto_check_updates(raw: &CliArgs) -> bool {
1052 !raw.no_update_check
1053 && !raw.check_update
1054 && !raw.update
1055 && !raw.json
1056 && !raw.json_stream
1057 && !raw.plain
1058 && !raw.headless
1059 && !raw.summary_only
1060 && io::stderr().is_terminal()
1061}
1062
1063fn check_for_update(cwd: &Path, use_cache: bool) -> Result<UpdateInfo, String> {
1064 if use_cache {
1065 if let Some(info) = read_cached_update_info(cwd) {
1066 return Ok(info);
1067 }
1068 }
1069
1070 let info = fetch_update_info()?;
1071 write_update_cache(cwd, &info);
1072 Ok(info)
1073}
1074
1075fn fetch_update_info() -> Result<UpdateInfo, String> {
1076 let client = reqwest::blocking::Client::builder()
1077 .timeout(Duration::from_millis(UPDATE_CHECK_TIMEOUT_MS))
1078 .user_agent(format!("{UPDATE_CRATE_NAME}/{VERSION}"))
1079 .build()
1080 .map_err(|error| error.to_string())?;
1081 let response: CratesIoResponse = client
1082 .get(UPDATE_CHECK_URL)
1083 .send()
1084 .map_err(|error| error.to_string())?
1085 .error_for_status()
1086 .map_err(|error| error.to_string())?
1087 .json()
1088 .map_err(|error| error.to_string())?;
1089 let latest_version = response.krate.newest_version;
1090 let update_available = version_cmp(&latest_version, VERSION) == std::cmp::Ordering::Greater;
1091 let changelog = if update_available {
1092 fetch_remote_changelog(&client)
1093 .ok()
1094 .and_then(|text| extract_changelog_range(&text, VERSION, &latest_version))
1095 .unwrap_or_else(|| "Changelog unavailable; run --check-update again or open the crates.io release page.".to_string())
1096 } else {
1097 String::new()
1098 };
1099 Ok(UpdateInfo {
1100 current_version: VERSION.to_string(),
1101 latest_version,
1102 update_available,
1103 changelog,
1104 source: UPDATE_CHECK_URL.to_string(),
1105 })
1106}
1107
1108fn fetch_remote_changelog(client: &reqwest::blocking::Client) -> Result<String, String> {
1109 client
1110 .get(UPDATE_CHANGELOG_URL)
1111 .send()
1112 .map_err(|error| error.to_string())?
1113 .error_for_status()
1114 .map_err(|error| error.to_string())?
1115 .text()
1116 .map_err(|error| error.to_string())
1117}
1118
1119fn read_cached_update_info(cwd: &Path) -> Option<UpdateInfo> {
1120 let path = update_cache_path(cwd)?;
1121 let text = fs::read_to_string(path).ok()?;
1122 let cache: UpdateCache = serde_json::from_str(&text).ok()?;
1123 if cache.info.current_version != VERSION {
1124 return None;
1125 }
1126 let age = unix_now_secs().saturating_sub(cache.checked_at_unix_secs);
1127 (age <= UPDATE_CACHE_TTL_SECS).then_some(cache.info)
1128}
1129
1130fn write_update_cache(cwd: &Path, info: &UpdateInfo) {
1131 let Some(path) = update_cache_path(cwd) else {
1132 return;
1133 };
1134 if let Some(parent) = path.parent() {
1135 let _ = fs::create_dir_all(parent);
1136 }
1137 let cache = UpdateCache {
1138 checked_at_unix_secs: unix_now_secs(),
1139 info: info.clone(),
1140 };
1141 if let Ok(text) = serde_json::to_string_pretty(&cache) {
1142 let _ = fs::write(path, text + "\n");
1143 }
1144}
1145
1146fn update_cache_path(cwd: &Path) -> Option<PathBuf> {
1147 if let Some(path) = std::env::var_os("AMON_HEN_UPDATE_CACHE").map(PathBuf::from) {
1148 return Some(if path.is_absolute() {
1149 path
1150 } else {
1151 cwd.join(path)
1152 });
1153 }
1154 if let Some(root) = std::env::var_os("XDG_CACHE_HOME").map(PathBuf::from) {
1155 return Some(root.join("amon-hen").join("update-check.json"));
1156 }
1157 std::env::var_os("HOME").map(PathBuf::from).map(|home| {
1158 home.join(".cache")
1159 .join("amon-hen")
1160 .join("update-check.json")
1161 })
1162}
1163
1164fn unix_now_secs() -> u64 {
1165 std::time::SystemTime::now()
1166 .duration_since(std::time::UNIX_EPOCH)
1167 .unwrap_or_default()
1168 .as_secs()
1169}
1170
1171fn render_startup_update_notice(info: &UpdateInfo) -> String {
1172 let mut lines = vec![
1173 format!(
1174 "Amon Hen update available: {} -> {}",
1175 info.current_version, info.latest_version
1176 ),
1177 "Run `amon-hen --update` to update from this terminal.".to_string(),
1178 ];
1179 let changelog = render_changelog_excerpt(&info.changelog, 10);
1180 if !changelog.is_empty() {
1181 lines.push("Changelog:".to_string());
1182 lines.push(indent(&changelog, " "));
1183 }
1184 lines.join("\n")
1185}
1186
1187fn render_update_check(info: &UpdateInfo) -> String {
1188 if !info.update_available {
1189 return format!(
1190 "Amon Hen is up to date: {} (checked {})",
1191 info.current_version, info.source
1192 );
1193 }
1194 let mut lines = vec![
1195 format!(
1196 "Amon Hen update available: {} -> {}",
1197 info.current_version, info.latest_version
1198 ),
1199 "Update command: amon-hen --update".to_string(),
1200 ];
1201 let changelog = render_changelog_excerpt(&info.changelog, 40);
1202 if !changelog.is_empty() {
1203 lines.push(String::new());
1204 lines.push("Changelog:".to_string());
1205 lines.push(changelog);
1206 }
1207 lines.join("\n")
1208}
1209
1210fn render_changelog_excerpt(changelog: &str, max_lines: usize) -> String {
1211 let mut lines = changelog
1212 .lines()
1213 .map(str::trim_end)
1214 .filter(|line| !line.trim().is_empty())
1215 .take(max_lines)
1216 .map(ToString::to_string)
1217 .collect::<Vec<_>>();
1218 if changelog
1219 .lines()
1220 .filter(|line| !line.trim().is_empty())
1221 .count()
1222 > max_lines
1223 {
1224 lines.push("...".to_string());
1225 }
1226 lines.join("\n")
1227}
1228
1229fn extract_changelog_range(changelog: &str, current: &str, latest: &str) -> Option<String> {
1230 let mut selected = Vec::new();
1231 let mut include = false;
1232 for line in changelog.lines() {
1233 if let Some(version) = parse_changelog_heading(line) {
1234 include = version_cmp(&version, current) == std::cmp::Ordering::Greater
1235 && version_cmp(&version, latest) != std::cmp::Ordering::Greater;
1236 }
1237 if include {
1238 selected.push(line.to_string());
1239 }
1240 }
1241 let text = selected.join("\n").trim().to_string();
1242 (!text.is_empty()).then_some(text)
1243}
1244
1245fn parse_changelog_heading(line: &str) -> Option<String> {
1246 let rest = line.trim().strip_prefix("## ")?;
1247 let first = rest
1248 .trim()
1249 .trim_matches(['[', ']'])
1250 .split_whitespace()
1251 .next()?
1252 .trim_start_matches('v')
1253 .trim_matches(['[', ']']);
1254 (!first.is_empty()).then(|| first.to_string())
1255}
1256
1257fn version_cmp(left: &str, right: &str) -> std::cmp::Ordering {
1258 let left_parts = version_numeric_parts(left);
1259 let right_parts = version_numeric_parts(right);
1260 let len = left_parts.len().max(right_parts.len()).max(3);
1261 for index in 0..len {
1262 let left = left_parts.get(index).copied().unwrap_or(0);
1263 let right = right_parts.get(index).copied().unwrap_or(0);
1264 match left.cmp(&right) {
1265 std::cmp::Ordering::Equal => {}
1266 ordering => return ordering,
1267 }
1268 }
1269 std::cmp::Ordering::Equal
1270}
1271
1272fn version_numeric_parts(version: &str) -> Vec<u64> {
1273 version
1274 .trim()
1275 .trim_start_matches('v')
1276 .split(|character: char| !character.is_ascii_digit())
1277 .filter(|part| !part.is_empty())
1278 .filter_map(|part| part.parse::<u64>().ok())
1279 .collect()
1280}
1281
1282fn run_self_update(cwd: &Path) -> i32 {
1283 match check_for_update(cwd, false) {
1284 Ok(info) => eprintln!("{}", render_update_check(&info)),
1285 Err(error) => eprintln!("Update check failed before install: {error}"),
1286 }
1287 let (_, _, display) = self_update_command();
1288 eprintln!("Running update command: {display}");
1289 let result = run_self_update_command(cwd, None);
1290 print_command_result(&result);
1291 if command_telemetry(&result).status == "ok" {
1292 eprintln!("Amon Hen updated. Restart the terminal command to use the new binary.");
1293 0
1294 } else {
1295 eprintln!("Amon Hen update failed.");
1296 1
1297 }
1298}
1299
1300fn run_self_update_command(cwd: &Path, cancel: Option<Arc<AtomicBool>>) -> CommandResult {
1301 let (command, args, _) = self_update_command();
1302 run_command(CommandRequest::new(&command, &args, cwd, 10 * 60 * 1000).cancel(cancel))
1303}
1304
1305fn self_update_command() -> (String, Vec<String>, String) {
1306 if let Ok(command) = std::env::var(UPDATE_COMMAND_ENV) {
1307 let shell = if cfg!(windows) { "cmd" } else { "sh" }.to_string();
1308 let args = if cfg!(windows) {
1309 vec!["/C".to_string(), command.clone()]
1310 } else {
1311 vec!["-lc".to_string(), command.clone()]
1312 };
1313 return (shell, args, command);
1314 }
1315 (
1316 "cargo".to_string(),
1317 vec![
1318 "install".to_string(),
1319 UPDATE_CRATE_NAME.to_string(),
1320 "--force".to_string(),
1321 ],
1322 format!("cargo install {UPDATE_CRATE_NAME} --force"),
1323 )
1324}
1325
1326fn print_command_result(result: &CommandResult) {
1327 if !result.stdout.trim().is_empty() {
1328 eprintln!("{}", result.stdout.trim_end());
1329 }
1330 if !result.stderr.trim().is_empty() {
1331 eprintln!("{}", result.stderr.trim_end());
1332 }
1333 if let Some(error) = &result.error {
1334 eprintln!("{error}");
1335 }
1336}
1337
1338fn parse_error_exit_code(kind: ErrorKind) -> i32 {
1339 match kind {
1340 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
1341 _ => 64,
1342 }
1343}
1344
1345fn render_cli_help() -> &'static str {
1346 r#"Amon Hen
1347Rust-native orchestration for Codex, Claude, Gemini, and Linear delivery.
1348
1349Usage:
1350 amon-hen [OPTIONS] "your task"
1351 amon-hen --studio --members codex,claude,gemini "your task"
1352 amon-hen --deliver-linear --linear-project "Project" --linear-until-complete
1353
1354Core run:
1355 --members codex,claude,gemini Providers to consult. Also supports --codex, --claude, --gemini, --all.
1356 --summarizer auto|codex|claude|gemini
1357 Provider used for synthesis. auto tries the configured order.
1358 --studio Open the interactive native Studio.
1359 --plain Disable rich terminal output for script-friendly runs.
1360 --json, --json-stream Emit JSON or newline-delimited JSON.
1361 -q, --quiet Print the synthesis only.
1362 -d, --verbose Include provider details, telemetry, and tool usage.
1363
1364Models, effort, and permissions:
1365 --codex-model NAME Model passed to Codex.
1366 --claude-model NAME Model passed to Claude.
1367 --gemini-model NAME Model passed to Gemini.
1368 --effort low|medium|high Shared default effort.
1369 --codex-effort low|medium|high|xhigh
1370 --claude-effort low|medium|high|xhigh|max
1371 --gemini-effort low|medium|high
1372 --codex-sandbox read-only|workspace-write|danger-full-access
1373 --claude-permission-mode MODE Claude permission mode such as plan, acceptEdits, bypassPermissions.
1374 --gemini-approval-mode MODE Gemini approval mode: plan, default, auto_edit, or yolo.
1375
1376Auth and provider capabilities:
1377 --auth-login Start provider social-login flows.
1378 --auth-status Show configured auth sources for each provider.
1379 --codex-auth auto|api-key|social-login|oauth|keychain
1380 --claude-auth auto|api-key|social-login|oauth|keychain
1381 --gemini-auth auto|api-key|social-login|oauth|keychain
1382 --capabilities-status Probe provider Skills / MCP / Tools support.
1383 --codex-capabilities inherit|override
1384 --claude-capabilities inherit|override
1385 --gemini-capabilities inherit|override
1386 --codex-config PATH Extra Codex config file, repeatable.
1387 --codex-mcp-profile NAME Codex MCP profile.
1388 --claude-mcp-config PATH Claude MCP config file, repeatable.
1389 --claude-allowed-tools LIST Allowed Claude tools.
1390 --claude-disallowed-tools LIST Disallowed Claude tools.
1391 --claude-tools LIST Claude tool list override.
1392 --claude-agent NAME Claude agent profile.
1393 --claude-agents-json JSON Claude agents JSON override.
1394 --claude-plugin-dir PATH Claude plugin directory, repeatable.
1395 --gemini-settings PATH Gemini settings file.
1396 --gemini-tools-profile LIST Gemini tool profile list.
1397 --gemini-allowed-mcp-servers LIST Gemini MCP allow-list.
1398 --gemini-policy LIST Gemini policy values.
1399 --gemini-admin-policy LIST Gemini admin policy values.
1400
1401Team workflow:
1402 --handoff Feed planner/lead context between providers.
1403 --planner codex|claude|gemini Assign the planning role.
1404 --planner-mode blocking|parallel|review-chain|handshake
1405 Wait, run beside executors, serially review, or run a role handshake.
1406 --lead codex|claude|gemini Assign the lead reviewer/synthesizer role.
1407 --iterations N Run multiple provider rounds per prompt.
1408 --team-work N Spawn N same-provider sub-agents per provider.
1409 --handshake Run a planner -> executor -> reviewer agent handshake.
1410 --handshake-provider codex|claude|gemini
1411 Non-declarative single-provider handshake model.
1412 --handshake-agent ROLE=PROVIDER[:SUBS]
1413 Declarative role slot, repeatable or comma-list. Roles: planner, executor, reviewer.
1414 --handshake-sub-agents N|auto Sub-agent count for generated handshake slots.
1415 --codex-sub-agents N Override Codex sub-agent count.
1416 --claude-sub-agents N Override Claude sub-agent count.
1417 --gemini-sub-agents N Override Gemini sub-agent count.
1418 --consensus off|required Require reviewer consensus before success.
1419 --consensus-reviewers LIST Providers that must approve consensus.
1420 --failure-policy continue|takeover Continue after failures or require reviewer takeover.
1421 --review-rounds N Consensus review rounds before blocking.
1422 --require-final-diff-review Review final git diff as part of consensus.
1423 --require-tests Run detected test suite as consensus evidence.
1424 --require-secret-scan Block on sensitive strings/local paths in repo.
1425 --require-clean-git-diff Run git diff hygiene checks.
1426 --stop-when iterations|consensus Stop after configured iterations or first consensus.
1427 --owner-map flexible|strict Require explicit ownership review.
1428 --file PATH Tag a local file into the prompt context, repeatable.
1429 --cmd COMMAND Run a local command and include its telemetry, repeatable.
1430
1431Linear delivery:
1432 --deliver-linear Deliver Linear work instead of a one-shot prompt.
1433 --linear-watch Poll Linear for matching work.
1434 --linear-until-complete Continue until review/delivery gate is met.
1435 --linear-issue ID,ID Target specific Linear issues.
1436 --linear-project NAME,NAME Target Linear projects.
1437 --linear-epic NAME,NAME Target Linear epics.
1438 --linear-team KEY Restrict to a Linear team.
1439 --linear-state NAME Restrict by Linear workflow state.
1440 --linear-completion-gate delivered|review|ci
1441 --linear-max-concurrency N Isolated workspace concurrency.
1442 --linear-max-attempts N Retry attempts per issue.
1443 --linear-workspace-strategy worktree|local
1444 --linear-workspace-root PATH Root for per-issue workspaces.
1445 --linear-observability-dir PATH Logs, reconciliation, and run telemetry.
1446 --linear-attach-media PATH,PATH Attach generated media back to Linear.
1447
1448Runtime:
1449 --cwd PATH Working directory for provider CLIs.
1450 --resume RUN_DIR Reopen a Studio run from its saved state directory.
1451 --timeout SECONDS Per-provider timeout.
1452 --max-member-chars N Max provider output included in synthesis.
1453 --max-prompt-chars N Hard cap for each provider prompt before launch.
1454 --color auto|always|never Color mode.
1455 --no-color Disable color.
1456 --check-update Check crates.io for a newer Amon Hen and print the changelog.
1457 --update Run the terminal self-update command.
1458 --no-update-check Disable the startup update notice.
1459 -v, --version Print version.
1460 -h, --help Show this help.
1461
1462Examples:
1463 amon-hen --studio --members codex,claude,gemini "Inspect this repo"
1464 amon-hen --members codex,claude,gemini --planner codex --lead claude --handoff --iterations 2 "Suggest the cleanest next patch"
1465 amon-hen --deliver-linear --linear-project "Developer Platform" --linear-until-complete --team-work 2
1466 amon-hen --auth-login --auth-login-providers codex,claude,gemini"#
1467}
1468
1469fn resolve_args(raw: CliArgs) -> Result<ResolvedArgs, String> {
1470 let members = resolve_members(&raw)?;
1471 validate_engine_name(&raw.summarizer, true, "--summarizer")?;
1472 if let Some(lead) = &raw.lead {
1473 validate_engine_name(lead, false, "--lead")?;
1474 if !handshake_requested(&raw) && !members.contains(lead) {
1475 return Err(format!(
1476 "--lead must be one of the enabled members: {}",
1477 members.join(", ")
1478 ));
1479 }
1480 }
1481 if let Some(planner) = &raw.planner {
1482 validate_engine_name(planner, false, "--planner")?;
1483 if !handshake_requested(&raw) && !members.contains(planner) {
1484 return Err(format!(
1485 "--planner must be one of the enabled members: {}",
1486 members.join(", ")
1487 ));
1488 }
1489 }
1490 validate_choice(
1491 "--planner-mode",
1492 &raw.planner_mode,
1493 &[
1494 PLANNER_MODE_BLOCKING,
1495 PLANNER_MODE_PARALLEL,
1496 PLANNER_MODE_REVIEW_CHAIN,
1497 PLANNER_MODE_HANDSHAKE,
1498 ],
1499 )?;
1500 if let Some(provider) = &raw.handshake_provider {
1501 validate_engine_name(provider, false, "--handshake-provider")?;
1502 }
1503 validate_handshake_sub_agents(&raw.handshake_sub_agents)?;
1504 for agent in &raw.handshake_agents {
1505 parse_handshake_agent_spec(agent)?;
1506 }
1507 validate_choice(
1508 "--consensus",
1509 &raw.consensus,
1510 &[CONSENSUS_OFF, CONSENSUS_REQUIRED],
1511 )?;
1512 validate_choice(
1513 "--failure-policy",
1514 &raw.failure_policy,
1515 &[FAILURE_POLICY_CONTINUE, FAILURE_POLICY_TAKEOVER],
1516 )?;
1517 validate_choice(
1518 "--stop-when",
1519 &raw.stop_when,
1520 &[STOP_WHEN_ITERATIONS, STOP_WHEN_CONSENSUS],
1521 )?;
1522 validate_choice(
1523 "--owner-map",
1524 &raw.owner_map,
1525 &[OWNER_MAP_FLEXIBLE, OWNER_MAP_STRICT],
1526 )?;
1527 if raw.review_rounds == 0 {
1528 return Err("--review-rounds requires a positive integer.".to_string());
1529 }
1530 for reviewer in raw
1531 .consensus_reviewers
1532 .iter()
1533 .map(|reviewer| reviewer.trim())
1534 .filter(|reviewer| !reviewer.is_empty())
1535 {
1536 validate_engine_name(reviewer, false, "--consensus-reviewers")?;
1537 }
1538 validate_provider_effort("codex", raw.codex_effort.as_deref())?;
1539 validate_provider_effort("claude", raw.claude_effort.as_deref())?;
1540 validate_provider_effort("gemini", raw.gemini_effort.as_deref())?;
1541 validate_choice(
1542 "--gemini-approval-mode",
1543 &raw.gemini_approval_mode,
1544 &GEMINI_APPROVAL_MODES,
1545 )?;
1546 validate_choice(
1547 "--claude-login-mode",
1548 &raw.claude_login_mode,
1549 &["claudeai", "console", "sso"],
1550 )?;
1551 validate_choice(
1552 "--codex-capabilities",
1553 &raw.codex_capabilities,
1554 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
1555 )?;
1556 validate_choice(
1557 "--claude-capabilities",
1558 &raw.claude_capabilities,
1559 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
1560 )?;
1561 validate_choice(
1562 "--gemini-capabilities",
1563 &raw.gemini_capabilities,
1564 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
1565 )?;
1566 validate_choice("--linear-auth", &raw.linear_auth, &["api-key", "oauth"])?;
1567 validate_choice(
1568 "--linear-completion-gate",
1569 &raw.linear_completion_gate,
1570 &["delivered", "human-review", "ci-success", "review-or-ci"],
1571 )?;
1572 validate_choice(
1573 "--linear-workspace-strategy",
1574 &raw.linear_workspace_strategy,
1575 &["worktree", "copy", "none"],
1576 )?;
1577 if raw.linear_limit == 0 {
1578 return Err("--linear-limit requires a positive integer.".to_string());
1579 }
1580 if raw.linear_max_concurrency == 0 {
1581 return Err("--linear-max-concurrency requires a positive integer.".to_string());
1582 }
1583 if raw.linear_max_polls == Some(0) {
1584 return Err("--linear-max-polls requires a positive integer.".to_string());
1585 }
1586 if raw.max_member_chars == 0 {
1587 return Err("--max-member-chars requires a positive integer.".to_string());
1588 }
1589 if raw.max_prompt_chars < 16_384 {
1590 return Err("--max-prompt-chars must be at least 16384.".to_string());
1591 }
1592 if raw.max_prompt_chars > PROVIDER_PROMPT_HARD_LIMIT_CHARS {
1593 return Err(format!(
1594 "--max-prompt-chars cannot exceed {PROVIDER_PROMPT_HARD_LIMIT_CHARS}; provider CLIs reject larger prompts."
1595 ));
1596 }
1597 if raw
1598 .linear_endpoint
1599 .as_ref()
1600 .is_some_and(|value| value.trim().is_empty())
1601 {
1602 return Err("--linear-endpoint requires a non-empty value.".to_string());
1603 }
1604 for phase in raw
1605 .delivery_phases
1606 .iter()
1607 .map(|phase| phase.trim())
1608 .filter(|phase| !phase.is_empty())
1609 {
1610 validate_choice(
1611 "--delivery-phases",
1612 phase,
1613 &["plan", "implement", "verify", "ship"],
1614 )?;
1615 }
1616
1617 let mut prompt = raw.prompt.join(" ");
1618 if prompt.trim().is_empty() && !io::stdin().is_terminal() {
1619 let mut stdin = String::new();
1620 io::stdin()
1621 .read_to_string(&mut stdin)
1622 .map_err(|error| format!("Failed to read stdin: {error}"))?;
1623 prompt = stdin;
1624 }
1625 let cwd = raw.cwd.canonicalize().unwrap_or_else(|_| raw.cwd.clone());
1626
1627 Ok(ResolvedArgs {
1628 raw,
1629 members,
1630 prompt,
1631 cwd,
1632 })
1633}
1634
1635fn apply_resume_prompt(resolved: &mut ResolvedArgs) -> Result<(), String> {
1636 let Some(resume) = &resolved.raw.resume else {
1637 return Ok(());
1638 };
1639 let run_dir = resolve_resume_dir(&resolved.cwd, resume);
1640 if !run_dir.exists() {
1641 return Err(format!(
1642 "--resume run directory was not found: {}",
1643 run_dir.display()
1644 ));
1645 }
1646 if !resolved.prompt.trim().is_empty() {
1647 return Ok(());
1648 }
1649 if let Some(prompt) = read_resume_prompt(&run_dir)? {
1650 resolved.prompt = prompt;
1651 return Ok(());
1652 }
1653 Err(format!(
1654 "--resume could not find a saved prompt in {}",
1655 run_dir.display()
1656 ))
1657}
1658
1659fn read_resume_prompt(run_dir: &Path) -> Result<Option<String>, String> {
1660 let state_path = run_dir.join("state.json");
1661 if state_path.exists() {
1662 let text = fs::read_to_string(&state_path)
1663 .map_err(|error| format!("Failed to read {}: {error}", state_path.display()))?;
1664 let value: Value = serde_json::from_str(&text)
1665 .map_err(|error| format!("Failed to parse {}: {error}", state_path.display()))?;
1666 if let Some(prompt) = value.get("prompt").and_then(Value::as_str) {
1667 return Ok(Some(prompt.to_string()));
1668 }
1669 }
1670
1671 let prompt_path = run_dir.join("prompt.txt");
1672 if prompt_path.exists() {
1673 return fs::read_to_string(&prompt_path)
1674 .map(Some)
1675 .map_err(|error| format!("Failed to read {}: {error}", prompt_path.display()));
1676 }
1677 Ok(None)
1678}
1679
1680fn resolve_resume_dir(cwd: &Path, resume: &Path) -> PathBuf {
1681 if resume.is_absolute() {
1682 resume.to_path_buf()
1683 } else {
1684 cwd.join(resume)
1685 }
1686}
1687
1688fn resolve_members(raw: &CliArgs) -> Result<Vec<String>, String> {
1689 let explicit_members =
1690 !raw.members.is_empty() || raw.all || raw.codex || raw.claude || raw.gemini;
1691 let mut members = if handshake_requested(raw) && !explicit_members {
1692 default_handshake_members(raw)?
1693 } else if raw.members.is_empty() {
1694 ENGINES
1695 .iter()
1696 .map(|name| (*name).to_string())
1697 .collect::<Vec<_>>()
1698 } else {
1699 raw.members
1700 .iter()
1701 .map(|name| name.trim().to_string())
1702 .filter(|name| !name.is_empty())
1703 .collect::<Vec<_>>()
1704 };
1705
1706 if raw.all {
1707 members = ENGINES.iter().map(|name| (*name).to_string()).collect();
1708 }
1709 if raw.no_codex {
1710 members.retain(|name| name != "codex");
1711 }
1712 if raw.no_claude {
1713 members.retain(|name| name != "claude");
1714 }
1715 if raw.no_gemini {
1716 members.retain(|name| name != "gemini");
1717 }
1718 for enabled in [
1719 (raw.codex, "codex"),
1720 (raw.claude, "claude"),
1721 (raw.gemini, "gemini"),
1722 ] {
1723 if enabled.0 && !members.iter().any(|name| name == enabled.1) {
1724 members.push(enabled.1.to_string());
1725 }
1726 }
1727
1728 let mut seen = HashSet::new();
1729 members.retain(|member| seen.insert(member.clone()));
1730 for member in &members {
1731 validate_engine_name(member, false, "--members")?;
1732 }
1733 if members.is_empty() {
1734 return Err("At least one member must be enabled.".to_string());
1735 }
1736 Ok(members)
1737}
1738
1739fn handshake_requested(raw: &CliArgs) -> bool {
1740 raw.handshake || raw.planner_mode == PLANNER_MODE_HANDSHAKE || !raw.handshake_agents.is_empty()
1741}
1742
1743fn default_handshake_members(raw: &CliArgs) -> Result<Vec<String>, String> {
1744 let mut members = Vec::new();
1745 for spec in raw
1746 .handshake_agents
1747 .iter()
1748 .map(|value| parse_handshake_agent_spec(value))
1749 {
1750 let spec = spec?;
1751 if !members.iter().any(|member| member == &spec.provider) {
1752 members.push(spec.provider);
1753 }
1754 }
1755 if members.is_empty() {
1756 let provider = raw
1757 .handshake_provider
1758 .as_deref()
1759 .or(raw.planner.as_deref())
1760 .or(raw.lead.as_deref())
1761 .unwrap_or("codex");
1762 validate_engine_name(provider, false, "--handshake-provider")?;
1763 members.push(provider.to_string());
1764 }
1765 Ok(members)
1766}
1767
1768fn validate_engine_name(name: &str, allow_auto: bool, flag: &str) -> Result<(), String> {
1769 if allow_auto && name == "auto" {
1770 return Ok(());
1771 }
1772 if Engine::parse(name).is_some() {
1773 Ok(())
1774 } else {
1775 Err(format!(
1776 "{flag} must be one of: {}",
1777 if allow_auto {
1778 "auto, codex, claude, gemini"
1779 } else {
1780 "codex, claude, gemini"
1781 }
1782 ))
1783 }
1784}
1785
1786fn validate_provider_effort(provider: &str, value: Option<&str>) -> Result<(), String> {
1787 let Some(value) = value else {
1788 return Ok(());
1789 };
1790 let engine = Engine::parse(provider).expect("provider effort validation uses known engines");
1791 if engine.allowed_efforts().contains(&value) {
1792 Ok(())
1793 } else {
1794 Err(format!("Unsupported --{provider}-effort value: {value}"))
1795 }
1796}
1797
1798fn validate_choice(flag: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
1799 if allowed.contains(&value) {
1800 Ok(())
1801 } else {
1802 Err(format!("{flag} must be one of: {}", allowed.join(", ")))
1803 }
1804}
1805
1806fn validate_handshake_sub_agents(value: &str) -> Result<(), String> {
1807 handshake_sub_agent_override(value).map(|_| ())
1808}
1809
1810fn handshake_sub_agent_override(value: &str) -> Result<Option<usize>, String> {
1811 let value = value.trim();
1812 if value.eq_ignore_ascii_case("auto") || value.is_empty() {
1813 return Ok(None);
1814 }
1815 value
1816 .parse::<usize>()
1817 .map(Some)
1818 .map_err(|_| "--handshake-sub-agents must be `auto` or a non-negative integer.".to_string())
1819}
1820
1821fn parse_handshake_agent_spec(spec: &str) -> Result<HandshakeAgentSpec, String> {
1822 let spec = spec.trim();
1823 if spec.is_empty() {
1824 return Err("--handshake-agent cannot be empty.".to_string());
1825 }
1826 let (role, rest) = spec
1827 .split_once('=')
1828 .or_else(|| spec.split_once(':'))
1829 .ok_or_else(|| {
1830 "--handshake-agent must look like ROLE=PROVIDER[:SUB_AGENTS].".to_string()
1831 })?;
1832 let role = normalize_handshake_role(role)?;
1833 let mut parts = rest.split(':');
1834 let provider = parts
1835 .next()
1836 .map(str::trim)
1837 .filter(|value| !value.is_empty())
1838 .ok_or_else(|| "--handshake-agent provider cannot be empty.".to_string())?;
1839 validate_engine_name(provider, false, "--handshake-agent")?;
1840 let sub_agents = parts
1841 .next()
1842 .map(str::trim)
1843 .filter(|value| !value.is_empty())
1844 .map(|value| {
1845 value.parse::<usize>().map_err(|_| {
1846 "--handshake-agent sub-agent count must be a non-negative integer.".to_string()
1847 })
1848 })
1849 .transpose()?;
1850 if parts.next().is_some() {
1851 return Err("--handshake-agent must look like ROLE=PROVIDER[:SUB_AGENTS].".to_string());
1852 }
1853 Ok(HandshakeAgentSpec {
1854 role,
1855 provider: provider.to_string(),
1856 sub_agents,
1857 })
1858}
1859
1860fn normalize_handshake_role(role: &str) -> Result<String, String> {
1861 match role.trim().to_ascii_lowercase().as_str() {
1862 "planner" | "plan" => Ok("planner".to_string()),
1863 "executor" | "execute" | "exec" | "builder" | "implementer" => Ok("executor".to_string()),
1864 "reviewer" | "review" | "verifier" | "critic" => Ok("reviewer".to_string()),
1865 _ => Err("--handshake-agent role must be one of: planner, executor, reviewer.".to_string()),
1866 }
1867}
1868
1869fn collect_auth_statuses(resolved: &ResolvedArgs) -> Vec<ProviderAuthStatus> {
1870 collect_auth_statuses_with_cancel(resolved, None)
1871}
1872
1873fn collect_auth_statuses_with_cancel(
1874 resolved: &ResolvedArgs,
1875 cancel: Option<Arc<AtomicBool>>,
1876) -> Vec<ProviderAuthStatus> {
1877 resolved
1878 .members
1879 .iter()
1880 .map(|provider| provider_auth_status_with_cancel(provider, resolved, cancel.clone()))
1881 .collect()
1882}
1883
1884fn provider_auth_status(provider: &str, resolved: &ResolvedArgs) -> ProviderAuthStatus {
1885 provider_auth_status_with_cancel(provider, resolved, None)
1886}
1887
1888fn provider_auth_status_with_cancel(
1889 provider: &str,
1890 resolved: &ResolvedArgs,
1891 cancel: Option<Arc<AtomicBool>>,
1892) -> ProviderAuthStatus {
1893 let auth = provider_auth(resolved, provider);
1894 if cancel
1895 .as_ref()
1896 .is_some_and(|cancel| cancel.load(Ordering::Relaxed))
1897 {
1898 return ProviderAuthStatus {
1899 provider: provider.to_string(),
1900 configured: false,
1901 status: "cancelled".to_string(),
1902 source: auth,
1903 detail: "Auth status refresh cancelled.".to_string(),
1904 };
1905 }
1906 match provider {
1907 "codex" => {
1908 let bin = resolve_binary("codex");
1909 let args = vec!["login".to_string(), "status".to_string()];
1910 status_from_command(provider, &auth, &bin, &args, &resolved.cwd, cancel)
1911 }
1912 "claude" => {
1913 let bin = resolve_binary("claude");
1914 let args = vec![
1915 "auth".to_string(),
1916 "status".to_string(),
1917 "--text".to_string(),
1918 ];
1919 status_from_command(provider, &auth, &bin, &args, &resolved.cwd, cancel)
1920 }
1921 "gemini" => {
1922 let has_api_key = std::env::var("GEMINI_API_KEY")
1923 .ok()
1924 .is_some_and(|value| !value.trim().is_empty());
1925 let home = std::env::var("HOME").unwrap_or_default();
1926 let has_oauth_file = !home.is_empty()
1927 && [
1928 ".gemini/oauth_creds.json",
1929 ".gemini/oauth_tokens.json",
1930 ".gemini/settings.json",
1931 ]
1932 .iter()
1933 .any(|path| Path::new(&home).join(path).exists());
1934 let configured = has_api_key || has_oauth_file;
1935 ProviderAuthStatus {
1936 provider: provider.to_string(),
1937 configured,
1938 status: if configured { "configured" } else { "unknown" }.to_string(),
1939 source: auth,
1940 detail: if has_api_key {
1941 "GEMINI_API_KEY is present.".to_string()
1942 } else if has_oauth_file {
1943 "Gemini local auth/config files are present.".to_string()
1944 } else {
1945 "Gemini CLI does not currently expose a stable headless auth status command; use Social login from Studio or run gemini interactively.".to_string()
1946 },
1947 }
1948 }
1949 _ => ProviderAuthStatus {
1950 provider: provider.to_string(),
1951 configured: false,
1952 status: "unknown".to_string(),
1953 source: auth,
1954 detail: "Unknown provider.".to_string(),
1955 },
1956 }
1957}
1958
1959fn status_from_command(
1960 provider: &str,
1961 auth: &str,
1962 bin: &str,
1963 args: &[String],
1964 cwd: &Path,
1965 cancel: Option<Arc<AtomicBool>>,
1966) -> ProviderAuthStatus {
1967 let result = run_command(CommandRequest::new(bin, args, cwd, 15_000).cancel(cancel));
1968 let configured = result.code == Some(0);
1969 ProviderAuthStatus {
1970 provider: provider.to_string(),
1971 configured,
1972 status: if configured {
1973 "configured"
1974 } else if result.cancelled {
1975 "cancelled"
1976 } else if result.error.is_some() {
1977 "missing-cli"
1978 } else {
1979 "not-configured"
1980 }
1981 .to_string(),
1982 source: auth.to_string(),
1983 detail: sanitize_status_detail(&compact_failure(&result)),
1984 }
1985}
1986
1987fn sanitize_status_detail(detail: &str) -> String {
1988 let cleaned = strip_terminal_control_sequences(detail);
1989 let cleaned = normalize_escaped_log_text(&cleaned);
1990 let detail = cleaned.trim();
1991 if detail.is_empty() {
1992 return "No status detail returned.".to_string();
1993 }
1994 let detail = detail
1995 .lines()
1996 .take(6)
1997 .map(str::trim)
1998 .filter(|line| !line.is_empty())
1999 .map(redact_possible_secret)
2000 .collect::<Vec<_>>()
2001 .join(" ");
2002 redact_local_paths(&detail)
2003}
2004
2005fn normalize_escaped_log_text(value: &str) -> String {
2006 value
2007 .replace("\\\\r", " ")
2008 .replace("\\\\n", " ")
2009 .replace("\\\\t", " ")
2010 .replace("\\r", " ")
2011 .replace("\\n", " ")
2012 .replace("\\t", " ")
2013 .replace("\\\\\"", "\"")
2014 .replace("\\\"", "\"")
2015}
2016
2017fn strip_terminal_control_sequences(value: &str) -> String {
2018 let needs_cleaning = value.as_bytes().contains(&0x1b)
2019 || value.contains('\u{9b}')
2020 || value.contains('\\')
2021 || value.contains("u001b")
2022 || value.contains("u001B")
2023 || value.contains("u{1b}")
2024 || value.contains("u{1B}")
2025 || value
2026 .chars()
2027 .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'));
2028 if !needs_cleaning {
2029 return value.to_string();
2030 }
2031
2032 let chars = value.chars().collect::<Vec<_>>();
2033 let mut output = String::with_capacity(value.len());
2034 let mut index = 0;
2035 while index < chars.len() {
2036 if chars[index] == '\x1b' {
2037 index = skip_terminal_escape(&chars, index + 1);
2038 continue;
2039 }
2040 if chars[index] == '\u{9b}' {
2041 index = skip_csi_sequence(&chars, index + 1);
2042 continue;
2043 }
2044 if let Some(after_escape) = escaped_escape_end(&chars, index) {
2045 index = skip_terminal_escape(&chars, after_escape);
2046 continue;
2047 }
2048 if let Some(after_escape) = bare_escaped_escape_end(&chars, index) {
2049 index = skip_terminal_escape(&chars, after_escape);
2050 continue;
2051 }
2052 let ch = chars[index];
2053 if ch.is_control() && !matches!(ch, '\n' | '\r' | '\t') {
2054 output.push(' ');
2055 } else {
2056 output.push(ch);
2057 }
2058 index += 1;
2059 }
2060 output
2061}
2062
2063fn bare_escaped_escape_end(chars: &[char], index: usize) -> Option<usize> {
2064 if starts_with_chars(chars, index, &['u', '0', '0', '1', 'b'])
2065 || starts_with_chars(chars, index, &['u', '0', '0', '1', 'B'])
2066 {
2067 return Some(index + 5);
2068 }
2069 if starts_with_chars(chars, index, &['u', '{', '1', 'b', '}'])
2070 || starts_with_chars(chars, index, &['u', '{', '1', 'B', '}'])
2071 {
2072 return Some(index + 5);
2073 }
2074 None
2075}
2076
2077fn escaped_escape_end(chars: &[char], index: usize) -> Option<usize> {
2078 if chars.get(index) != Some(&'\\') {
2079 return None;
2080 }
2081 let mut cursor = index;
2082 while chars.get(cursor) == Some(&'\\') {
2083 cursor += 1;
2084 }
2085 if starts_with_chars(chars, cursor, &['u', '0', '0', '1', 'b'])
2086 || starts_with_chars(chars, cursor, &['u', '0', '0', '1', 'B'])
2087 {
2088 return Some(cursor + 5);
2089 }
2090 if starts_with_chars(chars, cursor, &['u', '{', '1', 'b', '}'])
2091 || starts_with_chars(chars, cursor, &['u', '{', '1', 'B', '}'])
2092 {
2093 return Some(cursor + 5);
2094 }
2095 if starts_with_chars(chars, cursor, &['x', '1', 'b'])
2096 || starts_with_chars(chars, cursor, &['x', '1', 'B'])
2097 {
2098 return Some(cursor + 3);
2099 }
2100 if starts_with_chars(chars, cursor, &['0', '3', '3']) {
2101 return Some(cursor + 3);
2102 }
2103 if starts_with_chars(chars, cursor, &['e']) {
2104 return Some(cursor + 1);
2105 }
2106 None
2107}
2108
2109fn starts_with_chars(chars: &[char], index: usize, needle: &[char]) -> bool {
2110 chars
2111 .get(index..index.saturating_add(needle.len()))
2112 .is_some_and(|slice| slice == needle)
2113}
2114
2115fn skip_terminal_escape(chars: &[char], index: usize) -> usize {
2116 let Some(ch) = chars.get(index).copied() else {
2117 return index;
2118 };
2119 match ch {
2120 '[' => skip_csi_sequence(chars, index + 1),
2121 ']' => skip_terminated_control_string(chars, index + 1),
2122 'P' | '^' | '_' | 'X' => skip_terminated_control_string(chars, index + 1),
2123 '(' | ')' | '*' | '+' | '-' | '.' | '/' => (index + 2).min(chars.len()),
2124 '@'..='_' => (index + 1).min(chars.len()),
2125 _ => index,
2126 }
2127}
2128
2129fn skip_csi_sequence(chars: &[char], mut index: usize) -> usize {
2130 while index < chars.len() {
2131 let ch = chars[index];
2132 index += 1;
2133 if ('@'..='~').contains(&ch) {
2134 break;
2135 }
2136 }
2137 index
2138}
2139
2140fn skip_terminated_control_string(chars: &[char], mut index: usize) -> usize {
2141 while index < chars.len() {
2142 if chars[index] == '\u{7}' {
2143 return index + 1;
2144 }
2145 if chars[index] == '\x1b' && chars.get(index + 1) == Some(&'\\') {
2146 return index + 2;
2147 }
2148 if let Some(after_escape) = escaped_escape_end(chars, index) {
2149 if chars.get(after_escape) == Some(&'\\') {
2150 return after_escape + 1;
2151 }
2152 index = after_escape;
2153 continue;
2154 }
2155 index += 1;
2156 }
2157 index
2158}
2159
2160fn redact_possible_secret(value: &str) -> String {
2161 value
2162 .split_whitespace()
2163 .map(|token| {
2164 if looks_sensitive_token(token) {
2165 "[redacted]"
2166 } else if looks_like_email(token) {
2167 "[email]"
2168 } else {
2169 token
2170 }
2171 })
2172 .collect::<Vec<_>>()
2173 .join(" ")
2174}
2175
2176fn looks_sensitive_token(token: &str) -> bool {
2177 let trimmed =
2178 token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_');
2179 trimmed.len() >= 28
2180 && (trimmed.starts_with("sk-")
2181 || trimmed.starts_with("sk_")
2182 || trimmed.starts_with("gho_")
2183 || trimmed.starts_with("ghp_")
2184 || trimmed.starts_with("ghs_")
2185 || trimmed.starts_with("ghu_")
2186 || trimmed.starts_with("AIza")
2187 || trimmed.starts_with("AQ."))
2188}
2189
2190fn looks_like_email(token: &str) -> bool {
2191 let trimmed = token.trim_matches(|ch: char| {
2192 !ch.is_ascii_alphanumeric() && !matches!(ch, '@' | '.' | '_' | '-' | '+')
2193 });
2194 trimmed.contains('@') && trimmed.rsplit_once('.').is_some()
2195}
2196
2197fn redact_local_paths(value: &str) -> String {
2198 let mut redacted = value.to_string();
2199 if let Ok(home) = std::env::var("HOME") {
2200 if !home.trim().is_empty() {
2201 redacted = redacted.replace(&home, "~");
2202 }
2203 }
2204 redacted
2205}
2206
2207fn render_auth_statuses(statuses: &[ProviderAuthStatus]) -> String {
2208 let mut lines = vec!["Provider auth status".to_string()];
2209 for status in statuses {
2210 lines.push(format!(
2211 "- {}: {} via {} ({})",
2212 status.provider, status.status, status.source, status.detail
2213 ));
2214 }
2215 lines.join("\n")
2216}
2217
2218fn collect_provider_capability_statuses(resolved: &ResolvedArgs) -> Vec<ProviderCapabilityStatus> {
2219 collect_provider_capability_statuses_with_cancel(resolved, None)
2220}
2221
2222fn collect_provider_capability_statuses_with_cancel(
2223 resolved: &ResolvedArgs,
2224 cancel: Option<Arc<AtomicBool>>,
2225) -> Vec<ProviderCapabilityStatus> {
2226 resolved
2227 .members
2228 .iter()
2229 .map(|provider| provider_capability_status(provider, resolved, cancel.clone()))
2230 .collect()
2231}
2232
2233fn provider_capability_status(
2234 provider: &str,
2235 resolved: &ResolvedArgs,
2236 cancel: Option<Arc<AtomicBool>>,
2237) -> ProviderCapabilityStatus {
2238 match provider {
2239 "codex" => {
2240 let bin = resolve_binary("codex");
2241 let mcp = run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone());
2242 ProviderCapabilityStatus {
2243 provider: provider.to_string(),
2244 mcp,
2245 skills: None,
2246 tools: Some(run_capability_probe(
2247 &bin,
2248 &["plugin", "marketplace", "--help"],
2249 &resolved.cwd,
2250 cancel,
2251 )),
2252 detail:
2253 "Codex inherits ~/.codex config unless --codex-capabilities override is set."
2254 .to_string(),
2255 }
2256 }
2257 "claude" => {
2258 let bin = resolve_binary("claude");
2259 ProviderCapabilityStatus {
2260 provider: provider.to_string(),
2261 mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
2262 skills: Some(run_capability_probe(
2263 &bin,
2264 &["plugin", "list"],
2265 &resolved.cwd,
2266 cancel.clone(),
2267 )),
2268 tools: Some(run_capability_probe(
2269 &bin,
2270 &["agents"],
2271 &resolved.cwd,
2272 cancel,
2273 )),
2274 detail: "Claude override can manage MCP config, tools, agents, plugin dirs, and slash-command skills.".to_string(),
2275 }
2276 }
2277 "gemini" => {
2278 let bin = resolve_binary("gemini");
2279 ProviderCapabilityStatus {
2280 provider: provider.to_string(),
2281 mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd, cancel.clone()),
2282 skills: Some(run_capability_probe(
2283 &bin,
2284 &["skills", "list"],
2285 &resolved.cwd,
2286 cancel.clone(),
2287 )),
2288 tools: Some(run_capability_probe(
2289 &bin,
2290 &["extensions", "list"],
2291 &resolved.cwd,
2292 cancel,
2293 )),
2294 detail: "Gemini override can manage settings, extensions, MCP server allowlists, and policy files.".to_string(),
2295 }
2296 }
2297 _ => ProviderCapabilityStatus {
2298 provider: provider.to_string(),
2299 mcp: CommandTelemetry {
2300 command: provider.to_string(),
2301 status: "unknown".to_string(),
2302 detail: "Unknown provider.".to_string(),
2303 exit_code: None,
2304 duration_ms: 0,
2305 stdout_chars: 0,
2306 stderr_chars: 0,
2307 timed_out: false,
2308 },
2309 skills: None,
2310 tools: None,
2311 detail: "Unknown provider.".to_string(),
2312 },
2313 }
2314}
2315
2316fn run_capability_probe(
2317 bin: &str,
2318 args: &[&str],
2319 cwd: &Path,
2320 cancel: Option<Arc<AtomicBool>>,
2321) -> CommandTelemetry {
2322 let args = args
2323 .iter()
2324 .map(|arg| (*arg).to_string())
2325 .collect::<Vec<_>>();
2326 command_telemetry(&run_command(
2327 CommandRequest::new(bin, &args, cwd, 20_000).cancel(cancel),
2328 ))
2329}
2330
2331fn emit_runtime_event(
2332 progress: &Option<ProgressSink>,
2333 fallback_to_stderr: bool,
2334 event: ProgressEvent,
2335) {
2336 if let Some(progress) = progress {
2337 progress(event);
2338 } else if fallback_to_stderr {
2339 eprintln!("{}", event.message);
2340 }
2341}
2342
2343impl RuntimeEventBus {
2344 fn new() -> Self {
2345 Self {
2346 started: Instant::now(),
2347 sequence: Arc::new(AtomicU64::new(1)),
2348 emit_lock: Arc::new(Mutex::new(())),
2349 sinks: Arc::new(Mutex::new(Vec::new())),
2350 }
2351 }
2352
2353 fn add_sink(&self, sink: RuntimeEventSink) {
2354 if let Ok(mut sinks) = self.sinks.lock() {
2355 sinks.push(sink);
2356 }
2357 }
2358
2359 fn progress_sink(&self) -> ProgressSink {
2360 let bus = self.clone();
2361 Arc::new(move |event| bus.emit(event, None, None))
2362 }
2363
2364 fn emit_result(&self, result: &AmonHenResult) {
2365 let status = if is_success(result) { "ok" } else { "error" };
2366 self.emit(
2367 ProgressEvent {
2368 kind: RuntimeEventKind::Result,
2369 provider: Some(result.summary.name.clone()),
2370 role: Some(result.summary.role.clone()),
2371 stage: ProgressStage::Done,
2372 status: Some(status.to_string()),
2373 iteration: Some(result.workflow.iterations),
2374 total_iterations: Some(result.workflow.iterations),
2375 is_sub_agent: false,
2376 duration_ms: Some(result.summary.duration_ms),
2377 token_usage: Some(result.summary.token_usage.clone()),
2378 tool_calls: result.summary.tool_calls.clone(),
2379 message: format!("[amon-hen] result {status}"),
2380 },
2381 Some(Box::new(result.clone())),
2382 None,
2383 );
2384 }
2385
2386 fn emit_linear_result(&self, result: &linear_delivery::LinearDeliveryResult) {
2387 let status = if result.success { "ok" } else { "error" };
2388 self.emit(
2389 progress_event_with_context(
2390 Some("linear"),
2391 Some("delivery"),
2392 ProgressStage::Done,
2393 Some(status),
2394 None,
2395 None,
2396 false,
2397 Some(result.duration_ms),
2398 None,
2399 vec![],
2400 format!("[amon-hen] linear delivery {status}"),
2401 ),
2402 None,
2403 Some(Box::new(result.clone())),
2404 );
2405 }
2406
2407 fn emit(
2408 &self,
2409 progress: ProgressEvent,
2410 result: Option<Box<AmonHenResult>>,
2411 linear_result: Option<Box<linear_delivery::LinearDeliveryResult>>,
2412 ) {
2413 let _emit_guard = self.emit_lock.lock().ok();
2414 let event = RuntimeEvent {
2415 sequence: self.sequence.fetch_add(1, Ordering::SeqCst),
2416 elapsed_ms: self.started.elapsed().as_millis(),
2417 progress,
2418 result,
2419 linear_result,
2420 };
2421 let sinks = self
2422 .sinks
2423 .lock()
2424 .map(|sinks| sinks.clone())
2425 .unwrap_or_default();
2426 for sink in sinks {
2427 sink(event.clone());
2428 }
2429 }
2430}
2431
2432fn progress_event(
2433 provider: Option<&str>,
2434 role: Option<&str>,
2435 stage: ProgressStage,
2436 status: Option<&str>,
2437 message: impl Into<String>,
2438) -> ProgressEvent {
2439 progress_event_with_context(
2440 provider,
2441 role,
2442 stage,
2443 status,
2444 None,
2445 None,
2446 false,
2447 None,
2448 None,
2449 vec![],
2450 message,
2451 )
2452}
2453
2454#[allow(clippy::too_many_arguments)]
2455fn progress_event_with_context(
2456 provider: Option<&str>,
2457 role: Option<&str>,
2458 stage: ProgressStage,
2459 status: Option<&str>,
2460 iteration: Option<usize>,
2461 total_iterations: Option<usize>,
2462 is_sub_agent: bool,
2463 duration_ms: Option<u128>,
2464 token_usage: Option<TokenUsage>,
2465 tool_calls: Vec<ToolUsage>,
2466 message: impl Into<String>,
2467) -> ProgressEvent {
2468 ProgressEvent {
2469 kind: runtime_event_kind(provider, stage, token_usage.as_ref(), &tool_calls),
2470 provider: provider.map(ToString::to_string),
2471 role: role.map(ToString::to_string),
2472 stage,
2473 status: status.map(ToString::to_string),
2474 iteration,
2475 total_iterations,
2476 is_sub_agent,
2477 duration_ms,
2478 token_usage,
2479 tool_calls,
2480 message: message.into(),
2481 }
2482}
2483
2484fn runtime_event_kind(
2485 provider: Option<&str>,
2486 stage: ProgressStage,
2487 token_usage: Option<&TokenUsage>,
2488 tool_calls: &[ToolUsage],
2489) -> RuntimeEventKind {
2490 if !tool_calls.is_empty() {
2491 RuntimeEventKind::ToolUsage
2492 } else if token_usage.is_some() {
2493 RuntimeEventKind::TokenUsage
2494 } else if provider.is_some() {
2495 RuntimeEventKind::Provider
2496 } else if matches!(stage, ProgressStage::Context) {
2497 RuntimeEventKind::Context
2498 } else {
2499 RuntimeEventKind::Iteration
2500 }
2501}
2502
2503fn command_progress(label: Option<&str>, sink: Option<ProgressSink>) -> Option<CommandProgress> {
2504 command_progress_with_input(label, sink, 0)
2505}
2506
2507fn command_progress_with_input(
2508 label: Option<&str>,
2509 sink: Option<ProgressSink>,
2510 input_tokens: usize,
2511) -> Option<CommandProgress> {
2512 label.map(|label| CommandProgress {
2513 label: label.to_string(),
2514 sink,
2515 input_tokens,
2516 })
2517}
2518
2519fn render_provider_capability_statuses(statuses: &[ProviderCapabilityStatus]) -> String {
2520 let mut lines = vec!["Provider capabilities".to_string()];
2521 for status in statuses {
2522 lines.push(format!(
2523 "- {} MCP: {} ({})",
2524 status.provider, status.mcp.status, status.mcp.command
2525 ));
2526 if !status.mcp.detail.trim().is_empty() {
2527 lines.push(format!(" {}", status.mcp.detail));
2528 }
2529 if let Some(skills) = &status.skills {
2530 lines.push(format!(
2531 " skills/plugins: {} ({})",
2532 skills.status, skills.command
2533 ));
2534 if !skills.detail.trim().is_empty() {
2535 lines.push(format!(" {}", skills.detail));
2536 }
2537 }
2538 if let Some(tools) = &status.tools {
2539 lines.push(format!(
2540 " tools/extensions: {} ({})",
2541 tools.status, tools.command
2542 ));
2543 if !tools.detail.trim().is_empty() {
2544 lines.push(format!(" {}", tools.detail));
2545 }
2546 }
2547 lines.push(format!(" {}", status.detail));
2548 }
2549 lines.join("\n")
2550}
2551
2552fn run_social_login(resolved: &ResolvedArgs) -> Result<(), String> {
2553 let providers = if resolved.raw.auth_login_providers.is_empty() {
2554 resolved.members.clone()
2555 } else {
2556 resolved.raw.auth_login_providers.clone()
2557 };
2558
2559 for provider in providers {
2560 validate_engine_name(&provider, false, "--auth-login-providers")?;
2561 let (bin, args, instruction): (String, Vec<String>, &str) =
2562 match provider.as_str() {
2563 "codex" => {
2564 let mut args = vec!["login".to_string()];
2565 if resolved.raw.auth_device_code {
2566 args.push("--device-auth".to_string());
2567 }
2568 (
2569 resolve_binary("codex"),
2570 args,
2571 "Complete the Codex browser login. Deeplinks and pasted codes are supported by the provider CLI when prompted.",
2572 )
2573 }
2574 "claude" => {
2575 let mut args = vec!["auth".to_string(), "login".to_string()];
2576 match resolved.raw.claude_login_mode.as_str() {
2577 "console" => args.push("--console".to_string()),
2578 "sso" => args.push("--sso".to_string()),
2579 _ => args.push("--claudeai".to_string()),
2580 }
2581 if let Some(email) = &resolved.raw.claude_login_email {
2582 push_arg(&mut args, "--email", email.clone());
2583 }
2584 (
2585 resolve_binary("claude"),
2586 args,
2587 "Complete the Claude browser login. Paste any shown login code into this terminal when prompted; deeplinks are opened when the CLI emits them.",
2588 )
2589 }
2590 "gemini" => (
2591 resolve_binary("gemini"),
2592 vec![],
2593 "Use the Gemini CLI auth selector, choose browser/social login, and complete local callback or code paste when prompted.",
2594 ),
2595 _ => unreachable!(),
2596 };
2597 eprintln!(
2598 "[auth] launching {provider}: {}",
2599 format_command(&bin, &args)
2600 );
2601 eprintln!("[auth] {provider}: {instruction}");
2602 let result = run_interactive_auth_command(
2603 &bin,
2604 &args,
2605 &resolved.cwd,
2606 resolved.raw.auth_timeout * 1000,
2607 !resolved.raw.no_auth_open_browser,
2608 &provider,
2609 );
2610 if result.code.unwrap_or(1) != 0 {
2611 return Err(format!(
2612 "{provider} social login failed: {}",
2613 compact_failure(&result)
2614 ));
2615 }
2616 let status = provider_auth_status(&provider, resolved);
2617 eprintln!("[auth] {provider}: {} ({})", status.status, status.detail);
2618 }
2619 Ok(())
2620}
2621
2622fn run_interactive_auth_command(
2623 command: &str,
2624 args: &[String],
2625 cwd: &Path,
2626 timeout_ms: u64,
2627 open_browser: bool,
2628 provider: &str,
2629) -> CommandResult {
2630 let started = Instant::now();
2631 let mut process = Command::new(command);
2632 process
2633 .args(args)
2634 .current_dir(cwd)
2635 .stdin(Stdio::inherit())
2636 .stdout(Stdio::piped())
2637 .stderr(Stdio::piped());
2638 configure_process_group(&mut process);
2639 let mut child = match process.spawn() {
2640 Ok(child) => child,
2641 Err(error) => {
2642 return CommandResult {
2643 command: command.to_string(),
2644 args: args.to_vec(),
2645 code: None,
2646 stdout: String::new(),
2647 stderr: String::new(),
2648 timed_out: false,
2649 cancelled: false,
2650 error: Some(error.to_string()),
2651 timeout_ms,
2652 duration_ms: started.elapsed().as_millis(),
2653 }
2654 }
2655 };
2656
2657 let seen_urls = Arc::new(Mutex::new(HashSet::new()));
2658 let stdout = child.stdout.take().map(|pipe| {
2659 read_auth_pipe(
2660 pipe,
2661 true,
2662 provider.to_string(),
2663 open_browser,
2664 Arc::clone(&seen_urls),
2665 )
2666 });
2667 let stderr = child.stderr.take().map(|pipe| {
2668 read_auth_pipe(
2669 pipe,
2670 false,
2671 provider.to_string(),
2672 open_browser,
2673 Arc::clone(&seen_urls),
2674 )
2675 });
2676 let timeout = Duration::from_millis(timeout_ms);
2677 let mut timed_out = false;
2678 let code;
2679 loop {
2680 match child.try_wait() {
2681 Ok(Some(status)) => {
2682 code = status.code();
2683 break;
2684 }
2685 Ok(None) => {
2686 if timeout_ms > 0 && started.elapsed() >= timeout {
2687 timed_out = true;
2688 terminate_child_tree(&mut child);
2689 let status = child.wait().ok();
2690 code = status.and_then(|status| status.code());
2691 break;
2692 }
2693 thread::sleep(Duration::from_millis(50));
2694 }
2695 Err(error) => {
2696 return CommandResult {
2697 command: command.to_string(),
2698 args: args.to_vec(),
2699 code: None,
2700 stdout: String::new(),
2701 stderr: String::new(),
2702 timed_out,
2703 cancelled: false,
2704 error: Some(error.to_string()),
2705 timeout_ms,
2706 duration_ms: started.elapsed().as_millis(),
2707 }
2708 }
2709 }
2710 }
2711
2712 CommandResult {
2713 command: command.to_string(),
2714 args: args.to_vec(),
2715 code,
2716 stdout: stdout
2717 .and_then(|handle| handle.join().ok())
2718 .unwrap_or_default(),
2719 stderr: stderr
2720 .and_then(|handle| handle.join().ok())
2721 .unwrap_or_default(),
2722 timed_out,
2723 cancelled: false,
2724 error: None,
2725 timeout_ms,
2726 duration_ms: started.elapsed().as_millis(),
2727 }
2728}
2729
2730fn read_auth_pipe<R>(
2731 mut pipe: R,
2732 stdout: bool,
2733 provider: String,
2734 open_browser: bool,
2735 seen_urls: Arc<Mutex<HashSet<String>>>,
2736) -> thread::JoinHandle<String>
2737where
2738 R: Read + Send + 'static,
2739{
2740 thread::spawn(move || {
2741 let mut text = String::new();
2742 let mut buffer = [0u8; 4096];
2743 loop {
2744 let read = match pipe.read(&mut buffer) {
2745 Ok(0) => break,
2746 Ok(read) => read,
2747 Err(_) => break,
2748 };
2749 let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
2750 text.push_str(&chunk);
2751 if stdout {
2752 print!("{chunk}");
2753 let _ = io::stdout().flush();
2754 } else {
2755 eprint!("{chunk}");
2756 let _ = io::stderr().flush();
2757 }
2758 if open_browser {
2759 for url in extract_auth_urls(&chunk) {
2760 let mut seen = seen_urls.lock().ok();
2761 if seen.as_mut().is_some_and(|seen| !seen.insert(url.clone())) {
2762 continue;
2763 }
2764 eprintln!("[auth] {provider}: opening {url}");
2765 if let Err(error) = open_browser_url(&url) {
2766 eprintln!("[auth] {provider}: failed to open {url}: {error}");
2767 }
2768 }
2769 }
2770 }
2771 text
2772 })
2773}
2774
2775fn extract_auth_urls(text: &str) -> Vec<String> {
2776 text.split_whitespace()
2777 .map(|token| {
2778 token.trim_matches(|ch: char| {
2779 matches!(ch, '<' | '>' | '"' | '\'' | ')' | '(' | ',' | ';')
2780 })
2781 })
2782 .map(|token| token.trim_end_matches(['.', ':', ',', ';']))
2783 .filter(|token| {
2784 token.starts_with("http://")
2785 || token.starts_with("https://")
2786 || token.starts_with("codex://")
2787 || token.starts_with("openai://")
2788 || token.starts_with("claude://")
2789 || token.starts_with("anthropic://")
2790 || token.starts_with("gemini://")
2791 || token.starts_with("google://")
2792 })
2793 .map(ToString::to_string)
2794 .collect()
2795}
2796
2797fn open_browser_url(url: &str) -> Result<(), String> {
2798 let (command, args): (&str, Vec<String>) = if cfg!(target_os = "macos") {
2799 ("open", vec![url.to_string()])
2800 } else if cfg!(target_os = "windows") {
2801 (
2802 "cmd",
2803 vec![
2804 "/c".to_string(),
2805 "start".to_string(),
2806 "".to_string(),
2807 url.to_string(),
2808 ],
2809 )
2810 } else {
2811 ("xdg-open", vec![url.to_string()])
2812 };
2813 Command::new(command)
2814 .args(args)
2815 .stdin(Stdio::null())
2816 .stdout(Stdio::null())
2817 .stderr(Stdio::null())
2818 .spawn()
2819 .map(|mut child| {
2820 let _ = child.try_wait();
2821 })
2822 .map_err(|error| error.to_string())
2823}
2824
2825fn build_prompt_context_with_progress(
2826 resolved: &ResolvedArgs,
2827 progress: Option<ProgressSink>,
2828) -> Result<PromptContext, String> {
2829 let mut prompt = resolved.prompt.trim().to_string();
2830 let mut sections = Vec::new();
2831 let mut commands = Vec::new();
2832
2833 for file in &resolved.raw.files {
2834 let path = if file.is_absolute() {
2835 file.clone()
2836 } else {
2837 resolved.cwd.join(file)
2838 };
2839 let content = fs::read_to_string(&path)
2840 .map_err(|error| format!("Failed to read tagged file {}: {error}", path.display()))?;
2841 sections.push(format!("--- file: {} ---\n{}", file.display(), content));
2842 }
2843
2844 for command in &resolved.raw.commands {
2845 let shell = if cfg!(windows) { "cmd" } else { "sh" };
2846 let args = if cfg!(windows) {
2847 vec!["/C".to_string(), command.clone()]
2848 } else {
2849 vec!["-lc".to_string(), command.clone()]
2850 };
2851 let live_label = (resolved.raw.verbose || progress.is_some())
2852 .then(|| format!("context command `{}`", truncate(command, 80)));
2853 emit_runtime_event(
2854 &progress,
2855 false,
2856 progress_event(
2857 None,
2858 None,
2859 ProgressStage::Context,
2860 None,
2861 format!("[amon-hen] context command `{}`", truncate(command, 80)),
2862 ),
2863 );
2864 let result = run_command(
2865 CommandRequest::new(shell, &args, &resolved.cwd, resolved.raw.timeout * 1000)
2866 .progress(command_progress(live_label.as_deref(), progress.clone())),
2867 );
2868 let telemetry = command_telemetry(&result);
2869 let mut event = progress_event(
2870 None,
2871 None,
2872 ProgressStage::Done,
2873 Some(&telemetry.status),
2874 format!(
2875 "[amon-hen] context command `{}` {} in {:.1}s",
2876 truncate(command, 80),
2877 telemetry.status,
2878 result.duration_ms as f64 / 1000.0
2879 ),
2880 );
2881 event.kind = RuntimeEventKind::Context;
2882 emit_runtime_event(&progress, false, event);
2883 commands.push(telemetry);
2884 sections.push(format!(
2885 "--- command: {} (exit {}) ---\nstdout:\n{}\nstderr:\n{}",
2886 command,
2887 result
2888 .code
2889 .map(|code| code.to_string())
2890 .unwrap_or_else(|| "unknown".to_string()),
2891 result.stdout.trim(),
2892 result.stderr.trim()
2893 ));
2894 }
2895
2896 if !sections.is_empty() {
2897 prompt.push_str("\n\nPrompt context:\n");
2898 prompt.push_str(§ions.join("\n\n"));
2899 }
2900 Ok(PromptContext { prompt, commands })
2901}
2902
2903fn run_amon_hen_with_progress(
2904 resolved: &ResolvedArgs,
2905 query: String,
2906 prompt_commands: Vec<CommandTelemetry>,
2907 progress: Option<ProgressSink>,
2908) -> AmonHenResult {
2909 run_amon_hen_with_progress_and_cancel(resolved, query, prompt_commands, progress, None)
2910}
2911
2912fn run_amon_hen_with_progress_and_cancel(
2913 resolved: &ResolvedArgs,
2914 query: String,
2915 prompt_commands: Vec<CommandTelemetry>,
2916 progress: Option<ProgressSink>,
2917 cancel: Option<Arc<AtomicBool>>,
2918) -> AmonHenResult {
2919 let workflow = build_workflow(resolved);
2920 let mut previous_iteration = Vec::new();
2921 let mut final_members = Vec::new();
2922 let mut iterations = Vec::new();
2923 let mut final_consensus = None;
2924
2925 for iteration in 1..=workflow.iterations {
2926 let iteration_started = Instant::now();
2927 let handoff_context =
2928 iteration_handoff_context(&previous_iteration, resolved.raw.max_member_chars);
2929 emit_runtime_event(
2930 &progress,
2931 resolved.raw.verbose,
2932 progress_event_with_context(
2933 None,
2934 None,
2935 ProgressStage::Start,
2936 None,
2937 Some(iteration),
2938 Some(workflow.iterations),
2939 false,
2940 None,
2941 None,
2942 vec![],
2943 format!(
2944 "[amon-hen] iteration {iteration}/{} started",
2945 workflow.iterations
2946 ),
2947 ),
2948 );
2949 final_members = run_iteration(
2950 resolved,
2951 &query,
2952 &workflow,
2953 iteration,
2954 &previous_iteration,
2955 progress.clone(),
2956 cancel.clone(),
2957 );
2958 let mut record = iteration_record(
2959 iteration,
2960 workflow.iterations,
2961 final_members.clone(),
2962 iteration_started.elapsed().as_millis(),
2963 handoff_context,
2964 None,
2965 );
2966 if consensus_enabled(resolved) {
2967 let consensus = run_consensus_loop(ConsensusRunInput {
2968 resolved,
2969 query: &query,
2970 workflow: &workflow,
2971 iteration,
2972 members: &final_members,
2973 prior_iterations: &iterations,
2974 progress: progress.clone(),
2975 cancel: cancel.clone(),
2976 });
2977 let approved = consensus.approved;
2978 record.consensus = Some(consensus.clone());
2979 final_consensus = Some(consensus);
2980 iterations.push(record);
2981 previous_iteration = final_members.clone();
2982 if approved && resolved.raw.stop_when == STOP_WHEN_CONSENSUS {
2983 break;
2984 }
2985 continue;
2986 }
2987 iterations.push(record);
2988 previous_iteration = final_members.clone();
2989 }
2990
2991 let successes = final_members
2992 .iter()
2993 .filter(|result| result.status == "ok")
2994 .cloned()
2995 .collect::<Vec<_>>();
2996 let summary = if successes.is_empty() {
2997 EngineResult {
2998 name: resolved.raw.summarizer.clone(),
2999 bin: None,
3000 status: "error".to_string(),
3001 duration_ms: 0,
3002 detail: "No Amon Hen member produced a response.".to_string(),
3003 exit_code: None,
3004 stdout: String::new(),
3005 stderr: String::new(),
3006 output: String::new(),
3007 command: String::new(),
3008 token_usage: token_usage("", ""),
3009 tool_calls: vec![],
3010 sub_agents: vec![],
3011 role: "summary".to_string(),
3012 iteration: workflow.iterations,
3013 total_iterations: workflow.iterations,
3014 team_size: 0,
3015 }
3016 } else {
3017 let summary_prompt =
3018 build_summary_prompt(&query, &successes, &workflow, resolved.raw.max_member_chars);
3019 if let Some(record) = iterations.last_mut() {
3020 record.summary_context = Some(truncate(&summary_prompt, resolved.raw.max_member_chars));
3021 }
3022 let summarizer = pick_summarizer(resolved, &successes);
3023 let mut options = engine_options(
3024 resolved,
3025 EngineOptionsInput {
3026 member: &summarizer,
3027 prompt: summary_prompt,
3028 role: "summary",
3029 iteration: workflow.iterations,
3030 workflow: &workflow,
3031 progress: progress.clone(),
3032 cancel: cancel.clone(),
3033 },
3034 );
3035 options.role = "summary".to_string();
3036 options.team_size = 0;
3037 run_engine(&summarizer, options)
3038 };
3039
3040 AmonHenResult {
3041 query,
3042 cwd: resolved.cwd.display().to_string(),
3043 members_requested: resolved.members.clone(),
3044 summarizer_requested: resolved.raw.summarizer.clone(),
3045 workflow,
3046 prompt_commands,
3047 iterations,
3048 members: final_members,
3049 summary,
3050 consensus: final_consensus,
3051 }
3052}
3053
3054fn build_workflow(resolved: &ResolvedArgs) -> Workflow {
3055 let mut teams = HashMap::new();
3056 for member in ENGINES {
3057 teams.insert(member.to_string(), resolved.raw.team_work);
3058 }
3059 if let Some(value) = resolved.raw.codex_sub_agents {
3060 teams.insert("codex".to_string(), value);
3061 }
3062 if let Some(value) = resolved.raw.claude_sub_agents {
3063 teams.insert("claude".to_string(), value);
3064 }
3065 if let Some(value) = resolved.raw.gemini_sub_agents {
3066 teams.insert("gemini".to_string(), value);
3067 }
3068 let mut workflow = Workflow {
3069 handoff: resolved.raw.handoff || resolved.raw.planner_mode == PLANNER_MODE_REVIEW_CHAIN,
3070 lead: resolved.raw.lead.clone(),
3071 planner: resolved.raw.planner.clone(),
3072 planner_mode: resolved.raw.planner_mode.clone(),
3073 owner_map: resolved.raw.owner_map.clone(),
3074 handshake: handshake_requested(&resolved.raw),
3075 handshake_agents: Vec::new(),
3076 iterations: resolved.raw.iterations.max(1),
3077 team_work: resolved.raw.team_work,
3078 teams,
3079 };
3080 workflow.handoff = workflow.handoff || workflow.handshake;
3081 if workflow.handshake {
3082 workflow.handshake_agents = build_handshake_agent_slots(resolved, &workflow);
3083 }
3084 workflow
3085}
3086
3087fn effective_team_size(workflow: &Workflow, member: &str) -> usize {
3088 *workflow.teams.get(member).unwrap_or(&workflow.team_work)
3089}
3090
3091fn build_handshake_agent_slots(resolved: &ResolvedArgs, workflow: &Workflow) -> Vec<AgentSlot> {
3092 let override_sub_agents =
3093 handshake_sub_agent_override(&resolved.raw.handshake_sub_agents).unwrap_or(None);
3094 let specs = resolved
3095 .raw
3096 .handshake_agents
3097 .iter()
3098 .filter_map(|value| parse_handshake_agent_spec(value).ok())
3099 .collect::<Vec<_>>();
3100 let specs = if specs.is_empty() {
3101 let provider = resolved
3102 .raw
3103 .handshake_provider
3104 .as_deref()
3105 .or(workflow.planner.as_deref())
3106 .or(workflow.lead.as_deref())
3107 .or_else(|| resolved.members.first().map(String::as_str))
3108 .unwrap_or("codex")
3109 .to_string();
3110 vec![
3111 HandshakeAgentSpec {
3112 role: "planner".to_string(),
3113 provider: provider.clone(),
3114 sub_agents: None,
3115 },
3116 HandshakeAgentSpec {
3117 role: "executor".to_string(),
3118 provider: provider.clone(),
3119 sub_agents: None,
3120 },
3121 HandshakeAgentSpec {
3122 role: "reviewer".to_string(),
3123 provider,
3124 sub_agents: None,
3125 },
3126 ]
3127 } else {
3128 specs
3129 };
3130
3131 specs
3132 .into_iter()
3133 .enumerate()
3134 .map(|(index, spec)| {
3135 let team_size = spec
3136 .sub_agents
3137 .or(override_sub_agents)
3138 .unwrap_or_else(|| effective_team_size(workflow, &spec.provider));
3139 AgentSlot {
3140 id: format!("{}-{}", spec.role, index + 1),
3141 provider: spec.provider,
3142 role: spec.role,
3143 team_size,
3144 }
3145 })
3146 .collect()
3147}
3148
3149fn iteration_record(
3150 iteration: usize,
3151 total_iterations: usize,
3152 members: Vec<EngineResult>,
3153 duration_ms: u128,
3154 handoff_context: Option<String>,
3155 summary_context: Option<String>,
3156) -> IterationRecord {
3157 let status = if members.iter().any(|member| member.status == "ok") {
3158 "ok"
3159 } else if members.iter().any(|member| member.status == "cancelled") {
3160 "cancelled"
3161 } else if members.iter().any(|member| member.status == "timeout") {
3162 "timeout"
3163 } else if members.iter().any(|member| member.status == "missing") {
3164 "missing"
3165 } else {
3166 "error"
3167 };
3168 let sub_agents = members
3169 .iter()
3170 .flat_map(|member| member.sub_agents.iter().cloned())
3171 .collect::<Vec<_>>();
3172 let token_usage = aggregate_results_token_usage(members.iter());
3173 let tool_calls = members
3174 .iter()
3175 .chain(sub_agents.iter())
3176 .flat_map(|member| member.tool_calls.iter().cloned())
3177 .collect::<Vec<_>>();
3178 IterationRecord {
3179 iteration,
3180 total_iterations,
3181 status: status.to_string(),
3182 duration_ms,
3183 members,
3184 sub_agents,
3185 handoff_context,
3186 summary_context,
3187 consensus: None,
3188 token_usage,
3189 tool_calls,
3190 }
3191}
3192
3193fn iteration_handoff_context(
3194 previous_iteration: &[EngineResult],
3195 max_member_chars: usize,
3196) -> Option<String> {
3197 let context = previous_iteration
3198 .iter()
3199 .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
3200 .map(|result| {
3201 let output = bounded_context_text(result.output.trim(), max_member_chars, "handoff");
3202 format!("### {} ({})\n{}", result.name, result.role, output)
3203 })
3204 .collect::<Vec<_>>()
3205 .join("\n\n");
3206 (!context.is_empty()).then_some(context)
3207}
3208
3209fn consensus_enabled(resolved: &ResolvedArgs) -> bool {
3210 resolved.raw.consensus == CONSENSUS_REQUIRED
3211}
3212
3213fn consensus_config(resolved: &ResolvedArgs) -> ConsensusConfig {
3214 let reviewers = if resolved.raw.consensus_reviewers.is_empty() {
3215 resolved.members.clone()
3216 } else {
3217 resolved
3218 .raw
3219 .consensus_reviewers
3220 .iter()
3221 .map(|reviewer| reviewer.trim().to_string())
3222 .filter(|reviewer| !reviewer.is_empty())
3223 .collect()
3224 };
3225 ConsensusConfig {
3226 mode: resolved.raw.consensus.clone(),
3227 reviewers,
3228 failure_policy: resolved.raw.failure_policy.clone(),
3229 review_rounds: resolved.raw.review_rounds.max(1),
3230 require_final_diff_review: resolved.raw.require_final_diff_review,
3231 require_tests: resolved.raw.require_tests,
3232 require_secret_scan: resolved.raw.require_secret_scan,
3233 require_clean_git_diff: resolved.raw.require_clean_git_diff,
3234 stop_when: resolved.raw.stop_when.clone(),
3235 owner_map: resolved.raw.owner_map.clone(),
3236 }
3237}
3238
3239struct ConsensusRunInput<'a> {
3240 resolved: &'a ResolvedArgs,
3241 query: &'a str,
3242 workflow: &'a Workflow,
3243 iteration: usize,
3244 members: &'a [EngineResult],
3245 prior_iterations: &'a [IterationRecord],
3246 progress: Option<ProgressSink>,
3247 cancel: Option<Arc<AtomicBool>>,
3248}
3249
3250fn run_consensus_loop(input: ConsensusRunInput<'_>) -> ConsensusResult {
3251 let resolved = input.resolved;
3252 let workflow = input.workflow;
3253 let iteration = input.iteration;
3254 let config = consensus_config(resolved);
3255 let failed_members = input
3256 .members
3257 .iter()
3258 .filter(|member| member.status != "ok")
3259 .map(|member| member.name.clone())
3260 .collect::<Vec<_>>();
3261 let takeover_required =
3262 config.failure_policy == FAILURE_POLICY_TAKEOVER && !failed_members.is_empty();
3263 let mut rounds = Vec::new();
3264 let mut final_blockers = Vec::new();
3265 let mut final_takeover_by = Vec::new();
3266
3267 for round in 1..=config.review_rounds {
3268 emit_runtime_event(
3269 &input.progress,
3270 resolved.raw.verbose,
3271 progress_event_with_context(
3272 None,
3273 None,
3274 ProgressStage::Start,
3275 Some("consensus"),
3276 Some(iteration),
3277 Some(workflow.iterations),
3278 false,
3279 None,
3280 None,
3281 vec![],
3282 format!(
3283 "[amon-hen] consensus round {round}/{} started",
3284 config.review_rounds
3285 ),
3286 ),
3287 );
3288 let checks = run_consensus_checks(
3289 resolved,
3290 &config,
3291 input.progress.clone(),
3292 input.cancel.clone(),
3293 );
3294 let mut blockers = consensus_check_blockers(&checks);
3295 let mut reviews = Vec::new();
3296 let mut round_takeover_by = Vec::new();
3297 let mut prior_reviews = Vec::new();
3298
3299 for reviewer in &config.reviewers {
3300 let review = run_consensus_reviewer(ConsensusReviewerInput {
3301 resolved,
3302 query: input.query,
3303 workflow,
3304 iteration,
3305 round,
3306 config: &config,
3307 members: input.members,
3308 prior_iterations: input.prior_iterations,
3309 checks: &checks,
3310 prior_reviews: &prior_reviews,
3311 failed_members: &failed_members,
3312 progress: input.progress.clone(),
3313 cancel: input.cancel.clone(),
3314 reviewer,
3315 });
3316 if review.approved {
3317 for takeover in &review.takeover_for {
3318 if failed_members.iter().any(|failed| failed == takeover)
3319 && !round_takeover_by.iter().any(|seen| seen == reviewer)
3320 {
3321 round_takeover_by.push(reviewer.clone());
3322 }
3323 }
3324 }
3325 if review.status != "ok" {
3326 blockers.push(format!(
3327 "{} consensus review failed with status {}",
3328 reviewer, review.status
3329 ));
3330 } else if !review.approved {
3331 blockers.push(format!("{reviewer} did not approve consensus"));
3332 }
3333 blockers.extend(
3334 review
3335 .blockers
3336 .iter()
3337 .map(|blocker| format!("{reviewer}: {blocker}")),
3338 );
3339 prior_reviews.push(review.result.clone());
3340 reviews.push(review);
3341 }
3342
3343 if takeover_required && round_takeover_by.is_empty() {
3344 blockers.push(format!(
3345 "failure-policy takeover requires a successful reviewer to take over failed agents: {}",
3346 failed_members.join(",")
3347 ));
3348 }
3349 if config.owner_map == OWNER_MAP_STRICT
3350 && !reviews
3351 .iter()
3352 .any(|review| review.approved && review.status == "ok")
3353 {
3354 blockers.push(
3355 "owner-map strict requires at least one successful approving reviewer".to_string(),
3356 );
3357 }
3358 blockers.sort();
3359 blockers.dedup();
3360 round_takeover_by.sort();
3361 round_takeover_by.dedup();
3362 let approved = blockers.is_empty()
3363 && reviews.len() == config.reviewers.len()
3364 && reviews
3365 .iter()
3366 .all(|review| review.approved && review.status == "ok");
3367 let status = if approved { "approved" } else { "blocked" }.to_string();
3368 emit_runtime_event(
3369 &input.progress,
3370 resolved.raw.verbose,
3371 progress_event_with_context(
3372 None,
3373 None,
3374 ProgressStage::Done,
3375 Some(&status),
3376 Some(iteration),
3377 Some(workflow.iterations),
3378 false,
3379 None,
3380 None,
3381 vec![],
3382 format!("[amon-hen] consensus round {round} {status}"),
3383 ),
3384 );
3385 final_blockers = blockers.clone();
3386 final_takeover_by = round_takeover_by.clone();
3387 rounds.push(ConsensusRound {
3388 round,
3389 status: status.clone(),
3390 checks,
3391 reviews,
3392 blockers,
3393 takeover_by: round_takeover_by,
3394 });
3395 if approved {
3396 break;
3397 }
3398 }
3399
3400 let approved = rounds
3401 .last()
3402 .is_some_and(|round| round.status == "approved");
3403 ConsensusResult {
3404 status: if approved { "approved" } else { "blocked" }.to_string(),
3405 iteration,
3406 approved,
3407 config,
3408 rounds,
3409 blockers: final_blockers,
3410 takeover_required,
3411 takeover_by: final_takeover_by,
3412 }
3413}
3414
3415struct ConsensusReviewerInput<'a> {
3416 resolved: &'a ResolvedArgs,
3417 query: &'a str,
3418 workflow: &'a Workflow,
3419 iteration: usize,
3420 round: usize,
3421 config: &'a ConsensusConfig,
3422 members: &'a [EngineResult],
3423 prior_iterations: &'a [IterationRecord],
3424 checks: &'a [ConsensusCheck],
3425 prior_reviews: &'a [EngineResult],
3426 failed_members: &'a [String],
3427 progress: Option<ProgressSink>,
3428 cancel: Option<Arc<AtomicBool>>,
3429 reviewer: &'a str,
3430}
3431
3432fn run_consensus_reviewer(input: ConsensusReviewerInput<'_>) -> ConsensusReview {
3433 let resolved = input.resolved;
3434 let iteration = input.iteration;
3435 let round = input.round;
3436 let reviewer = input.reviewer;
3437 let role = format!("consensus-reviewer-round-{round}");
3438 let prompt = build_consensus_review_prompt(ConsensusPromptInput {
3439 query: input.query,
3440 workflow: input.workflow,
3441 iteration,
3442 round,
3443 config: input.config,
3444 members: input.members,
3445 prior_iterations: input.prior_iterations,
3446 checks: input.checks,
3447 prior_reviews: input.prior_reviews,
3448 failed_members: input.failed_members,
3449 max_prompt_chars: resolved.raw.max_prompt_chars,
3450 reviewer,
3451 });
3452 let mut options = engine_options(
3453 resolved,
3454 EngineOptionsInput {
3455 member: reviewer,
3456 prompt,
3457 role: &role,
3458 iteration,
3459 workflow: input.workflow,
3460 progress: input.progress,
3461 cancel: input.cancel,
3462 },
3463 );
3464 options.team_size = 0;
3465 let result = run_engine(reviewer, options);
3466 let approved = result.status == "ok" && consensus_output_approves(&result.output);
3467 let blockers = consensus_output_blockers(&result.output);
3468 let takeover_for = consensus_output_takeover_for(&result.output);
3469 ConsensusReview {
3470 provider: reviewer.to_string(),
3471 role,
3472 status: result.status.clone(),
3473 approved,
3474 blockers,
3475 takeover_for,
3476 result,
3477 }
3478}
3479
3480struct ConsensusPromptInput<'a> {
3481 query: &'a str,
3482 workflow: &'a Workflow,
3483 iteration: usize,
3484 round: usize,
3485 config: &'a ConsensusConfig,
3486 members: &'a [EngineResult],
3487 prior_iterations: &'a [IterationRecord],
3488 checks: &'a [ConsensusCheck],
3489 prior_reviews: &'a [EngineResult],
3490 failed_members: &'a [String],
3491 max_prompt_chars: usize,
3492 reviewer: &'a str,
3493}
3494
3495fn build_consensus_review_prompt(input: ConsensusPromptInput<'_>) -> String {
3496 let mut sections = vec![
3497 "You are an Amon Hen consensus reviewer in a fail-closed agent-to-agent review loop."
3498 .to_string(),
3499 format!(
3500 "Reviewer: {}. Consensus round: {}. Iteration: {}/{}.",
3501 input.reviewer, input.round, input.iteration, input.workflow.iterations
3502 ),
3503 format!(
3504 "Consensus policy: mode={}, failure_policy={}, owner_map={}, require_final_diff_review={}, require_tests={}, require_secret_scan={}, require_clean_git_diff={}.",
3505 input.config.mode,
3506 input.config.failure_policy,
3507 input.config.owner_map,
3508 input.config.require_final_diff_review,
3509 input.config.require_tests,
3510 input.config.require_secret_scan,
3511 input.config.require_clean_git_diff,
3512 ),
3513 "Your job is to review the actual agent outputs, current repository evidence, and prior reviewer notes. Approve only if the implementation is safe, tested, reviewable, and no required evidence is missing.".to_string(),
3514 "If the failure policy is takeover and an agent failed, explicitly take over only if you reviewed the failed scope and can stand behind the final state.".to_string(),
3515 "If owner-map is strict, block unless ownership is clear and changes do not create uncontrolled overlap.".to_string(),
3516 ];
3517 if !input.failed_members.is_empty() {
3518 sections.push(format!(
3519 "Failed agents requiring takeover consideration: {}",
3520 input.failed_members.join(",")
3521 ));
3522 }
3523 sections.push(format!("Original user query:\n{}", input.query.trim()));
3524 let member_blocks = input
3525 .members
3526 .iter()
3527 .map(|member| {
3528 format!(
3529 "### {} ({}) status={}\nDetail: {}\nOutput:\n{}",
3530 member.name,
3531 member.role,
3532 member.status,
3533 sanitize_status_detail(&member.detail),
3534 bounded_context_text(
3535 &member.output,
3536 input.config_review_budget(),
3537 "member output"
3538 )
3539 )
3540 })
3541 .collect::<Vec<_>>()
3542 .join("\n\n");
3543 sections.push(format!("Current iteration agent outputs:\n{member_blocks}"));
3544 if !input.prior_iterations.is_empty() {
3545 let prior = input
3546 .prior_iterations
3547 .iter()
3548 .filter_map(|iteration| iteration.consensus.as_ref())
3549 .map(|consensus| {
3550 format!(
3551 "iteration {} consensus={} blockers={}",
3552 consensus.iteration,
3553 consensus.status,
3554 consensus.blockers.join(" | ")
3555 )
3556 })
3557 .collect::<Vec<_>>()
3558 .join("\n");
3559 if !prior.trim().is_empty() {
3560 sections.push(format!("Prior consensus rounds:\n{prior}"));
3561 }
3562 }
3563 if !input.prior_reviews.is_empty() {
3564 let reviews = input
3565 .prior_reviews
3566 .iter()
3567 .map(|review| {
3568 format!(
3569 "### {} ({}) status={}\n{}",
3570 review.name,
3571 review.role,
3572 review.status,
3573 bounded_context_text(
3574 &review.output,
3575 input.config_review_budget(),
3576 "review output"
3577 )
3578 )
3579 })
3580 .collect::<Vec<_>>()
3581 .join("\n\n");
3582 sections.push(format!(
3583 "Earlier reviewer handoffs in this round:\n{reviews}"
3584 ));
3585 }
3586 let checks = input
3587 .checks
3588 .iter()
3589 .map(|check| {
3590 format!(
3591 "### {} [{}]\ncommand: {}\ndetail: {}\nstdout:\n{}\nstderr:\n{}",
3592 check.name,
3593 check.command.status,
3594 check.command.command,
3595 check.command.detail,
3596 bounded_context_text(&check.stdout, 6_000, "check stdout"),
3597 bounded_context_text(&check.stderr, 3_000, "check stderr")
3598 )
3599 })
3600 .collect::<Vec<_>>()
3601 .join("\n\n");
3602 sections.push(format!("Required evidence checks:\n{checks}"));
3603 sections.push(
3604 "Return your review with concrete findings. End with exactly these machine-readable lines:\n\
3605 CONSENSUS: approve|block\n\
3606 BLOCKERS: comma-separated blockers or none\n\
3607 TAKEOVER_FOR: comma-separated failed providers you explicitly take over or none"
3608 .to_string(),
3609 );
3610 limit_provider_prompt(§ions.join("\n\n"), input.max_prompt_chars)
3611}
3612
3613trait ConsensusPromptBudget {
3614 fn config_review_budget(&self) -> usize;
3615}
3616
3617impl ConsensusPromptBudget for ConsensusPromptInput<'_> {
3618 fn config_review_budget(&self) -> usize {
3619 12_000
3620 }
3621}
3622
3623fn run_consensus_checks(
3624 resolved: &ResolvedArgs,
3625 config: &ConsensusConfig,
3626 progress: Option<ProgressSink>,
3627 cancel: Option<Arc<AtomicBool>>,
3628) -> Vec<ConsensusCheck> {
3629 let mut checks = Vec::new();
3630 if config.require_clean_git_diff {
3631 checks.push(run_consensus_shell_check(
3632 resolved,
3633 "clean-git-diff",
3634 "git diff --check && git status --short && git diff --stat",
3635 progress.clone(),
3636 cancel.clone(),
3637 ));
3638 }
3639 if config.require_secret_scan {
3640 let command = secret_scan_command();
3641 checks.push(run_consensus_shell_check(
3642 resolved,
3643 "secret-scan",
3644 &command,
3645 progress.clone(),
3646 cancel.clone(),
3647 ));
3648 }
3649 if config.require_tests {
3650 checks.push(run_consensus_shell_check(
3651 resolved,
3652 "tests",
3653 "if [ -f Makefile ] && grep -Eq '(^|^[[:space:]]*)test:' Makefile; then make test; elif [ -f Cargo.toml ]; then cargo test --workspace --locked; elif [ -f package.json ]; then npm test; elif [ -f pyproject.toml ] || [ -d tests ]; then python -m pytest; else echo 'No standard test command detected'; exit 1; fi",
3654 progress.clone(),
3655 cancel.clone(),
3656 ));
3657 }
3658 if config.require_final_diff_review {
3659 checks.push(run_consensus_shell_check(
3660 resolved,
3661 "final-diff-review",
3662 "git status --short && git diff --stat && git diff --name-only",
3663 progress,
3664 cancel,
3665 ));
3666 }
3667 checks
3668}
3669
3670fn secret_scan_command() -> String {
3671 let sensitive_patterns = [
3672 format!("{}{}", "sk-", "ant"),
3673 "AQ[.]Ab".to_string(),
3674 format!("{}{}", "lin_", "api"),
3675 format!("{}{}", "cfat", "_"),
3676 format!("{}{}", "OPEN", "SSH"),
3677 format!("{}{}", "Digital", "Ocean"),
3678 "104[.]248".to_string(),
3679 format!("{}{}", "cf", "@"),
3680 format!("{}{}", "e132", "cc"),
3681 "/Users/[^[:space:]]+".to_string(),
3682 format!("{}{}", "Downloads/", "council"),
3683 format!("{}{}", "node ", "/Users"),
3684 ]
3685 .join("|");
3686
3687 format!(
3688 "if rg -n '{sensitive_patterns}' -g '!target/**' -g '!web/node_modules/**' -g '!web/dist/**' -g '!web/.astro/**' .; then echo 'sensitive pattern scan found matches'; exit 1; else code=$?; if [ \"$code\" -eq 1 ]; then echo 'sensitive pattern scan clean'; exit 0; else exit \"$code\"; fi; fi"
3689 )
3690}
3691
3692fn run_consensus_shell_check(
3693 resolved: &ResolvedArgs,
3694 name: &str,
3695 command: &str,
3696 progress: Option<ProgressSink>,
3697 cancel: Option<Arc<AtomicBool>>,
3698) -> ConsensusCheck {
3699 let shell = if cfg!(windows) { "cmd" } else { "sh" };
3700 let args = if cfg!(windows) {
3701 vec!["/C".to_string(), command.to_string()]
3702 } else {
3703 vec!["-lc".to_string(), command.to_string()]
3704 };
3705 emit_runtime_event(
3706 &progress,
3707 resolved.raw.verbose,
3708 progress_event(
3709 None,
3710 None,
3711 ProgressStage::Context,
3712 Some("consensus-check"),
3713 format!("[amon-hen] consensus check `{name}`"),
3714 ),
3715 );
3716 let result = run_command(
3717 CommandRequest::new(shell, &args, &resolved.cwd, resolved.raw.timeout * 1000)
3718 .progress(command_progress(
3719 Some(&format!("consensus check `{name}`")),
3720 progress,
3721 ))
3722 .cancel(cancel),
3723 );
3724 ConsensusCheck {
3725 name: name.to_string(),
3726 command: command_telemetry(&result),
3727 stdout: truncate(&sanitize_status_detail(&result.stdout), 20_000),
3728 stderr: truncate(&sanitize_status_detail(&result.stderr), 10_000),
3729 }
3730}
3731
3732fn consensus_check_blockers(checks: &[ConsensusCheck]) -> Vec<String> {
3733 checks
3734 .iter()
3735 .filter(|check| check.command.status != "ok")
3736 .map(|check| {
3737 format!(
3738 "{} check failed: {}",
3739 check.name,
3740 sanitize_status_detail(&check.command.detail)
3741 )
3742 })
3743 .collect()
3744}
3745
3746fn consensus_output_approves(output: &str) -> bool {
3747 output
3748 .lines()
3749 .rev()
3750 .map(|line| line.trim().to_ascii_lowercase())
3751 .any(|line| line == "consensus: approve" || line == "consensus: approved")
3752}
3753
3754fn consensus_output_blockers(output: &str) -> Vec<String> {
3755 output
3756 .lines()
3757 .filter_map(|line| line.trim().strip_prefix("BLOCKERS:"))
3758 .flat_map(|value| value.split(','))
3759 .map(str::trim)
3760 .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("none"))
3761 .map(ToString::to_string)
3762 .collect()
3763}
3764
3765fn consensus_output_takeover_for(output: &str) -> Vec<String> {
3766 output
3767 .lines()
3768 .filter_map(|line| line.trim().strip_prefix("TAKEOVER_FOR:"))
3769 .flat_map(|value| value.split(','))
3770 .map(str::trim)
3771 .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("none"))
3772 .map(ToString::to_string)
3773 .collect()
3774}
3775
3776fn run_iteration(
3777 resolved: &ResolvedArgs,
3778 query: &str,
3779 workflow: &Workflow,
3780 iteration: usize,
3781 previous_iteration: &[EngineResult],
3782 progress: Option<ProgressSink>,
3783 cancel: Option<Arc<AtomicBool>>,
3784) -> Vec<EngineResult> {
3785 if workflow.handshake {
3786 return run_handshake_iteration(
3787 resolved,
3788 query,
3789 workflow,
3790 iteration,
3791 previous_iteration,
3792 progress,
3793 cancel,
3794 );
3795 }
3796
3797 let ordered = execution_order(
3798 &resolved.members,
3799 workflow.planner.as_deref(),
3800 workflow.lead.as_deref(),
3801 );
3802 if workflow.handoff && planner_mode_runs_serial(workflow) {
3803 let mut results = Vec::new();
3804 for member in ordered {
3805 let role = role_for(&member, workflow);
3806 let team_size = effective_team_size(workflow, &member);
3807 let prompt = build_member_prompt(MemberPromptInput {
3808 query,
3809 role: &role,
3810 workflow,
3811 iteration,
3812 team_size,
3813 max_member_chars: resolved.raw.max_member_chars,
3814 max_prompt_chars: resolved.raw.max_prompt_chars,
3815 previous_iteration,
3816 handoff_results: &results,
3817 plan_output: "",
3818 });
3819 let options = engine_options(
3820 resolved,
3821 EngineOptionsInput {
3822 member: &member,
3823 prompt,
3824 role: &role,
3825 iteration,
3826 workflow,
3827 progress: progress.clone(),
3828 cancel: cancel.clone(),
3829 },
3830 );
3831 results.push(run_engine(&member, options));
3832 }
3833 return results;
3834 }
3835
3836 let planner = workflow
3837 .planner
3838 .as_ref()
3839 .filter(|planner| ordered.contains(planner))
3840 .cloned();
3841 let mut results = Vec::new();
3842 let mut plan_output = String::new();
3843 let blocking_planner = workflow.planner_mode == PLANNER_MODE_BLOCKING;
3844 if let Some(planner_name) = planner.as_ref().filter(|_| blocking_planner) {
3845 let role = role_for(planner_name, workflow);
3846 let team_size = effective_team_size(workflow, planner_name);
3847 let prompt = build_member_prompt(MemberPromptInput {
3848 query,
3849 role: &role,
3850 workflow,
3851 iteration,
3852 team_size,
3853 max_member_chars: resolved.raw.max_member_chars,
3854 max_prompt_chars: resolved.raw.max_prompt_chars,
3855 previous_iteration,
3856 handoff_results: &[],
3857 plan_output: "",
3858 });
3859 let options = engine_options(
3860 resolved,
3861 EngineOptionsInput {
3862 member: planner_name,
3863 prompt,
3864 role: &role,
3865 iteration,
3866 workflow,
3867 progress: progress.clone(),
3868 cancel: cancel.clone(),
3869 },
3870 );
3871 let result = run_engine(planner_name, options);
3872 if result.status == "ok" {
3873 plan_output = result.output.clone();
3874 }
3875 results.push(result);
3876 }
3877
3878 let (tx, rx) = mpsc::channel();
3879 let executors = ordered
3880 .into_iter()
3881 .filter(|member| !(blocking_planner && Some(member) == planner.as_ref()))
3882 .collect::<Vec<_>>();
3883 let resolved = Arc::new(resolved.clone());
3884 let workflow = Arc::new(workflow.clone());
3885 let previous_iteration = Arc::new(previous_iteration.to_vec());
3886 let plan_output = Arc::new(plan_output);
3887 let progress = Arc::new(progress);
3888 let cancel = Arc::new(cancel);
3889 for member in executors.clone() {
3890 let tx = tx.clone();
3891 let resolved = Arc::clone(&resolved);
3892 let workflow = Arc::clone(&workflow);
3893 let query = query.to_string();
3894 let previous_iteration = Arc::clone(&previous_iteration);
3895 let plan_output = Arc::clone(&plan_output);
3896 let progress = Arc::clone(&progress);
3897 let cancel = Arc::clone(&cancel);
3898 thread::spawn(move || {
3899 let role = role_for(&member, &workflow);
3900 let team_size = effective_team_size(&workflow, &member);
3901 let prompt = build_member_prompt(MemberPromptInput {
3902 query: &query,
3903 role: &role,
3904 workflow: &workflow,
3905 iteration,
3906 team_size,
3907 max_member_chars: resolved.raw.max_member_chars,
3908 max_prompt_chars: resolved.raw.max_prompt_chars,
3909 previous_iteration: previous_iteration.as_slice(),
3910 handoff_results: &[],
3911 plan_output: &plan_output,
3912 });
3913 let options = engine_options(
3914 &resolved,
3915 EngineOptionsInput {
3916 member: &member,
3917 prompt,
3918 role: &role,
3919 iteration,
3920 workflow: &workflow,
3921 progress: (*progress).clone(),
3922 cancel: (*cancel).clone(),
3923 },
3924 );
3925 let _ = tx.send(run_engine(&member, options));
3926 });
3927 }
3928 drop(tx);
3929 let mut executor_results = rx.into_iter().collect::<Vec<_>>();
3930 executor_results.sort_by_key(|result| {
3931 executors
3932 .iter()
3933 .position(|member| member == &result.name)
3934 .unwrap_or(usize::MAX)
3935 });
3936 results.extend(executor_results);
3937 results
3938}
3939
3940fn run_handshake_iteration(
3941 resolved: &ResolvedArgs,
3942 query: &str,
3943 workflow: &Workflow,
3944 iteration: usize,
3945 previous_iteration: &[EngineResult],
3946 progress: Option<ProgressSink>,
3947 cancel: Option<Arc<AtomicBool>>,
3948) -> Vec<EngineResult> {
3949 let mut results = Vec::new();
3950 for slot in &workflow.handshake_agents {
3951 let prompt = build_member_prompt(MemberPromptInput {
3952 query,
3953 role: &slot.role,
3954 workflow,
3955 iteration,
3956 team_size: slot.team_size,
3957 max_member_chars: resolved.raw.max_member_chars,
3958 max_prompt_chars: resolved.raw.max_prompt_chars,
3959 previous_iteration,
3960 handoff_results: &results,
3961 plan_output: "",
3962 });
3963 let mut options = engine_options(
3964 resolved,
3965 EngineOptionsInput {
3966 member: &slot.provider,
3967 prompt,
3968 role: &slot.role,
3969 iteration,
3970 workflow,
3971 progress: progress.clone(),
3972 cancel: cancel.clone(),
3973 },
3974 );
3975 options.team_size = slot.team_size;
3976 results.push(run_engine(&slot.provider, options));
3977 }
3978 results
3979}
3980
3981fn planner_mode_runs_serial(workflow: &Workflow) -> bool {
3982 matches!(
3983 workflow.planner_mode.as_str(),
3984 PLANNER_MODE_BLOCKING | PLANNER_MODE_REVIEW_CHAIN | PLANNER_MODE_HANDSHAKE
3985 )
3986}
3987
3988fn engine_options(resolved: &ResolvedArgs, input: EngineOptionsInput<'_>) -> EngineRunOptions {
3989 EngineRunOptions {
3990 prompt: limit_provider_prompt(&input.prompt, resolved.raw.max_prompt_chars),
3991 max_prompt_chars: resolved.raw.max_prompt_chars,
3992 max_member_chars: resolved.raw.max_member_chars,
3993 cwd: resolved.cwd.clone(),
3994 timeout_ms: resolved.raw.timeout * 1000,
3995 effort: provider_effort(resolved, input.member),
3996 model: provider_model(resolved, input.member),
3997 permission: provider_permission(resolved, input.member),
3998 auth: provider_auth(resolved, input.member),
3999 capability: provider_capability(resolved, input.member),
4000 role: input.role.to_string(),
4001 iteration: input.iteration,
4002 total_iterations: input.workflow.iterations,
4003 team_size: effective_team_size(input.workflow, input.member),
4004 is_sub_agent: false,
4005 live: resolved.raw.verbose || input.progress.is_some(),
4006 progress: input.progress,
4007 cancel: input.cancel,
4008 }
4009}
4010
4011fn provider_effort(resolved: &ResolvedArgs, member: &str) -> Option<String> {
4012 provider_option(
4013 member,
4014 &resolved.raw.codex_effort,
4015 &resolved.raw.claude_effort,
4016 &resolved.raw.gemini_effort,
4017 )
4018 .or_else(|| {
4019 resolved
4020 .raw
4021 .effort
4022 .map(|effort| effort.as_str().to_string())
4023 })
4024}
4025
4026fn provider_model(resolved: &ResolvedArgs, member: &str) -> Option<String> {
4027 provider_option(
4028 member,
4029 &resolved.raw.codex_model,
4030 &resolved.raw.claude_model,
4031 &resolved.raw.gemini_model,
4032 )
4033}
4034
4035fn provider_permission(resolved: &ResolvedArgs, member: &str) -> Option<String> {
4036 match Engine::parse(member) {
4037 Some(Engine::Codex) => Some(resolved.raw.codex_sandbox.clone()),
4038 Some(Engine::Claude) => Some(resolved.raw.claude_permission_mode.clone()),
4039 Some(Engine::Gemini) => Some(resolved.raw.gemini_approval_mode.clone()),
4040 None => None,
4041 }
4042}
4043
4044fn provider_auth(resolved: &ResolvedArgs, member: &str) -> String {
4045 match Engine::parse(member) {
4046 Some(Engine::Codex) => resolved.raw.codex_auth.clone(),
4047 Some(Engine::Claude) => resolved.raw.claude_auth.clone(),
4048 Some(Engine::Gemini) => resolved.raw.gemini_auth.clone(),
4049 None => DEFAULT_AUTH_MODE.to_string(),
4050 }
4051}
4052
4053fn provider_capability(resolved: &ResolvedArgs, member: &str) -> ProviderCapability {
4054 match Engine::parse(member).expect("provider capabilities use validated engines") {
4055 Engine::Codex => ProviderCapability {
4056 mode: inferred_capability_mode(
4057 &resolved.raw.codex_capabilities,
4058 !resolved.raw.codex_config.is_empty() || resolved.raw.codex_mcp_profile.is_some(),
4059 ),
4060 config: resolved.raw.codex_config.clone(),
4061 mcp_profile: resolved.raw.codex_mcp_profile.clone(),
4062 mcp_config: vec![],
4063 allowed_tools: vec![],
4064 disallowed_tools: vec![],
4065 tools: vec![],
4066 agent: None,
4067 agents_json: None,
4068 plugin_dirs: vec![],
4069 strict_mcp_config: false,
4070 disable_slash_commands: false,
4071 settings: None,
4072 tools_profile: vec![],
4073 allowed_mcp_servers: vec![],
4074 policy: vec![],
4075 admin_policy: vec![],
4076 },
4077 Engine::Claude => ProviderCapability {
4078 mode: inferred_capability_mode(
4079 &resolved.raw.claude_capabilities,
4080 !resolved.raw.claude_mcp_config.is_empty()
4081 || !resolved.raw.claude_allowed_tools.is_empty()
4082 || !resolved.raw.claude_disallowed_tools.is_empty()
4083 || !resolved.raw.claude_tools.is_empty()
4084 || resolved.raw.claude_agent.is_some()
4085 || resolved.raw.claude_agents_json.is_some()
4086 || !resolved.raw.claude_plugin_dir.is_empty()
4087 || resolved.raw.claude_strict_mcp_config
4088 || resolved.raw.claude_disable_slash_commands,
4089 ),
4090 config: vec![],
4091 mcp_profile: None,
4092 mcp_config: resolved.raw.claude_mcp_config.clone(),
4093 allowed_tools: resolved.raw.claude_allowed_tools.clone(),
4094 disallowed_tools: resolved.raw.claude_disallowed_tools.clone(),
4095 tools: resolved.raw.claude_tools.clone(),
4096 agent: resolved.raw.claude_agent.clone(),
4097 agents_json: resolved.raw.claude_agents_json.clone(),
4098 plugin_dirs: resolved.raw.claude_plugin_dir.clone(),
4099 strict_mcp_config: resolved.raw.claude_strict_mcp_config,
4100 disable_slash_commands: resolved.raw.claude_disable_slash_commands,
4101 settings: None,
4102 tools_profile: vec![],
4103 allowed_mcp_servers: vec![],
4104 policy: vec![],
4105 admin_policy: vec![],
4106 },
4107 Engine::Gemini => ProviderCapability {
4108 mode: inferred_capability_mode(
4109 &resolved.raw.gemini_capabilities,
4110 resolved.raw.gemini_settings.is_some()
4111 || !resolved.raw.gemini_tools_profile.is_empty()
4112 || !resolved.raw.gemini_allowed_mcp_servers.is_empty()
4113 || !resolved.raw.gemini_policy.is_empty()
4114 || !resolved.raw.gemini_admin_policy.is_empty(),
4115 ),
4116 config: vec![],
4117 mcp_profile: None,
4118 mcp_config: vec![],
4119 allowed_tools: vec![],
4120 disallowed_tools: vec![],
4121 tools: vec![],
4122 agent: None,
4123 agents_json: None,
4124 plugin_dirs: vec![],
4125 strict_mcp_config: false,
4126 disable_slash_commands: false,
4127 settings: resolved.raw.gemini_settings.clone(),
4128 tools_profile: resolved.raw.gemini_tools_profile.clone(),
4129 allowed_mcp_servers: resolved.raw.gemini_allowed_mcp_servers.clone(),
4130 policy: resolved.raw.gemini_policy.clone(),
4131 admin_policy: resolved.raw.gemini_admin_policy.clone(),
4132 },
4133 }
4134}
4135
4136fn inferred_capability_mode(configured: &str, has_override_flags: bool) -> String {
4137 if configured == CAPABILITY_INHERIT && has_override_flags {
4138 CAPABILITY_OVERRIDE.to_string()
4139 } else {
4140 configured.to_string()
4141 }
4142}
4143
4144fn provider_option(
4145 member: &str,
4146 codex: &Option<String>,
4147 claude: &Option<String>,
4148 gemini: &Option<String>,
4149) -> Option<String> {
4150 match Engine::parse(member) {
4151 Some(Engine::Codex) => codex.clone(),
4152 Some(Engine::Claude) => claude.clone(),
4153 Some(Engine::Gemini) => gemini.clone(),
4154 None => None,
4155 }
4156}
4157
4158fn build_sub_agent_prompt(original: &str, role: &str, index: usize, total: usize) -> String {
4159 format!(
4160 "You are sub-agent {index} of {total} for an Amon Hen provider assigned role `{role}`.\n\
4161 Work independently on a useful slice of the task. Inspect, reason, or verify as needed, \
4162 then return concise findings, risks, and concrete recommendations for the provider lead.\n\n\
4163 Original provider prompt:\n{original}"
4164 )
4165}
4166
4167fn build_team_lead_prompt(
4168 original: &str,
4169 sub_agents: &[EngineResult],
4170 max_member_chars: usize,
4171) -> String {
4172 let handoff = sub_agents
4173 .iter()
4174 .map(|agent| {
4175 let raw = if agent.output.trim().is_empty() {
4176 agent.detail.trim()
4177 } else {
4178 agent.output.trim()
4179 };
4180 let output = bounded_context_text(raw, max_member_chars, "sub-agent handoff");
4181 format!("### {} [{}]\n{}", agent.role, agent.status, output)
4182 })
4183 .collect::<Vec<_>>()
4184 .join("\n\n");
4185 format!(
4186 "You are the provider lead. Use the sub-agent handoffs below, resolve disagreements, \
4187 and produce the final provider response for the original Amon Hen role.\n\n\
4188 Sub-agent handoffs:\n{handoff}\n\nOriginal provider prompt:\n{original}"
4189 )
4190}
4191
4192fn run_engine(name: &str, options: EngineRunOptions) -> EngineResult {
4193 if let Some(result) = missing_engine_if_unavailable(name, &options) {
4194 return result;
4195 }
4196 if options.team_size > 0 && options.role != "summary" && !options.is_sub_agent {
4197 return run_engine_team(name, options);
4198 }
4199 run_engine_single(name, options, vec![])
4200}
4201
4202fn missing_engine_if_unavailable(name: &str, options: &EngineRunOptions) -> Option<EngineResult> {
4203 let bin = resolve_binary(name);
4204 if command_available(&bin) {
4205 return None;
4206 }
4207 let detail = format!(
4208 "Provider CLI `{bin}` was not found in PATH. Install the {name} CLI on this machine or set {} to the executable path.",
4209 Engine::parse(name)
4210 .map(Engine::binary_env_var)
4211 .unwrap_or("AMON_HEN_PROVIDER_BIN")
4212 );
4213 let message = format!("[amon-hen] {name} missing: {detail}");
4214 emit_runtime_event(
4215 &options.progress,
4216 options.live && options.progress.is_none(),
4217 progress_event_with_context(
4218 Some(name),
4219 Some(&options.role),
4220 ProgressStage::Done,
4221 Some("missing"),
4222 Some(options.iteration),
4223 Some(options.total_iterations),
4224 options.is_sub_agent,
4225 Some(0),
4226 Some(token_usage(&options.prompt, "")),
4227 vec![],
4228 message,
4229 ),
4230 );
4231 Some(EngineResult {
4232 name: name.to_string(),
4233 bin: Some(bin.clone()),
4234 status: "missing".to_string(),
4235 duration_ms: 0,
4236 detail: detail.clone(),
4237 exit_code: None,
4238 stdout: String::new(),
4239 stderr: detail,
4240 output: String::new(),
4241 command: bin,
4242 token_usage: token_usage(&options.prompt, ""),
4243 tool_calls: vec![],
4244 sub_agents: vec![],
4245 role: options.role.clone(),
4246 iteration: options.iteration,
4247 total_iterations: options.total_iterations,
4248 team_size: options.team_size,
4249 })
4250}
4251
4252fn command_available(command: &str) -> bool {
4253 let path = Path::new(command);
4254 if path.components().count() > 1 || path.is_absolute() {
4255 return path.is_file();
4256 }
4257 let Some(paths) = std::env::var_os("PATH") else {
4258 return false;
4259 };
4260 std::env::split_paths(&paths).any(|dir| dir.join(command).is_file())
4261}
4262
4263fn run_engine_team(name: &str, options: EngineRunOptions) -> EngineResult {
4264 let team_size = options.team_size;
4265 let (tx, rx) = mpsc::channel();
4266 for index in 1..=team_size {
4267 let tx = tx.clone();
4268 let name = name.to_string();
4269 let mut sub_options = options.clone();
4270 sub_options.team_size = 0;
4271 sub_options.is_sub_agent = true;
4272 sub_options.role = format!("{}:sub-agent-{index}", options.role);
4273 sub_options.prompt = limit_provider_prompt(
4274 &build_sub_agent_prompt(&options.prompt, &options.role, index, team_size),
4275 options.max_prompt_chars,
4276 );
4277 thread::spawn(move || {
4278 let _ = tx.send((index, run_engine_single(&name, sub_options, vec![])));
4279 });
4280 }
4281 drop(tx);
4282
4283 let mut indexed = rx.into_iter().collect::<Vec<_>>();
4284 indexed.sort_by_key(|(index, _)| *index);
4285 let sub_agents = indexed
4286 .into_iter()
4287 .map(|(_, result)| result)
4288 .collect::<Vec<_>>();
4289 let mut lead_options = options.clone();
4290 lead_options.prompt = limit_provider_prompt(
4291 &build_team_lead_prompt(&options.prompt, &sub_agents, options.max_member_chars),
4292 options.max_prompt_chars,
4293 );
4294 lead_options.is_sub_agent = true;
4295 run_engine_single(name, lead_options, sub_agents)
4296}
4297
4298fn run_engine_single(
4299 name: &str,
4300 options: EngineRunOptions,
4301 sub_agents: Vec<EngineResult>,
4302) -> EngineResult {
4303 let started = Instant::now();
4304 let bin = resolve_binary(name);
4305 let live = options.live;
4306 let role_is_sub_agent = options.role.contains(":sub-agent-");
4307 let start_message = format!(
4308 "[amon-hen] start {} role={} iteration={}/{}{}",
4309 name,
4310 options.role,
4311 options.iteration,
4312 options.total_iterations,
4313 if role_is_sub_agent { " sub-agent" } else { "" }
4314 );
4315 emit_runtime_event(
4316 &options.progress,
4317 live && options.progress.is_none(),
4318 progress_event_with_context(
4319 Some(name),
4320 Some(&options.role),
4321 ProgressStage::Start,
4322 Some("running"),
4323 Some(options.iteration),
4324 Some(options.total_iterations),
4325 role_is_sub_agent,
4326 None,
4327 None,
4328 vec![],
4329 start_message,
4330 ),
4331 );
4332 let result = match Engine::parse(name) {
4333 Some(Engine::Codex) => run_codex(&bin, &options),
4334 Some(Engine::Claude) => run_claude(&bin, &options),
4335 Some(Engine::Gemini) => run_gemini(&bin, &options),
4336 None => CommandResult {
4337 command: name.to_string(),
4338 args: vec![],
4339 code: None,
4340 stdout: String::new(),
4341 stderr: String::new(),
4342 timed_out: false,
4343 cancelled: false,
4344 error: Some(format!("Unknown engine: {name}")),
4345 timeout_ms: options.timeout_ms,
4346 duration_ms: started.elapsed().as_millis(),
4347 },
4348 };
4349 let engine_progress = options.progress.clone();
4350 let engine = finalize_engine(
4351 name,
4352 &bin,
4353 started.elapsed().as_millis(),
4354 result,
4355 options,
4356 sub_agents,
4357 );
4358 if live {
4359 let failure_detail = if engine.status == "ok" {
4360 String::new()
4361 } else {
4362 format!(
4363 " detail={}",
4364 truncate(&sanitize_status_detail(&engine.detail), 220)
4365 )
4366 };
4367 let done_message = format!(
4368 "[amon-hen] done {} role={} status={} elapsed={:.1}s tokens={} tools={} sub-agents={}{}",
4369 engine.name,
4370 engine.role,
4371 engine.status,
4372 engine.duration_ms as f64 / 1000.0,
4373 engine.token_usage.total,
4374 engine.tool_calls.len(),
4375 engine.sub_agents.len(),
4376 failure_detail
4377 );
4378 emit_runtime_event(
4379 &engine_progress,
4380 live && engine_progress.is_none(),
4381 progress_event_with_context(
4382 Some(&engine.name),
4383 Some(&engine.role),
4384 ProgressStage::Done,
4385 Some(&engine.status),
4386 Some(engine.iteration),
4387 Some(engine.total_iterations),
4388 engine.role.contains(":sub-agent-"),
4389 Some(engine.duration_ms),
4390 Some(engine.token_usage.clone()),
4391 engine.tool_calls.clone(),
4392 done_message,
4393 ),
4394 );
4395 }
4396 engine
4397}
4398
4399fn push_arg(args: &mut Vec<String>, flag: &str, value: impl Into<String>) {
4400 args.push(flag.to_string());
4401 args.push(value.into());
4402}
4403
4404fn push_optional_arg(args: &mut Vec<String>, flag: &str, value: &Option<String>) {
4405 if let Some(value) = value {
4406 push_arg(args, flag, value.clone());
4407 }
4408}
4409
4410fn push_repeated_flag(args: &mut Vec<String>, flag: &str, values: &[String]) {
4411 if !values.is_empty() {
4412 args.push(flag.to_string());
4413 args.extend(values.iter().cloned());
4414 }
4415}
4416
4417fn push_each_arg(args: &mut Vec<String>, flag: &str, values: &[String]) {
4418 for value in values {
4419 push_arg(args, flag, value.clone());
4420 }
4421}
4422
4423fn run_codex(bin: &str, options: &EngineRunOptions) -> CommandResult {
4424 let temp = match tempfile::tempdir() {
4425 Ok(temp) => temp,
4426 Err(error) => {
4427 return CommandResult {
4428 command: bin.to_string(),
4429 args: vec![],
4430 code: None,
4431 stdout: String::new(),
4432 stderr: String::new(),
4433 timed_out: false,
4434 cancelled: false,
4435 error: Some(error.to_string()),
4436 timeout_ms: options.timeout_ms,
4437 duration_ms: 0,
4438 }
4439 }
4440 };
4441 let output_path = temp.path().join("last-message.txt");
4442 let mut args = vec!["exec".to_string()];
4443 push_optional_arg(&mut args, "--model", &options.model);
4444 if let Some(effort) = &options.effort {
4445 push_arg(&mut args, "-c", format!("model_reasoning_effort={effort}"));
4446 }
4447 if options.capability.mode == CAPABILITY_OVERRIDE {
4448 for config in &options.capability.config {
4449 push_arg(&mut args, "-c", config.clone());
4450 }
4451 push_optional_arg(&mut args, "--profile", &options.capability.mcp_profile);
4452 }
4453 args.extend([
4454 "--skip-git-repo-check".to_string(),
4455 "--sandbox".to_string(),
4456 options
4457 .permission
4458 .clone()
4459 .unwrap_or_else(|| "read-only".to_string()),
4460 "--ephemeral".to_string(),
4461 "--json".to_string(),
4462 "-o".to_string(),
4463 output_path.display().to_string(),
4464 "-".to_string(),
4465 ]);
4466 let mut result = run_command(
4467 CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
4468 .stdin_text(Some(&options.prompt))
4469 .progress(command_progress_with_input(
4470 live_label("codex", options).as_deref(),
4471 options.progress.clone(),
4472 estimate_tokens(&options.prompt),
4473 ))
4474 .cancel(options.cancel.clone()),
4475 );
4476 if let Ok(output) = fs::read_to_string(output_path) {
4477 if !output.trim().is_empty() {
4478 result.stdout = output;
4479 }
4480 }
4481 result
4482}
4483
4484fn run_claude(bin: &str, options: &EngineRunOptions) -> CommandResult {
4485 let mut args = Vec::new();
4486 if should_use_claude_bare_mode(&options.auth) {
4487 args.push("--bare".to_string());
4488 }
4489 args.extend([
4490 "-p".to_string(),
4491 "--permission-mode".to_string(),
4492 options
4493 .permission
4494 .clone()
4495 .unwrap_or_else(|| "plan".to_string()),
4496 "--verbose".to_string(),
4497 "--output-format".to_string(),
4498 "stream-json".to_string(),
4499 "--include-partial-messages".to_string(),
4500 "--no-session-persistence".to_string(),
4501 ]);
4502 push_optional_arg(&mut args, "--model", &options.model);
4503 if let Some(effort) = options
4504 .effort
4505 .clone()
4506 .or_else(|| std::env::var("CLAUDE_CODE_EFFORT_LEVEL").ok())
4507 {
4508 push_arg(&mut args, "--effort", effort);
4509 }
4510 if options.capability.mode == CAPABILITY_OVERRIDE {
4511 push_repeated_flag(&mut args, "--mcp-config", &options.capability.mcp_config);
4512 push_repeated_flag(
4513 &mut args,
4514 "--allowedTools",
4515 &options.capability.allowed_tools,
4516 );
4517 push_repeated_flag(
4518 &mut args,
4519 "--disallowedTools",
4520 &options.capability.disallowed_tools,
4521 );
4522 push_repeated_flag(&mut args, "--tools", &options.capability.tools);
4523 push_optional_arg(&mut args, "--agent", &options.capability.agent);
4524 push_optional_arg(&mut args, "--agents", &options.capability.agents_json);
4525 push_each_arg(&mut args, "--plugin-dir", &options.capability.plugin_dirs);
4526 if options.capability.strict_mcp_config {
4527 args.push("--strict-mcp-config".to_string());
4528 }
4529 if options.capability.disable_slash_commands {
4530 args.push("--disable-slash-commands".to_string());
4531 }
4532 }
4533 run_command(
4534 CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
4535 .stdin_text(Some(&options.prompt))
4536 .progress(command_progress_with_input(
4537 live_label("claude", options).as_deref(),
4538 options.progress.clone(),
4539 estimate_tokens(&options.prompt),
4540 ))
4541 .cancel(options.cancel.clone()),
4542 )
4543}
4544
4545fn run_gemini(bin: &str, options: &EngineRunOptions) -> CommandResult {
4546 let mut args = Vec::new();
4547 push_optional_arg(&mut args, "--model", &options.model);
4548 if options.capability.mode == CAPABILITY_OVERRIDE {
4549 push_repeated_flag(&mut args, "--extensions", &options.capability.tools_profile);
4550 push_repeated_flag(
4551 &mut args,
4552 "--allowed-mcp-server-names",
4553 &options.capability.allowed_mcp_servers,
4554 );
4555 push_repeated_flag(&mut args, "--policy", &options.capability.policy);
4556 push_repeated_flag(
4557 &mut args,
4558 "--admin-policy",
4559 &options.capability.admin_policy,
4560 );
4561 }
4562 args.extend([
4563 "-p".to_string(),
4564 String::new(),
4565 "--skip-trust".to_string(),
4566 "--approval-mode".to_string(),
4567 options
4568 .permission
4569 .clone()
4570 .unwrap_or_else(|| "plan".to_string()),
4571 "--output-format".to_string(),
4572 "stream-json".to_string(),
4573 ]);
4574 let mut envs = HashMap::new();
4575 let effort_settings = prepare_gemini_settings(options);
4576 if let Some(path) = effort_settings.as_ref() {
4577 envs.insert(
4578 "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
4579 path.path.display().to_string(),
4580 );
4581 } else if options.capability.mode == CAPABILITY_OVERRIDE {
4582 if let Some(settings) = &options.capability.settings {
4583 envs.insert(
4584 "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
4585 settings.clone(),
4586 );
4587 }
4588 }
4589 run_command(
4590 CommandRequest::new(bin, &args, &options.cwd, options.timeout_ms)
4591 .envs(envs)
4592 .stdin_text(Some(&options.prompt))
4593 .progress(command_progress_with_input(
4594 live_label("gemini", options).as_deref(),
4595 options.progress.clone(),
4596 estimate_tokens(&options.prompt),
4597 ))
4598 .cancel(options.cancel.clone()),
4599 )
4600}
4601
4602fn live_label(name: &str, options: &EngineRunOptions) -> Option<String> {
4603 options.live.then(|| {
4604 format!(
4605 "{} {} iteration {}/{}",
4606 name, options.role, options.iteration, options.total_iterations
4607 )
4608 })
4609}
4610
4611fn provider_from_live_label(label: &str) -> Option<&str> {
4612 let provider = label.split_whitespace().next()?;
4613 ENGINES.contains(&provider).then_some(provider)
4614}
4615
4616fn role_from_live_label(label: &str) -> Option<&str> {
4617 if provider_from_live_label(label).is_some() {
4618 return label.split_whitespace().nth(1);
4619 }
4620 None
4621}
4622
4623fn iteration_from_live_label(label: &str) -> Option<usize> {
4624 iteration_pair_from_live_label(label).map(|(iteration, _)| iteration)
4625}
4626
4627fn total_iterations_from_live_label(label: &str) -> Option<usize> {
4628 iteration_pair_from_live_label(label).map(|(_, total)| total)
4629}
4630
4631fn iteration_pair_from_live_label(label: &str) -> Option<(usize, usize)> {
4632 let pair = label.split_whitespace().last()?;
4633 let (iteration, total) = pair.split_once('/')?;
4634 Some((iteration.parse().ok()?, total.parse().ok()?))
4635}
4636
4637fn prepare_gemini_settings(options: &EngineRunOptions) -> Option<TempSettings> {
4638 let effort = options.effort.as_deref()?;
4639 let budget = match effort {
4640 "low" => 1024,
4641 "medium" => 8192,
4642 "high" => 24576,
4643 _ => return None,
4644 };
4645 let dir = tempfile::tempdir().ok()?;
4646 let path = dir.path().join("settings.json");
4647 let mut settings = if options.capability.mode == CAPABILITY_OVERRIDE {
4648 options
4649 .capability
4650 .settings
4651 .as_ref()
4652 .and_then(|path| fs::read_to_string(path).ok())
4653 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
4654 .unwrap_or_else(|| Value::Object(Default::default()))
4655 } else {
4656 Value::Object(Default::default())
4657 };
4658 if let Value::Object(object) = &mut settings {
4659 object.insert("thinkingBudget".to_string(), Value::from(budget));
4660 }
4661 let _ = fs::write(&path, serde_json::to_vec(&settings).ok()?);
4662 Some(TempSettings { _dir: dir, path })
4663}
4664
4665fn should_use_claude_bare_mode(auth: &str) -> bool {
4666 if auth == "api-key" {
4667 return true;
4668 }
4669 if matches!(auth, "social-login" | "oauth" | "keychain") {
4670 return false;
4671 }
4672 if std::env::var("CLAUDE_CODE_OAUTH_TOKEN")
4673 .ok()
4674 .filter(|value| !value.trim().is_empty())
4675 .is_some()
4676 {
4677 return false;
4678 }
4679 std::env::var("ANTHROPIC_API_KEY")
4680 .ok()
4681 .filter(|value| !value.trim().is_empty())
4682 .is_some()
4683}
4684
4685fn configure_process_group(command: &mut Command) {
4686 #[cfg(unix)]
4687 {
4688 use std::os::unix::process::CommandExt;
4689 command.process_group(0);
4690 }
4691}
4692
4693fn terminate_child_tree(child: &mut Child) {
4694 #[cfg(unix)]
4695 {
4696 let pgid = -(child.id() as i32);
4697 unsafe {
4698 libc::kill(pgid, libc::SIGTERM);
4699 }
4700 for _ in 0..20 {
4701 if child.try_wait().ok().flatten().is_some() {
4702 return;
4703 }
4704 thread::sleep(Duration::from_millis(25));
4705 }
4706 unsafe {
4707 libc::kill(pgid, libc::SIGKILL);
4708 }
4709 }
4710 let _ = child.kill();
4711}
4712
4713fn run_command(request: CommandRequest<'_>) -> CommandResult {
4714 let CommandRequest {
4715 command,
4716 args,
4717 cwd,
4718 stdin_text,
4719 timeout_ms,
4720 envs,
4721 progress,
4722 cancel,
4723 } = request;
4724 let started = Instant::now();
4725 if let Some(progress) = &progress {
4726 emit_runtime_event(
4727 &progress.sink,
4728 progress.sink.is_none(),
4729 progress_event_with_context(
4730 provider_from_live_label(&progress.label),
4731 role_from_live_label(&progress.label),
4732 ProgressStage::Spawn,
4733 Some("running"),
4734 iteration_from_live_label(&progress.label),
4735 total_iterations_from_live_label(&progress.label),
4736 role_from_live_label(&progress.label)
4737 .is_some_and(|role| role.contains(":sub-agent-")),
4738 None,
4739 None,
4740 vec![],
4741 format!("[amon-hen] spawn {}", progress.label),
4742 ),
4743 );
4744 }
4745 let mut process = Command::new(command);
4746 process
4747 .args(args)
4748 .current_dir(cwd)
4749 .envs(envs)
4750 .stdin(Stdio::piped())
4751 .stdout(Stdio::piped())
4752 .stderr(Stdio::piped());
4753 configure_process_group(&mut process);
4754 let mut child = match process.spawn() {
4755 Ok(child) => child,
4756 Err(error) => {
4757 let status = spawn_error_status(&error);
4758 if let Some(progress) = &progress {
4759 emit_runtime_event(
4760 &progress.sink,
4761 progress.sink.is_none(),
4762 progress_event_with_context(
4763 provider_from_live_label(&progress.label),
4764 role_from_live_label(&progress.label),
4765 ProgressStage::Done,
4766 Some(status),
4767 iteration_from_live_label(&progress.label),
4768 total_iterations_from_live_label(&progress.label),
4769 role_from_live_label(&progress.label)
4770 .is_some_and(|role| role.contains(":sub-agent-")),
4771 Some(started.elapsed().as_millis()),
4772 None,
4773 vec![],
4774 format!("[amon-hen] spawn failed {}: {error}", progress.label),
4775 ),
4776 );
4777 }
4778 return CommandResult {
4779 command: command.to_string(),
4780 args: args.to_vec(),
4781 code: None,
4782 stdout: String::new(),
4783 stderr: String::new(),
4784 timed_out: false,
4785 cancelled: false,
4786 error: Some(error.to_string()),
4787 timeout_ms,
4788 duration_ms: started.elapsed().as_millis(),
4789 };
4790 }
4791 };
4792
4793 let stdout = child
4794 .stdout
4795 .take()
4796 .map(|pipe| read_pipe(pipe, progress.clone(), "stdout"));
4797 let stderr = child
4798 .stderr
4799 .take()
4800 .map(|pipe| read_pipe(pipe, progress.clone(), "stderr"));
4801 let mut stdin_writer = child.stdin.take().and_then(|mut stdin| {
4802 stdin_text.map(|text| {
4803 let text = text.to_string();
4804 thread::spawn(move || {
4805 let _ = stdin.write_all(text.as_bytes());
4806 })
4807 })
4808 });
4809 let timeout = Duration::from_millis(timeout_ms);
4810 let mut next_live_tick = Duration::from_secs(10);
4811 let mut timed_out = false;
4812 let mut cancelled = false;
4813 let code;
4814 loop {
4815 match child.try_wait() {
4816 Ok(Some(status)) => {
4817 code = status.code();
4818 break;
4819 }
4820 Ok(None) => {
4821 if cancel
4822 .as_ref()
4823 .is_some_and(|cancel| cancel.load(Ordering::Relaxed))
4824 {
4825 cancelled = true;
4826 terminate_child_tree(&mut child);
4827 let status = child.wait().ok();
4828 code = status.and_then(|status| status.code());
4829 break;
4830 }
4831 if timeout_ms > 0 && started.elapsed() >= timeout {
4832 timed_out = true;
4833 terminate_child_tree(&mut child);
4834 let status = child.wait().ok();
4835 code = status.and_then(|status| status.code());
4836 break;
4837 }
4838 if let Some(progress) = &progress {
4839 let elapsed = started.elapsed();
4840 if elapsed >= next_live_tick {
4841 let timeout_detail = if timeout_ms > 0 {
4842 format!("/{}s", timeout_ms / 1000)
4843 } else {
4844 String::new()
4845 };
4846 emit_runtime_event(
4847 &progress.sink,
4848 progress.sink.is_none(),
4849 progress_event_with_context(
4850 provider_from_live_label(&progress.label),
4851 role_from_live_label(&progress.label),
4852 ProgressStage::Heartbeat,
4853 Some("running"),
4854 iteration_from_live_label(&progress.label),
4855 total_iterations_from_live_label(&progress.label),
4856 role_from_live_label(&progress.label)
4857 .is_some_and(|role| role.contains(":sub-agent-")),
4858 Some(elapsed.as_millis()),
4859 None,
4860 vec![],
4861 format!(
4862 "[amon-hen] running {} for {}s{}",
4863 progress.label,
4864 elapsed.as_secs(),
4865 timeout_detail
4866 ),
4867 ),
4868 );
4869 next_live_tick += Duration::from_secs(10);
4870 }
4871 }
4872 thread::sleep(Duration::from_millis(25));
4873 }
4874 Err(error) => {
4875 if let Some(handle) = stdin_writer.take() {
4876 let _ = handle.join();
4877 }
4878 return CommandResult {
4879 command: command.to_string(),
4880 args: args.to_vec(),
4881 code: None,
4882 stdout: String::new(),
4883 stderr: String::new(),
4884 timed_out,
4885 cancelled,
4886 error: Some(error.to_string()),
4887 timeout_ms,
4888 duration_ms: started.elapsed().as_millis(),
4889 };
4890 }
4891 }
4892 }
4893
4894 if let Some(handle) = stdin_writer {
4895 let _ = handle.join();
4896 }
4897
4898 CommandResult {
4899 command: command.to_string(),
4900 args: args.to_vec(),
4901 code,
4902 stdout: stdout
4903 .and_then(|handle| handle.join().ok())
4904 .unwrap_or_default(),
4905 stderr: stderr
4906 .and_then(|handle| handle.join().ok())
4907 .unwrap_or_default(),
4908 timed_out,
4909 cancelled,
4910 error: cancelled.then(|| "cancelled".to_string()),
4911 timeout_ms,
4912 duration_ms: started.elapsed().as_millis(),
4913 }
4914}
4915
4916fn read_pipe<R>(
4917 mut pipe: R,
4918 progress: Option<CommandProgress>,
4919 stream: &'static str,
4920) -> thread::JoinHandle<String>
4921where
4922 R: Read + Send + 'static,
4923{
4924 thread::spawn(move || {
4925 let mut text = String::new();
4926 let mut pending = String::new();
4927 let mut buffer = [0u8; 4096];
4928 let mut last_stream_event = Instant::now() - PROVIDER_STREAM_EVENT_MIN_INTERVAL;
4929 let mut stream_state = ProviderStreamState::new();
4930 loop {
4931 let read = match pipe.read(&mut buffer) {
4932 Ok(0) => break,
4933 Ok(read) => read,
4934 Err(_) => break,
4935 };
4936 let chunk = String::from_utf8_lossy(&buffer[..read]);
4937 text.push_str(&chunk);
4938 pending.push_str(&chunk);
4939 while let Some(newline) = pending.find('\n') {
4940 let mut line = pending.drain(..=newline).collect::<String>();
4941 if line.ends_with('\n') {
4942 line.pop();
4943 }
4944 if line.ends_with('\r') {
4945 line.pop();
4946 }
4947 if stream_state.maybe_emit_assistant_delta(
4948 &progress,
4949 stream,
4950 &line,
4951 &text,
4952 &mut last_stream_event,
4953 ) {
4954 continue;
4955 }
4956 maybe_emit_provider_stream_line(
4957 &progress,
4958 stream,
4959 &line,
4960 &text,
4961 &mut last_stream_event,
4962 );
4963 }
4964 if !chunk.contains('\n') {
4965 maybe_emit_provider_stream_chunk(
4966 &progress,
4967 stream,
4968 &chunk,
4969 &text,
4970 &mut last_stream_event,
4971 );
4972 }
4973 }
4974 stream_state.flush(&progress, stream, &text);
4975 if !pending.trim().is_empty() {
4976 emit_provider_stream_line(&progress, stream, &pending, &text);
4977 }
4978 text
4979 })
4980}
4981
4982struct ProviderStreamState {
4983 assistant_text: String,
4984 emitted_assistant_bytes: usize,
4985}
4986
4987impl ProviderStreamState {
4988 fn new() -> Self {
4989 Self {
4990 assistant_text: String::new(),
4991 emitted_assistant_bytes: 0,
4992 }
4993 }
4994
4995 fn maybe_emit_assistant_delta(
4996 &mut self,
4997 progress: &Option<CommandProgress>,
4998 stream: &str,
4999 line: &str,
5000 accumulated: &str,
5001 last_stream_event: &mut Instant,
5002 ) -> bool {
5003 let Some(progress) = progress else {
5004 return false;
5005 };
5006 let Some(provider) = provider_from_live_label(&progress.label) else {
5007 return false;
5008 };
5009 let Some(delta) = provider_assistant_text_delta(provider, line) else {
5010 return false;
5011 };
5012 if delta.is_empty() {
5013 return true;
5014 }
5015 self.assistant_text.push_str(&delta);
5016 let now = Instant::now();
5017 let bytes_since_emit = self
5018 .assistant_text
5019 .len()
5020 .saturating_sub(self.emitted_assistant_bytes);
5021 if bytes_since_emit >= 96
5022 || now.duration_since(*last_stream_event) >= PROVIDER_STREAM_EVENT_MIN_INTERVAL
5023 {
5024 self.emit_snapshot(progress, stream, accumulated);
5025 *last_stream_event = now;
5026 }
5027 true
5028 }
5029
5030 fn flush(&mut self, progress: &Option<CommandProgress>, stream: &str, accumulated: &str) {
5031 let Some(progress) = progress else {
5032 return;
5033 };
5034 if self.assistant_text.len() > self.emitted_assistant_bytes {
5035 self.emit_snapshot(progress, stream, accumulated);
5036 }
5037 }
5038
5039 fn emit_snapshot(&mut self, progress: &CommandProgress, stream: &str, accumulated: &str) {
5040 let visible = format!(
5041 "assistant live: {}",
5042 sanitize_status_detail(&tail_chars(&self.assistant_text, 900))
5043 );
5044 emit_provider_stream_visible(progress, stream, accumulated, &visible, Vec::new());
5045 self.emitted_assistant_bytes = self.assistant_text.len();
5046 }
5047}
5048
5049fn maybe_emit_provider_stream_chunk(
5050 progress: &Option<CommandProgress>,
5051 stream: &str,
5052 chunk: &str,
5053 accumulated: &str,
5054 last_stream_event: &mut Instant,
5055) {
5056 if progress
5057 .as_ref()
5058 .and_then(|progress| provider_from_live_label(&progress.label))
5059 .is_some()
5060 {
5061 return;
5062 }
5063 let clipped = chunk.trim();
5064 if clipped.len() >= 24 {
5065 maybe_emit_provider_stream_line(progress, stream, clipped, accumulated, last_stream_event);
5066 }
5067}
5068
5069fn maybe_emit_provider_stream_line(
5070 progress: &Option<CommandProgress>,
5071 stream: &str,
5072 line: &str,
5073 accumulated: &str,
5074 last_stream_event: &mut Instant,
5075) {
5076 let now = Instant::now();
5077 let important = provider_stream_line_is_important(line);
5078 if important || now.duration_since(*last_stream_event) >= PROVIDER_STREAM_EVENT_MIN_INTERVAL {
5079 emit_provider_stream_line(progress, stream, line, accumulated);
5080 *last_stream_event = now;
5081 }
5082}
5083
5084fn provider_stream_line_is_important(line: &str) -> bool {
5085 let line = line.to_ascii_lowercase();
5086 [
5087 "tool_use",
5088 "tool_call",
5089 "functioncall",
5090 "function_call",
5091 "command_execution",
5092 "item.completed",
5093 "\"result\"",
5094 "\"error\"",
5095 "error",
5096 "failed",
5097 "permission",
5098 ]
5099 .iter()
5100 .any(|needle| line.contains(needle))
5101}
5102
5103fn emit_provider_stream_line(
5104 progress: &Option<CommandProgress>,
5105 stream: &str,
5106 line: &str,
5107 accumulated: &str,
5108) {
5109 let Some(progress) = progress else {
5110 return;
5111 };
5112 let line = line.trim();
5113 if line.is_empty() {
5114 return;
5115 }
5116 let provider = provider_from_live_label(&progress.label);
5117 let tool_calls = provider
5118 .map(|provider| extract_tool_usage(provider, line, ""))
5119 .unwrap_or_default();
5120 let visible = match provider_visible_stream(provider, line, &tool_calls) {
5121 StreamDisplay::Visible(visible) => visible,
5122 StreamDisplay::Suppress => return,
5123 };
5124 emit_provider_stream_visible(progress, stream, accumulated, &visible, tool_calls);
5125}
5126
5127fn emit_provider_stream_visible(
5128 progress: &CommandProgress,
5129 stream: &str,
5130 accumulated: &str,
5131 visible: &str,
5132 tool_calls: Vec<ToolUsage>,
5133) {
5134 let provider = provider_from_live_label(&progress.label);
5135 let role = role_from_live_label(&progress.label);
5136 let output_tokens = estimate_tokens(accumulated);
5137 let token_usage = provider.map(|_| TokenUsage {
5138 input: progress.input_tokens,
5139 output: output_tokens,
5140 total: progress.input_tokens + output_tokens,
5141 estimated: true,
5142 source: "live-stream-estimate".to_string(),
5143 });
5144 emit_runtime_event(
5145 &progress.sink,
5146 progress.sink.is_none(),
5147 progress_event_with_context(
5148 provider,
5149 role,
5150 ProgressStage::Heartbeat,
5151 Some("streaming"),
5152 iteration_from_live_label(&progress.label),
5153 total_iterations_from_live_label(&progress.label),
5154 role.is_some_and(|role| role.contains(":sub-agent-")),
5155 None,
5156 token_usage,
5157 tool_calls,
5158 format!(
5159 "[amon-hen] stream {} {stream}: {}",
5160 progress.label,
5161 truncate(visible, 220)
5162 ),
5163 ),
5164 );
5165}
5166
5167#[derive(Debug, Eq, PartialEq)]
5168enum StreamDisplay {
5169 Visible(String),
5170 Suppress,
5171}
5172
5173fn provider_visible_stream(
5174 provider: Option<&str>,
5175 line: &str,
5176 tool_calls: &[ToolUsage],
5177) -> StreamDisplay {
5178 let Some(provider) = provider else {
5179 return StreamDisplay::Visible(sanitize_status_detail(line));
5180 };
5181 let Ok(value) = serde_json::from_str::<Value>(line) else {
5182 if looks_like_json_line(line) {
5183 return StreamDisplay::Suppress;
5184 }
5185 return StreamDisplay::Visible(sanitize_status_detail(line));
5186 };
5187 let visible = match provider {
5188 "claude" => claude_visible_stream(&value),
5189 "gemini" => gemini_visible_stream(&value),
5190 "codex" => codex_visible_stream(&value),
5191 _ => None,
5192 }
5193 .or_else(|| tool_calls.first().map(visible_tool_summary));
5194 visible
5195 .filter(|text| !text.trim().is_empty())
5196 .map(StreamDisplay::Visible)
5197 .unwrap_or(StreamDisplay::Suppress)
5198}
5199
5200fn provider_assistant_text_delta(provider: &str, line: &str) -> Option<String> {
5201 let value = serde_json::from_str::<Value>(line).ok()?;
5202 match provider {
5203 "claude" => claude_assistant_text_delta(&value),
5204 _ => None,
5205 }
5206}
5207
5208fn claude_assistant_text_delta(value: &Value) -> Option<String> {
5209 if value.get("type").and_then(Value::as_str) == Some("stream_event") {
5210 return value.get("event").and_then(claude_assistant_text_delta);
5211 }
5212 match value.get("type").and_then(Value::as_str) {
5213 Some("content_block_delta") => {
5214 let delta = value.get("delta")?;
5215 if delta.get("type").and_then(Value::as_str) == Some("text_delta") {
5216 return delta
5217 .get("text")
5218 .and_then(Value::as_str)
5219 .map(str::to_string);
5220 }
5221 }
5222 Some("message_delta" | "message_start" | "message_stop" | "content_block_stop") => {}
5223 _ => {
5224 if let Some(text) = value.pointer("/delta/text").and_then(Value::as_str) {
5225 return Some(text.to_string());
5226 }
5227 }
5228 }
5229 None
5230}
5231
5232fn tail_chars(text: &str, max_chars: usize) -> String {
5233 byte_index_for_tail_chars(text, max_chars)
5234 .map(|index| format!("...{}", &text[index..]))
5235 .unwrap_or_else(|| text.to_string())
5236}
5237
5238fn visible_tool_summary(tool: &ToolUsage) -> String {
5239 let detail = sanitize_status_detail(&tool.detail);
5240 if detail.is_empty() {
5241 format!("tool {}: {}", tool.status, tool.name)
5242 } else {
5243 format!("tool {}: {} {}", tool.status, tool.name, detail)
5244 }
5245}
5246
5247fn claude_visible_stream(value: &Value) -> Option<String> {
5248 if value.get("type").and_then(Value::as_str) == Some("stream_event") {
5249 return value.get("event").and_then(claude_visible_stream);
5250 }
5251 match value.get("type").and_then(Value::as_str) {
5252 Some("content_block_delta") => return claude_visible_delta(value.get("delta")?),
5253 Some("content_block_start") => {
5254 return value
5255 .get("content_block")
5256 .and_then(visible_content_block)
5257 .map(|text| sanitize_status_detail(&text));
5258 }
5259 Some("content_block_stop" | "message_start" | "message_delta" | "message_stop") => {
5260 return None;
5261 }
5262 _ => {}
5263 }
5264 if let Some(result) = non_empty_str(value.get("result")) {
5265 return Some(format!("result: {}", sanitize_status_detail(result)));
5266 }
5267 if let Some(content) = value.pointer("/message/content").and_then(Value::as_array) {
5268 if let Some(text) = visible_content_blocks(content) {
5269 return Some(text);
5270 }
5271 }
5272 if let Some(delta) = value.pointer("/delta/text").and_then(Value::as_str) {
5273 return Some(format!("assistant: {}", sanitize_status_detail(delta)));
5274 }
5275 if let Some(block) = value.pointer("/content_block") {
5276 if let Some(text) = visible_content_block(block) {
5277 return Some(text);
5278 }
5279 }
5280 if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
5281 return Some(format!("error: {}", sanitize_status_detail(error)));
5282 }
5283 None
5284}
5285
5286fn claude_visible_delta(delta: &Value) -> Option<String> {
5287 match delta.get("type").and_then(Value::as_str) {
5288 Some("text_delta") => delta
5289 .get("text")
5290 .and_then(Value::as_str)
5291 .filter(|text| !text.trim().is_empty())
5292 .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
5293 Some("input_json_delta" | "thinking_delta" | "signature_delta") => None,
5294 _ => delta
5295 .get("text")
5296 .and_then(Value::as_str)
5297 .filter(|text| !text.trim().is_empty())
5298 .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
5299 }
5300}
5301
5302fn claude_output_text(value: &Value) -> Option<String> {
5303 if value.get("type").and_then(Value::as_str) == Some("stream_event") {
5304 return value.get("event").and_then(claude_output_text);
5305 }
5306 match value.get("type").and_then(Value::as_str) {
5307 Some("content_block_delta") => {
5308 let delta = value.get("delta")?;
5309 return match delta.get("type").and_then(Value::as_str) {
5310 Some("text_delta") => non_empty_str(delta.get("text")).map(str::to_string),
5311 _ => None,
5312 };
5313 }
5314 Some("content_block_start") => {
5315 let block = value.get("content_block")?;
5316 if matches!(
5317 block.get("type").and_then(Value::as_str),
5318 Some("text" | "output_text")
5319 ) {
5320 return non_empty_str(block.get("text")).map(str::to_string);
5321 }
5322 }
5323 _ => {}
5324 }
5325 if let Some(result) = non_empty_str(value.get("result")) {
5326 return Some(result.to_string());
5327 }
5328 value
5329 .pointer("/message/content")
5330 .and_then(Value::as_array)
5331 .map(|content| {
5332 content
5333 .iter()
5334 .filter_map(|block| {
5335 matches!(
5336 block.get("type").and_then(Value::as_str),
5337 Some("text" | "output_text")
5338 )
5339 .then(|| non_empty_str(block.get("text")))
5340 .flatten()
5341 })
5342 .collect::<String>()
5343 })
5344 .filter(|text| !text.trim().is_empty())
5345}
5346
5347fn gemini_visible_stream(value: &Value) -> Option<String> {
5348 match value.get("type").and_then(Value::as_str) {
5349 Some("message") => {
5350 if value.get("role").and_then(Value::as_str) != Some("assistant") {
5351 return None;
5352 }
5353 return non_empty_str(value.get("content"))
5354 .map(|text| format!("assistant: {}", sanitize_status_detail(text)));
5355 }
5356 Some("tool_use") => return Some(format!("tool: {}", function_call_summary(value))),
5357 Some("tool_result") => {
5358 let status = value
5359 .get("status")
5360 .and_then(Value::as_str)
5361 .unwrap_or("observed");
5362 let detail = value
5363 .get("output")
5364 .or_else(|| value.pointer("/error/message"))
5365 .and_then(Value::as_str)
5366 .map(sanitize_status_detail)
5367 .unwrap_or_default();
5368 let tool_id = value
5369 .get("tool_id")
5370 .and_then(Value::as_str)
5371 .unwrap_or("tool");
5372 if detail.is_empty() {
5373 return Some(format!("tool result {status}: {tool_id}"));
5374 }
5375 return Some(format!(
5376 "tool result {status}: {tool_id} -> {}",
5377 truncate(&detail, 140)
5378 ));
5379 }
5380 Some("error") => {
5381 if let Some(message) = non_empty_str(value.get("message")) {
5382 return Some(format!("error: {}", sanitize_status_detail(message)));
5383 }
5384 }
5385 Some("result") => {
5386 if value.get("status").and_then(Value::as_str) == Some("error") {
5387 if let Some(message) = value.pointer("/error/message").and_then(Value::as_str) {
5388 return Some(format!("error: {}", sanitize_status_detail(message)));
5389 }
5390 return Some("error: Gemini returned an error result.".to_string());
5391 }
5392 return None;
5393 }
5394 Some("init") => return None,
5395 _ => {}
5396 }
5397 if let Some(response) = non_empty_str(value.get("response")) {
5398 return Some(format!("assistant: {}", sanitize_status_detail(response)));
5399 }
5400 if let Some(text) = non_empty_str(value.get("text")) {
5401 return Some(format!("assistant: {}", sanitize_status_detail(text)));
5402 }
5403 if let Some(text) = value
5404 .pointer("/candidates/0/content/parts/0/text")
5405 .and_then(Value::as_str)
5406 .filter(|text| !text.trim().is_empty())
5407 {
5408 return Some(format!("assistant: {}", sanitize_status_detail(text)));
5409 }
5410 if let Some(call) = value
5411 .pointer("/functionCall")
5412 .or_else(|| value.pointer("/candidates/0/content/parts/0/functionCall"))
5413 {
5414 return Some(format!("tool: {}", function_call_summary(call)));
5415 }
5416 if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
5417 return Some(format!("error: {}", sanitize_status_detail(error)));
5418 }
5419 None
5420}
5421
5422fn codex_visible_stream(value: &Value) -> Option<String> {
5423 if let Some(message) = non_empty_str(value.get("message")) {
5424 return Some(format!("assistant: {}", sanitize_status_detail(message)));
5425 }
5426 if let Some(text) = non_empty_str(value.get("text")) {
5427 return Some(format!("assistant: {}", sanitize_status_detail(text)));
5428 }
5429 if let Some(item) = value.get("item") {
5430 return codex_visible_item(item);
5431 }
5432 if let Some(error) = value.pointer("/error/message").and_then(Value::as_str) {
5433 return Some(format!("error: {}", sanitize_status_detail(error)));
5434 }
5435 None
5436}
5437
5438fn codex_visible_item(item: &Value) -> Option<String> {
5439 let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
5440 match item_type {
5441 "message" => codex_message_text(item),
5442 "command_execution" => {
5443 let command = item
5444 .get("command")
5445 .and_then(Value::as_str)
5446 .map(sanitize_status_detail)
5447 .unwrap_or_else(|| "command".to_string());
5448 let output = item
5449 .get("aggregated_output")
5450 .or_else(|| item.get("output"))
5451 .and_then(Value::as_str)
5452 .map(sanitize_status_detail)
5453 .unwrap_or_default();
5454 if output.trim().is_empty() {
5455 Some(format!("tool: shell {command}"))
5456 } else {
5457 Some(format!(
5458 "tool: shell {command} -> {}",
5459 truncate(&output, 140)
5460 ))
5461 }
5462 }
5463 "function_call" | "tool_call" => Some(format!("tool: {}", function_call_summary(item))),
5464 "error" => item
5465 .get("message")
5466 .and_then(Value::as_str)
5467 .map(|message| format!("error: {}", sanitize_status_detail(message))),
5468 "reasoning" => item
5469 .get("summary")
5470 .and_then(Value::as_str)
5471 .filter(|summary| !summary.trim().is_empty())
5472 .map(|summary| format!("reasoning summary: {}", sanitize_status_detail(summary))),
5473 _ => None,
5474 }
5475}
5476
5477fn codex_message_text(item: &Value) -> Option<String> {
5478 if let Some(text) = non_empty_str(item.get("text")) {
5479 return Some(sanitize_status_detail(text));
5480 }
5481 item.get("content")
5482 .and_then(Value::as_array)
5483 .and_then(|blocks| visible_content_blocks(blocks))
5484}
5485
5486fn visible_content_blocks(blocks: &[Value]) -> Option<String> {
5487 let parts = blocks
5488 .iter()
5489 .filter_map(visible_content_block)
5490 .collect::<Vec<_>>();
5491 (!parts.is_empty()).then(|| parts.join(" | "))
5492}
5493
5494fn visible_content_block(block: &Value) -> Option<String> {
5495 match block.get("type").and_then(Value::as_str) {
5496 Some("text") | Some("output_text") => non_empty_str(block.get("text"))
5497 .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
5498 Some("tool_use") => {
5499 let name = block.get("name").and_then(Value::as_str).unwrap_or("tool");
5500 let input = block
5501 .get("input")
5502 .map(short_json_detail)
5503 .unwrap_or_default();
5504 if input.is_empty() {
5505 Some(format!("tool: {name}"))
5506 } else {
5507 Some(format!("tool: {name} {}", truncate(&input, 140)))
5508 }
5509 }
5510 Some("function_call") | Some("tool_call") => {
5511 Some(format!("tool: {}", function_call_summary(block)))
5512 }
5513 _ => non_empty_str(block.get("text"))
5514 .map(|text| format!("assistant: {}", sanitize_status_detail(text))),
5515 }
5516}
5517
5518fn function_call_summary(value: &Value) -> String {
5519 let name = value
5520 .get("name")
5521 .or_else(|| value.get("tool"))
5522 .or_else(|| value.get("tool_name"))
5523 .or_else(|| value.get("function"))
5524 .and_then(Value::as_str)
5525 .unwrap_or("tool");
5526 let args = value
5527 .get("args")
5528 .or_else(|| value.get("arguments"))
5529 .or_else(|| value.get("parameters"))
5530 .or_else(|| value.get("input"))
5531 .map(short_json_detail)
5532 .unwrap_or_default();
5533 if args.is_empty() {
5534 name.to_string()
5535 } else {
5536 format!("{name} {}", truncate(&args, 140))
5537 }
5538}
5539
5540fn non_empty_str(value: Option<&Value>) -> Option<&str> {
5541 value
5542 .and_then(Value::as_str)
5543 .map(str::trim)
5544 .filter(|text| !text.is_empty())
5545}
5546
5547fn finalize_engine(
5548 name: &str,
5549 bin: &str,
5550 duration_ms: u128,
5551 command_result: CommandResult,
5552 options: EngineRunOptions,
5553 sub_agents: Vec<EngineResult>,
5554) -> EngineResult {
5555 let output = match Engine::parse(name) {
5556 Some(Engine::Claude) => parse_claude_output(&command_result.stdout),
5557 Some(Engine::Gemini) => parse_gemini_output(&command_result.stdout),
5558 Some(Engine::Codex) => parse_codex_output(&command_result.stdout),
5559 None => command_result.stdout.trim().to_string(),
5560 };
5561 let status = if command_result.cancelled {
5562 "cancelled"
5563 } else if let Some(error) = command_result.error.as_deref() {
5564 spawn_failure_status(error)
5565 } else if command_result.timed_out {
5566 "timeout"
5567 } else if command_result.code != Some(0) || output.trim().is_empty() {
5568 "error"
5569 } else {
5570 "ok"
5571 };
5572 let detail = if status == "ok" {
5573 String::new()
5574 } else if command_result.cancelled {
5575 "Cancelled by Studio/user request.".to_string()
5576 } else if command_result.timed_out {
5577 format!("Timed out after {}s.", command_result.timeout_ms / 1000)
5578 } else {
5579 compact_failure(&command_result)
5580 };
5581 let usage = aggregate_token_usage(
5582 extract_token_usage(&command_result.stdout, &options.prompt, &output)
5583 .unwrap_or_else(|| token_usage(&options.prompt, &output)),
5584 &sub_agents,
5585 );
5586 let tool_calls = extract_tool_usage(name, &command_result.stdout, &command_result.stderr);
5587 EngineResult {
5588 name: name.to_string(),
5589 bin: Some(bin.to_string()),
5590 status: status.to_string(),
5591 duration_ms,
5592 detail,
5593 exit_code: command_result.code,
5594 stdout: command_result.stdout,
5595 stderr: command_result.stderr,
5596 output,
5597 command: format_command(&command_result.command, &command_result.args),
5598 token_usage: usage,
5599 tool_calls,
5600 sub_agents,
5601 role: options.role,
5602 iteration: options.iteration,
5603 total_iterations: options.total_iterations,
5604 team_size: options.team_size,
5605 }
5606}
5607
5608fn compact_failure(result: &CommandResult) -> String {
5609 if let Some(error) = &result.error {
5610 return error.clone();
5611 }
5612 if !result.stderr.trim().is_empty() {
5613 return result.stderr.trim().to_string();
5614 }
5615 if let Some(detail) = provider_failure_detail(&result.stdout) {
5616 return detail;
5617 }
5618 if !result.stdout.trim().is_empty() {
5619 return result.stdout.trim().to_string();
5620 }
5621 result
5622 .code
5623 .map(|code| format!("Exited with code {code}."))
5624 .unwrap_or_else(|| "Command failed.".to_string())
5625}
5626
5627fn provider_failure_detail(stdout: &str) -> Option<String> {
5628 for value in json_values(stdout).into_iter().rev() {
5629 if let Some(message) = value
5630 .pointer("/error/message")
5631 .or_else(|| value.get("message"))
5632 .and_then(Value::as_str)
5633 .map(sanitize_status_detail)
5634 .filter(|message| !message.trim().is_empty())
5635 {
5636 return Some(message);
5637 }
5638 if value.get("type").and_then(Value::as_str) == Some("result")
5639 && value.get("status").and_then(Value::as_str) == Some("error")
5640 {
5641 return Some("Provider returned an error result.".to_string());
5642 }
5643 }
5644 None
5645}
5646
5647fn command_telemetry(result: &CommandResult) -> CommandTelemetry {
5648 let status = if result.cancelled {
5649 "cancelled"
5650 } else if let Some(error) = result.error.as_deref() {
5651 spawn_failure_status(error)
5652 } else if result.timed_out {
5653 "timeout"
5654 } else if result.code == Some(0) {
5655 "ok"
5656 } else {
5657 "error"
5658 };
5659 CommandTelemetry {
5660 command: format_command(&result.command, &result.args),
5661 status: status.to_string(),
5662 detail: truncate(&sanitize_status_detail(&compact_failure(result)), 600),
5663 exit_code: result.code,
5664 duration_ms: result.duration_ms,
5665 stdout_chars: result.stdout.len(),
5666 stderr_chars: result.stderr.len(),
5667 timed_out: result.timed_out,
5668 }
5669}
5670
5671fn spawn_error_status(error: &io::Error) -> &'static str {
5672 if error.kind() == io::ErrorKind::NotFound {
5673 "missing"
5674 } else {
5675 "error"
5676 }
5677}
5678
5679fn spawn_failure_status(error: &str) -> &'static str {
5680 let lowered = error.to_ascii_lowercase();
5681 if lowered.contains("no such file")
5682 || lowered.contains("not found")
5683 || lowered.contains("cannot find")
5684 {
5685 "missing"
5686 } else {
5687 "error"
5688 }
5689}
5690
5691fn parse_codex_output(stdout: &str) -> String {
5692 stdout.trim().to_string()
5693}
5694
5695fn parse_claude_output(stdout: &str) -> String {
5696 let trimmed = stdout.trim();
5697 if trimmed.is_empty() {
5698 return String::new();
5699 }
5700 if let Some(value) = extract_json(trimmed) {
5701 if let Some(result) = value.get("result").and_then(Value::as_str) {
5702 return result.trim().to_string();
5703 }
5704 }
5705 let mut latest = String::new();
5706 let mut streamed_text = String::new();
5707 for line in trimmed.lines() {
5708 let Ok(value) = serde_json::from_str::<Value>(line) else {
5709 continue;
5710 };
5711 if value.get("type").and_then(Value::as_str) == Some("result") {
5712 if let Some(result) = value.get("result").and_then(Value::as_str) {
5713 latest = result.trim().to_string();
5714 }
5715 continue;
5716 }
5717 if let Some(text) = claude_output_text(&value) {
5718 if value.get("type").and_then(Value::as_str) == Some("stream_event") {
5719 streamed_text.push_str(&text);
5720 latest = streamed_text.trim().to_string();
5721 } else if !text.trim().is_empty() {
5722 latest = text.trim().to_string();
5723 }
5724 }
5725 }
5726 if latest.is_empty() {
5727 trimmed.to_string()
5728 } else {
5729 latest
5730 }
5731}
5732
5733fn parse_gemini_output(stdout: &str) -> String {
5734 let trimmed = stdout.trim();
5735 if trimmed.is_empty() {
5736 return String::new();
5737 }
5738 if let Some(value) = extract_json(trimmed) {
5739 if let Some(response) = value.get("response").and_then(Value::as_str) {
5740 return response.trim().to_string();
5741 }
5742 if let Some(text) = value.get("text").and_then(Value::as_str) {
5743 return text.trim().to_string();
5744 }
5745 }
5746 let mut assistant = String::new();
5747 let mut latest_error = String::new();
5748 for value in json_values(trimmed) {
5749 match value.get("type").and_then(Value::as_str) {
5750 Some("message") if value.get("role").and_then(Value::as_str) == Some("assistant") => {
5751 if let Some(content) = value.get("content").and_then(Value::as_str) {
5752 assistant.push_str(content);
5753 }
5754 }
5755 Some("error") => {
5756 if let Some(message) = value.get("message").and_then(Value::as_str) {
5757 latest_error = sanitize_status_detail(message);
5758 }
5759 }
5760 Some("result") if value.get("status").and_then(Value::as_str) == Some("error") => {
5761 if let Some(message) = value.pointer("/error/message").and_then(Value::as_str) {
5762 latest_error = sanitize_status_detail(message);
5763 }
5764 }
5765 _ => {}
5766 }
5767 }
5768 if !assistant.trim().is_empty() {
5769 assistant.trim().to_string()
5770 } else if !latest_error.trim().is_empty() {
5771 latest_error
5772 } else {
5773 trimmed.to_string()
5774 }
5775}
5776
5777fn extract_json(text: &str) -> Option<Value> {
5778 serde_json::from_str::<Value>(text).ok().or_else(|| {
5779 let start = text.find('{')?;
5780 let end = text.rfind('}')?;
5781 serde_json::from_str::<Value>(&text[start..=end]).ok()
5782 })
5783}
5784
5785fn build_member_prompt(input: MemberPromptInput<'_>) -> String {
5786 let mut sections = vec![
5787 "You are one member of a multi-model Amon Hen run.".to_string(),
5788 format!(
5789 "Amon Hen workflow: iteration {iteration} of {}.",
5790 input.workflow.iterations,
5791 iteration = input.iteration
5792 ),
5793 input
5794 .workflow
5795 .lead
5796 .as_ref()
5797 .map(|lead| format!("Lead model: {lead}."))
5798 .unwrap_or_else(|| "Lead model: auto.".to_string()),
5799 input
5800 .workflow
5801 .planner
5802 .as_ref()
5803 .map(|planner| format!("Planner model: {planner}."))
5804 .unwrap_or_else(|| "Planner model: none.".to_string()),
5805 format!("Your assigned role: {}.", input.role),
5806 role_instruction(input.role).to_string(),
5807 "Answer the user query directly.".to_string(),
5808 "Do not introduce yourself.".to_string(),
5809 "Do not describe your tools, environment, or capabilities unless the user explicitly asks."
5810 .to_string(),
5811 "Be concise unless the user asks for depth.".to_string(),
5812 ];
5813 if input.team_size > 0 {
5814 sections.push(format!(
5815 "Team work: you may coordinate up to {} internal sub-agents or subtasks inside your own CLI if that helps.",
5816 input.team_size
5817 ));
5818 }
5819 if input.workflow.handoff {
5820 sections.push(
5821 "Handoff mode is enabled. Treat earlier Amon Hen outputs as working context."
5822 .to_string(),
5823 );
5824 }
5825 if input.workflow.planner_mode == PLANNER_MODE_REVIEW_CHAIN {
5826 sections.push(
5827 "Review-chain mode is enabled. Each Amon Hen member runs after the previous member. \
5828 Review prior agent output and the current repo state before changing anything, preserve accepted work, \
5829 call out conflicts, and only make deliberate deltas that survive that review."
5830 .to_string(),
5831 );
5832 }
5833 if input.workflow.handshake {
5834 sections.push(
5835 "Three-way handshake mode is enabled. The planner, executor, and reviewer are distinct Amon Hen agent roles even when they use the same underlying provider/model. Treat prior handoffs as agent-to-agent context, not your own earlier answer."
5836 .to_string(),
5837 );
5838 }
5839 if input.workflow.owner_map == OWNER_MAP_STRICT {
5840 sections.push(
5841 "Strict ownership is enabled. Before editing, state the files or scopes you own. \
5842 Keep changes inside that scope, hand off unclear overlap for review, and block rather than overwrite another agent's work."
5843 .to_string(),
5844 );
5845 }
5846 if !input.plan_output.trim().is_empty() {
5847 sections.push(format!(
5848 "Planner handoff:\n{}",
5849 bounded_context_text(
5850 input.plan_output.trim(),
5851 input.max_member_chars,
5852 "planner handoff"
5853 )
5854 ));
5855 }
5856 let context = input
5857 .previous_iteration
5858 .iter()
5859 .chain(input.handoff_results.iter())
5860 .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
5861 .map(|result| {
5862 let output =
5863 bounded_context_text(result.output.trim(), input.max_member_chars, "handoff");
5864 format!("### {} ({})\n{}", result.name, result.role, output)
5865 })
5866 .collect::<Vec<_>>()
5867 .join("\n\n");
5868 if !context.is_empty() {
5869 sections.push(format!("Earlier Amon Hen handoffs:\n{context}"));
5870 }
5871 sections.push(format!("Current user query:\n{}", input.query.trim()));
5872 limit_provider_prompt(§ions.join("\n\n"), input.max_prompt_chars)
5873}
5874
5875fn build_summary_prompt(
5876 query: &str,
5877 responses: &[EngineResult],
5878 workflow: &Workflow,
5879 max_member_chars: usize,
5880) -> String {
5881 let blocks = responses
5882 .iter()
5883 .map(|response| {
5884 let output = truncate(&response.output, max_member_chars);
5885 format!("### {}\n{}", response.name, output.trim())
5886 })
5887 .collect::<Vec<_>>()
5888 .join("\n\n");
5889 format!(
5890 "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{}",
5891 workflow.iterations,
5892 if workflow.iterations == 1 { "" } else { "s" },
5893 if workflow.handoff { "handoff enabled" } else { "parallel consultation" },
5894 workflow.lead.as_deref().unwrap_or("auto"),
5895 workflow.planner.as_deref().unwrap_or("none"),
5896 query.trim(),
5897 blocks
5898 )
5899}
5900
5901fn role_instruction(role: &str) -> &'static str {
5902 match role {
5903 "planner" => "Plan the work: identify the approach, risks, checkpoints, and useful handoffs for the executors.",
5904 "executor" => "Execute the work: use the planner handoff and current evidence, make the strongest safe implementation attempt, and leave reviewable output.",
5905 "reviewer" => "Review the planner and executor handoffs plus the current state. Verify tests, risks, regressions, ownership, and whether the implementation should be accepted, revised, or blocked.",
5906 "lead" => "Lead the work: make the strongest direct attempt while watching for conflicts you may need to resolve in synthesis.",
5907 "lead+planner" => "Plan and lead the work: produce a practical plan, then make the strongest direct attempt from that plan.",
5908 _ => "Execute the work: use any plan or handoff context, then produce your independent best answer.",
5909 }
5910}
5911
5912fn execution_order(members: &[String], planner: Option<&str>, lead: Option<&str>) -> Vec<String> {
5913 let mut ordered = Vec::new();
5914 if let Some(planner) = planner {
5915 if members.iter().any(|member| member == planner) {
5916 ordered.push(planner.to_string());
5917 }
5918 }
5919 ordered.extend(
5920 members
5921 .iter()
5922 .filter(|member| Some(member.as_str()) != planner && Some(member.as_str()) != lead)
5923 .cloned(),
5924 );
5925 if lead != planner {
5926 if let Some(lead) = lead {
5927 if members.iter().any(|member| member == lead) {
5928 ordered.push(lead.to_string());
5929 }
5930 }
5931 }
5932 ordered
5933}
5934
5935fn role_for(member: &str, workflow: &Workflow) -> String {
5936 let is_lead = workflow.lead.as_deref() == Some(member);
5937 let is_planner = workflow.planner.as_deref() == Some(member);
5938 match (is_lead, is_planner) {
5939 (true, true) => "lead+planner",
5940 (true, false) => "lead",
5941 (false, true) => "planner",
5942 (false, false) => "executor",
5943 }
5944 .to_string()
5945}
5946
5947fn pick_summarizer(resolved: &ResolvedArgs, successes: &[EngineResult]) -> String {
5948 if resolved.raw.summarizer != "auto" {
5949 return resolved.raw.summarizer.clone();
5950 }
5951 if let Some(lead) = &resolved.raw.lead {
5952 if successes.iter().any(|result| &result.name == lead) {
5953 return lead.clone();
5954 }
5955 }
5956 DEFAULT_SUMMARIZER_ORDER
5957 .iter()
5958 .find(|name| successes.iter().any(|result| result.name == **name))
5959 .unwrap_or(&successes[0].name.as_str())
5960 .to_string()
5961}
5962
5963fn resolve_binary(name: &str) -> String {
5964 let Some(engine) = Engine::parse(name) else {
5965 return name.to_string();
5966 };
5967 std::env::var(engine.binary_env_var())
5968 .ok()
5969 .filter(|value| !value.trim().is_empty())
5970 .unwrap_or_else(|| engine.as_str().to_string())
5971}
5972
5973fn token_usage(prompt: &str, output: &str) -> TokenUsage {
5974 let input = estimate_tokens(prompt);
5975 let output = estimate_tokens(output);
5976 TokenUsage {
5977 input,
5978 output,
5979 total: input + output,
5980 estimated: true,
5981 source: "estimate".to_string(),
5982 }
5983}
5984
5985fn aggregate_token_usage(mut usage: TokenUsage, sub_agents: &[EngineResult]) -> TokenUsage {
5986 if sub_agents.is_empty() {
5987 return usage;
5988 }
5989 for agent in sub_agents {
5990 usage.input += agent.token_usage.input;
5991 usage.output += agent.token_usage.output;
5992 usage.total += agent.token_usage.total;
5993 usage.estimated |= agent.token_usage.estimated;
5994 }
5995 usage.source = format!("{}+sub-agents", usage.source);
5996 usage
5997}
5998
5999fn aggregate_results_token_usage<'a>(
6000 results: impl IntoIterator<Item = &'a EngineResult>,
6001) -> TokenUsage {
6002 let mut usage = TokenUsage {
6003 input: 0,
6004 output: 0,
6005 total: 0,
6006 estimated: false,
6007 source: "aggregate".to_string(),
6008 };
6009 for result in results {
6010 usage.input += result.token_usage.input;
6011 usage.output += result.token_usage.output;
6012 usage.total += result.token_usage.total;
6013 usage.estimated |= result.token_usage.estimated;
6014 }
6015 usage
6016}
6017
6018fn extract_token_usage(stdout: &str, prompt: &str, output: &str) -> Option<TokenUsage> {
6019 let mut usage = TokenAccumulator::default();
6020 for value in json_values(stdout) {
6021 collect_token_counts(&value, &mut usage);
6022 }
6023 if usage.input.is_none() && usage.output.is_none() && usage.total.is_none() {
6024 return None;
6025 }
6026 let input = usage.input.unwrap_or_else(|| estimate_tokens(prompt));
6027 let output = usage.output.unwrap_or_else(|| estimate_tokens(output));
6028 let total = usage.total.unwrap_or(input + output);
6029 Some(TokenUsage {
6030 input,
6031 output,
6032 total,
6033 estimated: usage.input.is_none() || usage.output.is_none(),
6034 source: "provider".to_string(),
6035 })
6036}
6037
6038#[derive(Default)]
6039struct TokenAccumulator {
6040 input: Option<usize>,
6041 output: Option<usize>,
6042 total: Option<usize>,
6043}
6044
6045fn collect_token_counts(value: &Value, usage: &mut TokenAccumulator) {
6046 match value {
6047 Value::Object(map) => {
6048 for (key, value) in map {
6049 let normalized = key.replace(['_', '-'], "").to_ascii_lowercase();
6050 if let Some(count) = value.as_u64().map(|value| value as usize) {
6051 match normalized.as_str() {
6052 "inputtokens" | "inputtokencount" | "prompttokens" | "prompttokencount" => {
6053 usage.input = Some(count)
6054 }
6055 "outputtokens"
6056 | "outputtokencount"
6057 | "completiontokens"
6058 | "candidatestokencount" => usage.output = Some(count),
6059 "totaltokens" | "totaltokencount" => usage.total = Some(count),
6060 _ => {}
6061 }
6062 }
6063 collect_token_counts(value, usage);
6064 }
6065 }
6066 Value::Array(values) => {
6067 for value in values {
6068 collect_token_counts(value, usage);
6069 }
6070 }
6071 _ => {}
6072 }
6073}
6074
6075fn extract_tool_usage(provider: &str, stdout: &str, stderr: &str) -> Vec<ToolUsage> {
6076 let mut tools = Vec::new();
6077 for value in json_values(stdout) {
6078 collect_tool_usage(&value, &mut tools);
6079 }
6080 for line in stdout.lines().chain(stderr.lines()) {
6081 if let Some(tool) = line_tool_usage(provider, line) {
6082 tools.push(tool);
6083 }
6084 }
6085 dedupe_tools(tools)
6086}
6087
6088fn collect_tool_usage(value: &Value, tools: &mut Vec<ToolUsage>) {
6089 match value {
6090 Value::Object(map) => {
6091 let type_name = map.get("type").and_then(Value::as_str).unwrap_or_default();
6092 let name = map
6093 .get("name")
6094 .or_else(|| map.get("tool"))
6095 .or_else(|| map.get("tool_name"))
6096 .or_else(|| map.get("toolName"))
6097 .and_then(Value::as_str);
6098 if let Some(name) = name.filter(|_| {
6099 type_name.contains("tool")
6100 || map.contains_key("input")
6101 || map.contains_key("arguments")
6102 || map.contains_key("toolCall")
6103 }) {
6104 tools.push(ToolUsage {
6105 name: name.to_string(),
6106 kind: if type_name.is_empty() {
6107 "tool"
6108 } else {
6109 type_name
6110 }
6111 .to_string(),
6112 status: "observed".to_string(),
6113 detail: short_json_detail(value),
6114 });
6115 }
6116 for value in map.values() {
6117 collect_tool_usage(value, tools);
6118 }
6119 }
6120 Value::Array(values) => {
6121 for value in values {
6122 collect_tool_usage(value, tools);
6123 }
6124 }
6125 _ => {}
6126 }
6127}
6128
6129fn line_tool_usage(provider: &str, line: &str) -> Option<ToolUsage> {
6130 let trimmed = line.trim();
6131 if looks_like_json_line(trimmed) {
6132 return None;
6133 }
6134 let lowered = trimmed.to_ascii_lowercase();
6135 let marker = ["running shell:", "tool:", "tool_use", "mcp:", "command:"]
6136 .iter()
6137 .find(|marker| lowered.contains(**marker))?;
6138 Some(ToolUsage {
6139 name: marker.trim_end_matches(':').to_string(),
6140 kind: provider.to_string(),
6141 status: "observed".to_string(),
6142 detail: truncate(trimmed, 240),
6143 })
6144}
6145
6146fn short_json_detail(value: &Value) -> String {
6147 serde_json::to_string(value)
6148 .map(|text| truncate(&text, 240))
6149 .unwrap_or_default()
6150}
6151
6152fn dedupe_tools(tools: Vec<ToolUsage>) -> Vec<ToolUsage> {
6153 let mut seen = HashSet::new();
6154 tools
6155 .into_iter()
6156 .filter(|tool| seen.insert(format!("{}:{}:{}", tool.kind, tool.name, tool.detail)))
6157 .collect()
6158}
6159
6160fn json_values(text: &str) -> Vec<Value> {
6161 let trimmed = text.trim();
6162 if trimmed.is_empty() {
6163 return vec![];
6164 }
6165 let mut values = Vec::new();
6166 if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
6167 values.push(value);
6168 return values;
6169 }
6170 for line in trimmed.lines() {
6171 if let Ok(value) = serde_json::from_str::<Value>(line.trim()) {
6172 values.push(value);
6173 }
6174 }
6175 values
6176}
6177
6178fn looks_like_json_line(line: &str) -> bool {
6179 let trimmed = line.trim_start();
6180 trimmed.starts_with('{') || trimmed.starts_with('[')
6181}
6182
6183fn estimate_tokens(text: &str) -> usize {
6184 if text.trim().is_empty() {
6185 0
6186 } else {
6187 text.len().div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN)
6188 }
6189}
6190
6191fn format_command(command: &str, args: &[String]) -> String {
6192 std::iter::once(command.to_string())
6193 .chain(args.iter().map(|arg| shell_quote(arg)))
6194 .collect::<Vec<_>>()
6195 .join(" ")
6196}
6197
6198fn shell_quote(value: &str) -> String {
6199 if value
6200 .chars()
6201 .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
6202 {
6203 value.to_string()
6204 } else {
6205 serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
6206 }
6207}
6208
6209fn truncate(text: &str, max_chars: usize) -> String {
6210 if let Some(index) = byte_index_after_chars(text, max_chars) {
6211 let mut truncated = String::with_capacity(index + "\n...[truncated]".len());
6212 truncated.push_str(&text[..index]);
6213 truncated.push_str("\n...[truncated]");
6214 truncated
6215 } else {
6216 text.to_string()
6217 }
6218}
6219
6220fn bounded_context_text(text: &str, max_chars: usize, label: &str) -> String {
6221 let total = text.chars().count();
6222 if total <= max_chars {
6223 return text.to_string();
6224 }
6225 if max_chars < 96 {
6226 return truncate(text, max_chars);
6227 }
6228 let notice = format!(
6229 "\n\n[amon-hen: {label} truncated from {total} chars to {max_chars} chars to stay under provider prompt limits.]\n\n"
6230 );
6231 let notice_len = notice.chars().count();
6232 if notice_len >= max_chars {
6233 return take_chars(text, max_chars);
6234 }
6235 let keep = max_chars - notice_len;
6236 let head = keep.saturating_mul(2) / 3;
6237 let tail = keep.saturating_sub(head);
6238 format!(
6239 "{}{}{}",
6240 take_chars(text, head),
6241 notice,
6242 take_tail_chars(text, tail)
6243 )
6244}
6245
6246fn limit_provider_prompt(prompt: &str, max_chars: usize) -> String {
6247 let max_chars = max_chars.min(PROVIDER_PROMPT_HARD_LIMIT_CHARS);
6248 let total = prompt.chars().count();
6249 if total <= max_chars {
6250 return prompt.to_string();
6251 }
6252 bounded_context_text(prompt, max_chars, "provider prompt")
6253}
6254
6255fn take_chars(text: &str, max_chars: usize) -> String {
6256 byte_index_after_chars(text, max_chars)
6257 .map(|index| text[..index].to_string())
6258 .unwrap_or_else(|| text.to_string())
6259}
6260
6261fn take_tail_chars(text: &str, max_chars: usize) -> String {
6262 byte_index_for_tail_chars(text, max_chars)
6263 .map(|index| text[index..].to_string())
6264 .unwrap_or_else(|| text.to_string())
6265}
6266
6267fn byte_index_after_chars(text: &str, max_chars: usize) -> Option<usize> {
6268 if max_chars == 0 {
6269 return (!text.is_empty()).then_some(0);
6270 }
6271 for (count, (index, _)) in text.char_indices().enumerate() {
6272 if count == max_chars {
6273 return Some(index);
6274 }
6275 }
6276 None
6277}
6278
6279fn byte_index_for_tail_chars(text: &str, max_chars: usize) -> Option<usize> {
6280 if max_chars == 0 {
6281 return (!text.is_empty()).then_some(text.len());
6282 }
6283 for (count, (index, _)) in text.char_indices().rev().enumerate() {
6284 if count + 1 == max_chars {
6285 return Some(index);
6286 }
6287 }
6288 None
6289}
6290
6291fn should_show_banner(raw: &CliArgs) -> bool {
6292 !raw.no_banner && !raw.headless && !raw.json && !raw.json_stream && !raw.plain
6293}
6294
6295fn linear_delivery_requested(raw: &CliArgs) -> bool {
6296 raw.deliver_linear
6297 || raw.linear_until_complete
6298 || raw.linear_watch
6299 || !raw.linear_issue.is_empty()
6300 || raw
6301 .linear_query
6302 .as_ref()
6303 .is_some_and(|value| !value.trim().is_empty())
6304 || !raw.linear_project.is_empty()
6305 || !raw.linear_epic.is_empty()
6306}
6307
6308fn render_banner() -> &'static str {
6309 " _ _ _ \n / \\ _ __ ___ ___ _ __ | | | | ___ _ __ \n / _ \\ | '_ ` _ \\ / _ \\| '_ \\| |_| |/ _ \\ '_ \\ \n / ___ \\| | | | | | (_) | | | | _ | __/ | | |\n/_/ \\_\\_| |_| |_|\\___/|_| |_|_| |_|\\___|_| |_|"
6310}
6311
6312fn render_human_result(result: &AmonHenResult, verbose: bool) -> String {
6313 let mut lines = Vec::new();
6314 if verbose {
6315 lines.push(format!(
6316 "Amon Hen consulted: {}",
6317 result.members_requested.join(", ")
6318 ));
6319 for command in &result.prompt_commands {
6320 lines.push(format!(
6321 "cmd [{}] {} ({:.1}s)",
6322 command.status,
6323 command.command,
6324 command.duration_ms as f64 / 1000.0
6325 ));
6326 }
6327 for (index, member) in result.members.iter().enumerate() {
6328 lines.push(format!(
6329 "{}. [{}] {} ({:.1}s, tokens:{}, tools:{}, sub-agents:{}){}",
6330 index + 1,
6331 member.status,
6332 member.name,
6333 member.duration_ms as f64 / 1000.0,
6334 member.token_usage.total,
6335 member.tool_calls.len(),
6336 member.sub_agents.len(),
6337 if member.detail.is_empty() { "" } else { ": " }
6338 ));
6339 if !member.detail.is_empty() {
6340 lines.push(format!(" {}", member.detail));
6341 }
6342 for sub_agent in &member.sub_agents {
6343 lines.push(format!(
6344 " - {} [{}] tokens:{} tools:{}",
6345 sub_agent.role,
6346 sub_agent.status,
6347 sub_agent.token_usage.total,
6348 sub_agent.tool_calls.len()
6349 ));
6350 }
6351 if member.status == "ok" {
6352 lines.push(indent(&member.output, " "));
6353 }
6354 }
6355 if let Some(consensus) = &result.consensus {
6356 lines.push("----------- consensus -----------".to_string());
6357 lines.push(format!(
6358 "consensus [{}] iteration:{} rounds:{} reviewers:{}",
6359 consensus.status,
6360 consensus.iteration,
6361 consensus.rounds.len(),
6362 consensus.config.reviewers.join(",")
6363 ));
6364 for blocker in &consensus.blockers {
6365 lines.push(format!(" blocker: {blocker}"));
6366 }
6367 }
6368 lines.push("----------- synthesis -----------".to_string());
6369 }
6370 if let Some(consensus) = &result.consensus {
6371 if consensus.config.mode == CONSENSUS_REQUIRED && !consensus.approved {
6372 lines.push(format!(
6373 "Consensus blocked after {} round(s): {}",
6374 consensus.rounds.len(),
6375 if consensus.blockers.is_empty() {
6376 "no reviewer consensus".to_string()
6377 } else {
6378 consensus.blockers.join("; ")
6379 }
6380 ));
6381 }
6382 }
6383 if result.summary.status == "ok" {
6384 lines.push(result.summary.output.trim().to_string());
6385 } else {
6386 lines.push(format!("Synthesis failed: {}", result.summary.detail));
6387 }
6388 lines.join("\n")
6389}
6390
6391fn indent(text: &str, prefix: &str) -> String {
6392 text.lines()
6393 .map(|line| format!("{prefix}{line}"))
6394 .collect::<Vec<_>>()
6395 .join("\n")
6396}
6397
6398fn is_success(result: &AmonHenResult) -> bool {
6399 let consensus_ok = result
6400 .consensus
6401 .as_ref()
6402 .map(|consensus| consensus.config.mode != CONSENSUS_REQUIRED || consensus.approved)
6403 .unwrap_or(true);
6404 result.members.iter().any(|member| member.status == "ok")
6405 && result.summary.status == "ok"
6406 && consensus_ok
6407}
6408
6409#[cfg(test)]
6410mod tests {
6411 use super::*;
6412 use std::sync::Mutex as TestMutex;
6413
6414 static ENV_LOCK: TestMutex<()> = TestMutex::new(());
6415
6416 struct ProviderEnvGuard {
6417 saved: Vec<(&'static str, Option<OsString>)>,
6418 }
6419
6420 impl ProviderEnvGuard {
6421 fn install_echo_bins() -> Option<Self> {
6422 let echo = Path::new("/bin/echo");
6423 if !echo.is_file() {
6424 return None;
6425 }
6426 let vars = [
6427 "AMON_HEN_CODEX_BIN",
6428 "AMON_HEN_CLAUDE_BIN",
6429 "AMON_HEN_GEMINI_BIN",
6430 ];
6431 let saved = vars
6432 .into_iter()
6433 .map(|name| (name, std::env::var_os(name)))
6434 .collect::<Vec<_>>();
6435 for (name, _) in &saved {
6436 unsafe {
6437 std::env::set_var(name, echo);
6438 }
6439 }
6440 Some(Self { saved })
6441 }
6442 }
6443
6444 impl Drop for ProviderEnvGuard {
6445 fn drop(&mut self) {
6446 for (name, value) in &self.saved {
6447 unsafe {
6448 if let Some(value) = value {
6449 std::env::set_var(name, value);
6450 } else {
6451 std::env::remove_var(name);
6452 }
6453 }
6454 }
6455 }
6456 }
6457
6458 #[test]
6459 fn help_flags_use_success_exit_code() {
6460 let long_help = CliArgs::try_parse_from(["amon-hen", "--help"]).unwrap_err();
6461 let short_help = CliArgs::try_parse_from(["amon-hen", "-h"]).unwrap_err();
6462
6463 assert_eq!(long_help.kind(), ErrorKind::DisplayHelp);
6464 assert_eq!(short_help.kind(), ErrorKind::DisplayHelp);
6465 assert_eq!(parse_error_exit_code(long_help.kind()), 0);
6466 assert_eq!(parse_error_exit_code(short_help.kind()), 0);
6467 }
6468
6469 #[test]
6470 fn rendered_help_is_grouped_and_compact() {
6471 let help = render_cli_help();
6472
6473 assert!(help.contains("Core run:"));
6474 assert!(help.contains("Models, effort, and permissions:"));
6475 assert!(help.contains("Auth and provider capabilities:"));
6476 assert!(help.contains("Linear delivery:"));
6477 assert!(help.contains("--members codex,claude,gemini"));
6478 assert!(help.contains("--claude-permission-mode MODE"));
6479 assert!(help.contains("--gemini-approval-mode MODE"));
6480 assert!(help.contains("--resume RUN_DIR"));
6481 assert!(help.contains("--check-update"));
6482 assert!(help.contains("--update"));
6483 assert!(help.contains("blocking|parallel|review-chain|handshake"));
6484 assert!(help.contains("--consensus off|required"));
6485 assert!(help.contains("--failure-policy continue|takeover"));
6486 assert!(help.contains("--owner-map flexible|strict"));
6487 assert!(!help.contains("\n\n\n"));
6488 }
6489
6490 #[test]
6491 fn parses_resume_run_directory() {
6492 let args = CliArgs::try_parse_from([
6493 "amon-hen",
6494 "--studio",
6495 "--resume",
6496 ".amon-hen/runs/example",
6497 "Continue the run",
6498 ])
6499 .unwrap();
6500
6501 assert_eq!(args.resume, Some(PathBuf::from(".amon-hen/runs/example")));
6502 assert_eq!(args.prompt, vec!["Continue the run"]);
6503 }
6504
6505 #[test]
6506 fn update_flags_parse_without_prompt() {
6507 let check = CliArgs::try_parse_from(["amon-hen", "--check-update"]).unwrap();
6508 let update = CliArgs::try_parse_from(["amon-hen", "--update"]).unwrap();
6509 let disabled = CliArgs::try_parse_from(["amon-hen", "--no-update-check", "hello"]).unwrap();
6510
6511 assert!(check.check_update);
6512 assert!(update.update);
6513 assert!(disabled.no_update_check);
6514 }
6515
6516 #[test]
6517 fn version_comparison_handles_multi_digit_patches() {
6518 assert_eq!(version_cmp("0.1.25", "0.1.9"), std::cmp::Ordering::Greater);
6519 assert_eq!(version_cmp("v1.2.0", "1.2"), std::cmp::Ordering::Equal);
6520 assert_eq!(version_cmp("0.2.0", "0.10.0"), std::cmp::Ordering::Less);
6521 }
6522
6523 #[test]
6524 fn update_changelog_extracts_versions_between_current_and_latest() {
6525 let changelog = "\
6526# Changelog
6527
6528## 0.1.27
6529- latest
6530
6531## 0.1.26
6532- middle
6533
6534## 0.1.25
6535- current
6536";
6537 let extracted = extract_changelog_range(changelog, "0.1.25", "0.1.27").unwrap();
6538
6539 assert!(extracted.contains("## 0.1.27"));
6540 assert!(extracted.contains("- latest"));
6541 assert!(extracted.contains("## 0.1.26"));
6542 assert!(!extracted.contains("## 0.1.25"));
6543 }
6544
6545 #[test]
6546 fn parses_members_and_provider_flags() {
6547 let args = CliArgs::try_parse_from([
6548 "amon-hen",
6549 "--members",
6550 "codex,claude",
6551 "--planner",
6552 "codex",
6553 "--planner-mode",
6554 "parallel",
6555 "--handoff",
6556 "--lead",
6557 "claude",
6558 "--codex-effort",
6559 "xhigh",
6560 "--claude-capabilities",
6561 "override",
6562 "--claude-allowed-tools",
6563 "Read,Bash(git:*)",
6564 "--claude-tools",
6565 "Read,Edit",
6566 "--claude-agent",
6567 "reviewer",
6568 "--gemini-allowed-mcp-servers",
6569 "linear,github",
6570 "ship it",
6571 ])
6572 .unwrap();
6573 let resolved = resolve_args(args).unwrap();
6574 assert_eq!(resolved.members, vec!["codex", "claude"]);
6575 assert_eq!(resolved.raw.planner_mode, PLANNER_MODE_PARALLEL);
6576 assert_eq!(resolved.raw.codex_effort.as_deref(), Some("xhigh"));
6577 assert_eq!(
6578 resolved.raw.claude_allowed_tools,
6579 vec!["Read", "Bash(git:*)"]
6580 );
6581 assert_eq!(resolved.raw.claude_tools, vec!["Read", "Edit"]);
6582 assert_eq!(resolved.raw.claude_agent.as_deref(), Some("reviewer"));
6583 assert_eq!(
6584 resolved.raw.gemini_allowed_mcp_servers,
6585 vec!["linear", "github"]
6586 );
6587 }
6588
6589 #[test]
6590 fn parses_consensus_gate_flags() {
6591 let args = CliArgs::try_parse_from([
6592 "amon-hen",
6593 "--members",
6594 "codex,claude,gemini",
6595 "--consensus",
6596 "required",
6597 "--consensus-reviewers",
6598 "codex,claude,gemini",
6599 "--failure-policy",
6600 "takeover",
6601 "--review-rounds",
6602 "3",
6603 "--require-final-diff-review",
6604 "--require-tests",
6605 "--require-secret-scan",
6606 "--require-clean-git-diff",
6607 "--stop-when",
6608 "consensus",
6609 "--owner-map",
6610 "strict",
6611 "ship it",
6612 ])
6613 .unwrap();
6614 let resolved = resolve_args(args).unwrap();
6615 let config = consensus_config(&resolved);
6616
6617 assert_eq!(config.mode, CONSENSUS_REQUIRED);
6618 assert_eq!(config.reviewers, vec!["codex", "claude", "gemini"]);
6619 assert_eq!(config.failure_policy, FAILURE_POLICY_TAKEOVER);
6620 assert_eq!(config.review_rounds, 3);
6621 assert!(config.require_final_diff_review);
6622 assert!(config.require_tests);
6623 assert!(config.require_secret_scan);
6624 assert!(config.require_clean_git_diff);
6625 assert_eq!(config.stop_when, STOP_WHEN_CONSENSUS);
6626 assert_eq!(config.owner_map, OWNER_MAP_STRICT);
6627 }
6628
6629 #[test]
6630 fn parses_machine_readable_consensus_review_footer() {
6631 let output = "Reviewed diff and tests.\nCONSENSUS: approve\nBLOCKERS: none\nTAKEOVER_FOR: gemini, codex";
6632
6633 assert!(consensus_output_approves(output));
6634 assert!(consensus_output_blockers(output).is_empty());
6635 assert_eq!(
6636 consensus_output_takeover_for(output),
6637 vec!["gemini".to_string(), "codex".to_string()]
6638 );
6639 }
6640
6641 #[test]
6642 fn required_consensus_blocks_success_until_approved() {
6643 let mut result = test_amon_hen_result(vec![test_engine_result("codex", "planner", 1)], 1);
6644 result.consensus = Some(ConsensusResult {
6645 status: "blocked".to_string(),
6646 iteration: 1,
6647 approved: false,
6648 config: ConsensusConfig {
6649 mode: CONSENSUS_REQUIRED.to_string(),
6650 reviewers: vec!["codex".to_string()],
6651 failure_policy: FAILURE_POLICY_TAKEOVER.to_string(),
6652 review_rounds: 1,
6653 require_final_diff_review: true,
6654 require_tests: true,
6655 require_secret_scan: true,
6656 require_clean_git_diff: true,
6657 stop_when: STOP_WHEN_CONSENSUS.to_string(),
6658 owner_map: OWNER_MAP_STRICT.to_string(),
6659 },
6660 rounds: vec![],
6661 blockers: vec!["reviewer did not approve".to_string()],
6662 takeover_required: false,
6663 takeover_by: vec![],
6664 });
6665
6666 assert!(!is_success(&result));
6667 result.consensus.as_mut().unwrap().approved = true;
6668 result.consensus.as_mut().unwrap().status = "approved".to_string();
6669 assert!(is_success(&result));
6670 }
6671
6672 #[test]
6673 fn planner_parallel_mode_keeps_executors_unblocked() {
6674 let args = CliArgs::try_parse_from([
6675 "amon-hen",
6676 "--members",
6677 "codex,claude,gemini",
6678 "--planner",
6679 "claude",
6680 "--planner-mode",
6681 "parallel",
6682 "--handoff",
6683 "--lead",
6684 "claude",
6685 "ship it",
6686 ])
6687 .unwrap();
6688 let resolved = resolve_args(args).unwrap();
6689 let workflow = build_workflow(&resolved);
6690 let ordered = execution_order(
6691 &resolved.members,
6692 workflow.planner.as_deref(),
6693 workflow.lead.as_deref(),
6694 );
6695 let planner = workflow
6696 .planner
6697 .as_ref()
6698 .filter(|planner| ordered.contains(planner))
6699 .cloned();
6700 let blocking_planner = workflow.planner_mode == PLANNER_MODE_BLOCKING;
6701 let executors = ordered
6702 .into_iter()
6703 .filter(|member| !(blocking_planner && Some(member) == planner.as_ref()))
6704 .collect::<Vec<_>>();
6705
6706 assert_eq!(workflow.planner_mode, PLANNER_MODE_PARALLEL);
6707 assert!(workflow.handoff);
6708 assert_eq!(executors, vec!["claude", "codex", "gemini"]);
6709 assert_eq!(role_for("claude", &workflow), "lead+planner");
6710 assert_eq!(role_for("codex", &workflow), "executor");
6711 assert_eq!(role_for("gemini", &workflow), "executor");
6712 }
6713
6714 #[test]
6715 fn planner_review_chain_mode_serializes_agent_review() {
6716 let args = CliArgs::try_parse_from([
6717 "amon-hen",
6718 "--members",
6719 "codex,claude,gemini",
6720 "--planner",
6721 "claude",
6722 "--planner-mode",
6723 "review-chain",
6724 "--lead",
6725 "claude",
6726 "ship it",
6727 ])
6728 .unwrap();
6729 let resolved = resolve_args(args).unwrap();
6730 let workflow = build_workflow(&resolved);
6731 let ordered = execution_order(
6732 &resolved.members,
6733 workflow.planner.as_deref(),
6734 workflow.lead.as_deref(),
6735 );
6736 let prior = vec![EngineResult {
6737 name: "codex".to_string(),
6738 bin: Some("codex".to_string()),
6739 status: "ok".to_string(),
6740 duration_ms: 1,
6741 detail: String::new(),
6742 exit_code: Some(0),
6743 stdout: String::new(),
6744 stderr: String::new(),
6745 output: "codex patch notes".to_string(),
6746 command: "codex".to_string(),
6747 token_usage: token_usage("a", "b"),
6748 tool_calls: vec![],
6749 sub_agents: vec![],
6750 role: "executor".to_string(),
6751 iteration: 1,
6752 total_iterations: 1,
6753 team_size: 0,
6754 }];
6755 let prompt = build_member_prompt(MemberPromptInput {
6756 query: "ship it",
6757 role: "executor",
6758 workflow: &workflow,
6759 iteration: 1,
6760 team_size: 0,
6761 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
6762 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
6763 previous_iteration: &[],
6764 handoff_results: &prior,
6765 plan_output: "",
6766 });
6767
6768 assert_eq!(workflow.planner_mode, PLANNER_MODE_REVIEW_CHAIN);
6769 assert!(
6770 workflow.handoff,
6771 "review-chain should imply handoff context"
6772 );
6773 assert!(planner_mode_runs_serial(&workflow));
6774 assert_eq!(ordered, vec!["claude", "codex", "gemini"]);
6775 assert!(prompt.contains("Review-chain mode is enabled"));
6776 assert!(prompt.contains("### codex (executor)"));
6777 assert!(prompt.contains("codex patch notes"));
6778 }
6779
6780 #[test]
6781 fn parses_historical_linear_flags_in_rust() {
6782 let args = CliArgs::try_parse_from([
6783 "amon-hen",
6784 "--banner",
6785 "--auth-open-browser",
6786 "--members",
6787 "codex,claude,gemini",
6788 "--linear-project",
6789 "ENG",
6790 "--linear-endpoint",
6791 "https://linear.example/graphql",
6792 "--linear-auth",
6793 "oauth",
6794 "--linear-max-polls",
6795 "2",
6796 "--linear-max-concurrency",
6797 "3",
6798 "--linear-workflow-file",
6799 "docs/linear-workflow.md",
6800 "--linear-workspace-strategy",
6801 "copy",
6802 "--linear-completion-gate",
6803 "review-or-ci",
6804 "--delivery-phases",
6805 "plan,implement,verify,ship",
6806 "deliver it",
6807 ])
6808 .unwrap();
6809 let resolved = resolve_args(args).unwrap();
6810
6811 assert!(linear_delivery_requested(&resolved.raw));
6812 assert_eq!(
6813 resolved.raw.linear_endpoint.as_deref(),
6814 Some("https://linear.example/graphql")
6815 );
6816 assert_eq!(resolved.raw.linear_auth, "oauth");
6817 assert_eq!(resolved.raw.linear_max_polls, Some(2));
6818 assert_eq!(resolved.raw.linear_max_concurrency, 3);
6819 assert_eq!(
6820 resolved.raw.linear_workflow_file.as_deref(),
6821 Some(Path::new("docs/linear-workflow.md"))
6822 );
6823 assert_eq!(resolved.raw.linear_workspace_strategy, "copy");
6824 assert_eq!(resolved.raw.linear_completion_gate, "review-or-ci");
6825 assert_eq!(
6826 resolved.raw.delivery_phases,
6827 vec!["plan", "implement", "verify", "ship"]
6828 );
6829 }
6830
6831 #[test]
6832 fn cli_role_flag_matrix_reaches_provider_options_and_prompts() {
6833 let args = CliArgs::try_parse_from([
6834 "amon-hen",
6835 "--members",
6836 "codex,claude,gemini",
6837 "--planner",
6838 "codex",
6839 "--lead",
6840 "claude",
6841 "--handoff",
6842 "--iterations",
6843 "3",
6844 "--team-work",
6845 "2",
6846 "--codex-sub-agents",
6847 "4",
6848 "--claude-sub-agents",
6849 "1",
6850 "--gemini-sub-agents",
6851 "0",
6852 "--codex-model",
6853 "gpt-5.2",
6854 "--codex-effort",
6855 "xhigh",
6856 "--codex-sandbox",
6857 "danger-full-access",
6858 "--codex-config",
6859 "sandbox_workspace_write.network_access=true",
6860 "--codex-mcp-profile",
6861 "repo",
6862 "--claude-model",
6863 "sonnet",
6864 "--claude-effort",
6865 "max",
6866 "--claude-permission-mode",
6867 "bypassPermissions",
6868 "--claude-allowed-tools",
6869 "Read,Edit",
6870 "--claude-tools",
6871 "Bash",
6872 "--claude-agent",
6873 "lead-reviewer",
6874 "--gemini-model",
6875 "gemini-2.5-pro",
6876 "--gemini-effort",
6877 "high",
6878 "--gemini-approval-mode",
6879 "auto_edit",
6880 "--gemini-settings",
6881 "gemini-settings.json",
6882 "--gemini-tools-profile",
6883 "repo,ci",
6884 "--gemini-allowed-mcp-servers",
6885 "linear",
6886 "Fix the regression",
6887 ])
6888 .unwrap();
6889 let resolved = resolve_args(args).unwrap();
6890 let workflow = build_workflow(&resolved);
6891 let previous = vec![EngineResult {
6892 name: "gemini".to_string(),
6893 bin: Some("gemini".to_string()),
6894 status: "ok".to_string(),
6895 duration_ms: 1,
6896 detail: String::new(),
6897 exit_code: Some(0),
6898 stdout: String::new(),
6899 stderr: String::new(),
6900 output: "previous iteration context".to_string(),
6901 command: "gemini".to_string(),
6902 token_usage: token_usage("a", "b"),
6903 tool_calls: vec![],
6904 sub_agents: vec![],
6905 role: "executor".to_string(),
6906 iteration: 1,
6907 total_iterations: 3,
6908 team_size: 0,
6909 }];
6910 let handoff = vec![EngineResult {
6911 name: "codex".to_string(),
6912 bin: Some("codex".to_string()),
6913 status: "ok".to_string(),
6914 duration_ms: 1,
6915 detail: String::new(),
6916 exit_code: Some(0),
6917 stdout: String::new(),
6918 stderr: String::new(),
6919 output: "planner handoff context".to_string(),
6920 command: "codex".to_string(),
6921 token_usage: token_usage("a", "b"),
6922 tool_calls: vec![],
6923 sub_agents: vec![],
6924 role: "planner".to_string(),
6925 iteration: 2,
6926 total_iterations: 3,
6927 team_size: 4,
6928 }];
6929
6930 assert_eq!(workflow.iterations, 3);
6931 assert!(workflow.handoff);
6932 assert_eq!(workflow.team_work, 2);
6933 assert_eq!(workflow.teams.get("codex"), Some(&4));
6934 assert_eq!(workflow.teams.get("claude"), Some(&1));
6935 assert_eq!(workflow.teams.get("gemini"), Some(&0));
6936 assert_eq!(role_for("codex", &workflow), "planner");
6937 assert_eq!(role_for("claude", &workflow), "lead");
6938 assert_eq!(role_for("gemini", &workflow), "executor");
6939
6940 let codex_prompt = build_member_prompt(MemberPromptInput {
6941 query: "Fix the regression",
6942 role: "planner",
6943 workflow: &workflow,
6944 iteration: 2,
6945 team_size: effective_team_size(&workflow, "codex"),
6946 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
6947 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
6948 previous_iteration: &previous,
6949 handoff_results: &handoff,
6950 plan_output: "plan output",
6951 });
6952 assert!(codex_prompt.contains("Amon Hen workflow: iteration 2 of 3."));
6953 assert!(codex_prompt.contains("Lead model: claude."));
6954 assert!(codex_prompt.contains("Planner model: codex."));
6955 assert!(codex_prompt.contains("Your assigned role: planner."));
6956 assert!(codex_prompt.contains("Handoff mode is enabled."));
6957 assert!(codex_prompt.contains("Team work: you may coordinate up to 4 internal sub-agents"));
6958 assert!(codex_prompt.contains("Planner handoff:\nplan output"));
6959 assert!(codex_prompt.contains("previous iteration context"));
6960 assert!(codex_prompt.contains("planner handoff context"));
6961
6962 let codex_options = engine_options(
6963 &resolved,
6964 EngineOptionsInput {
6965 member: "codex",
6966 prompt: codex_prompt,
6967 role: "planner",
6968 iteration: 2,
6969 workflow: &workflow,
6970 progress: None,
6971 cancel: None,
6972 },
6973 );
6974 assert_eq!(codex_options.model.as_deref(), Some("gpt-5.2"));
6975 assert_eq!(codex_options.effort.as_deref(), Some("xhigh"));
6976 assert_eq!(
6977 codex_options.permission.as_deref(),
6978 Some("danger-full-access")
6979 );
6980 assert_eq!(codex_options.role, "planner");
6981 assert_eq!(codex_options.iteration, 2);
6982 assert_eq!(codex_options.total_iterations, 3);
6983 assert_eq!(codex_options.team_size, 4);
6984 assert_eq!(codex_options.capability.mode, CAPABILITY_OVERRIDE);
6985 assert_eq!(
6986 codex_options.capability.config,
6987 vec!["sandbox_workspace_write.network_access=true"]
6988 );
6989 assert_eq!(
6990 codex_options.capability.mcp_profile.as_deref(),
6991 Some("repo")
6992 );
6993
6994 let claude_options = engine_options(
6995 &resolved,
6996 EngineOptionsInput {
6997 member: "claude",
6998 prompt: build_member_prompt(MemberPromptInput {
6999 query: "Fix the regression",
7000 role: "lead",
7001 workflow: &workflow,
7002 iteration: 2,
7003 team_size: effective_team_size(&workflow, "claude"),
7004 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7005 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7006 previous_iteration: &[],
7007 handoff_results: &[],
7008 plan_output: "",
7009 }),
7010 role: "lead",
7011 iteration: 2,
7012 workflow: &workflow,
7013 progress: None,
7014 cancel: None,
7015 },
7016 );
7017 assert_eq!(claude_options.model.as_deref(), Some("sonnet"));
7018 assert_eq!(claude_options.effort.as_deref(), Some("max"));
7019 assert_eq!(
7020 claude_options.permission.as_deref(),
7021 Some("bypassPermissions")
7022 );
7023 assert_eq!(claude_options.role, "lead");
7024 assert_eq!(claude_options.team_size, 1);
7025 assert_eq!(claude_options.capability.mode, CAPABILITY_OVERRIDE);
7026 assert_eq!(
7027 claude_options.capability.allowed_tools,
7028 vec!["Read", "Edit"]
7029 );
7030 assert_eq!(claude_options.capability.tools, vec!["Bash"]);
7031 assert_eq!(
7032 claude_options.capability.agent.as_deref(),
7033 Some("lead-reviewer")
7034 );
7035
7036 let gemini_options = engine_options(
7037 &resolved,
7038 EngineOptionsInput {
7039 member: "gemini",
7040 prompt: build_member_prompt(MemberPromptInput {
7041 query: "Fix the regression",
7042 role: "executor",
7043 workflow: &workflow,
7044 iteration: 2,
7045 team_size: effective_team_size(&workflow, "gemini"),
7046 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7047 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7048 previous_iteration: &[],
7049 handoff_results: &[],
7050 plan_output: "",
7051 }),
7052 role: "executor",
7053 iteration: 2,
7054 workflow: &workflow,
7055 progress: None,
7056 cancel: None,
7057 },
7058 );
7059 assert_eq!(gemini_options.model.as_deref(), Some("gemini-2.5-pro"));
7060 assert_eq!(gemini_options.effort.as_deref(), Some("high"));
7061 assert_eq!(gemini_options.permission.as_deref(), Some("auto_edit"));
7062 assert_eq!(gemini_options.role, "executor");
7063 assert_eq!(gemini_options.team_size, 0);
7064 assert_eq!(gemini_options.capability.mode, CAPABILITY_OVERRIDE);
7065 assert_eq!(
7066 gemini_options.capability.settings.as_deref(),
7067 Some("gemini-settings.json")
7068 );
7069 assert_eq!(gemini_options.capability.tools_profile, vec!["repo", "ci"]);
7070 assert_eq!(
7071 gemini_options.capability.allowed_mcp_servers,
7072 vec!["linear"]
7073 );
7074 }
7075
7076 #[test]
7077 fn provider_command_args_include_role_matrix_settings() {
7078 let cwd = std::env::current_dir().unwrap();
7079 let codex_options = EngineRunOptions {
7080 prompt: "codex prompt".to_string(),
7081 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7082 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7083 cwd: cwd.clone(),
7084 timeout_ms: 1,
7085 effort: Some("high".to_string()),
7086 model: Some("gpt-5.2".to_string()),
7087 permission: Some("workspace-write".to_string()),
7088 auth: DEFAULT_AUTH_MODE.to_string(),
7089 capability: ProviderCapability {
7090 mode: CAPABILITY_OVERRIDE.to_string(),
7091 config: vec!["tools.web_search=true".to_string()],
7092 mcp_profile: Some("repo".to_string()),
7093 mcp_config: vec![],
7094 allowed_tools: vec![],
7095 disallowed_tools: vec![],
7096 tools: vec![],
7097 agent: None,
7098 agents_json: None,
7099 plugin_dirs: vec![],
7100 strict_mcp_config: false,
7101 disable_slash_commands: false,
7102 settings: None,
7103 tools_profile: vec![],
7104 allowed_mcp_servers: vec![],
7105 policy: vec![],
7106 admin_policy: vec![],
7107 },
7108 role: "planner".to_string(),
7109 iteration: 2,
7110 total_iterations: 3,
7111 team_size: 4,
7112 is_sub_agent: false,
7113 live: false,
7114 progress: None,
7115 cancel: None,
7116 };
7117 let codex = run_codex("definitely-missing-codex-test-bin", &codex_options);
7118 assert_eq!(codex.args[0], "exec");
7119 assert!(codex
7120 .args
7121 .windows(2)
7122 .any(|pair| pair == ["--model", "gpt-5.2"]));
7123 assert!(codex
7124 .args
7125 .windows(2)
7126 .any(|pair| pair == ["-c", "model_reasoning_effort=high"]));
7127 assert!(codex
7128 .args
7129 .windows(2)
7130 .any(|pair| pair == ["-c", "tools.web_search=true"]));
7131 assert!(codex
7132 .args
7133 .windows(2)
7134 .any(|pair| pair == ["--profile", "repo"]));
7135 assert!(codex
7136 .args
7137 .windows(2)
7138 .any(|pair| pair == ["--sandbox", "workspace-write"]));
7139
7140 let claude_options = EngineRunOptions {
7141 prompt: "claude prompt".to_string(),
7142 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7143 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7144 cwd: cwd.clone(),
7145 timeout_ms: 1,
7146 effort: Some("max".to_string()),
7147 model: Some("sonnet".to_string()),
7148 permission: Some("bypassPermissions".to_string()),
7149 auth: "social-login".to_string(),
7150 capability: ProviderCapability {
7151 mode: CAPABILITY_OVERRIDE.to_string(),
7152 config: vec![],
7153 mcp_profile: None,
7154 mcp_config: vec!["mcp.json".to_string()],
7155 allowed_tools: vec!["Read".to_string(), "Edit".to_string()],
7156 disallowed_tools: vec!["WebFetch".to_string()],
7157 tools: vec!["Bash".to_string()],
7158 agent: Some("lead-reviewer".to_string()),
7159 agents_json: Some("agents.json".to_string()),
7160 plugin_dirs: vec!["plugins/a".to_string(), "plugins/b".to_string()],
7161 strict_mcp_config: true,
7162 disable_slash_commands: true,
7163 settings: None,
7164 tools_profile: vec![],
7165 allowed_mcp_servers: vec![],
7166 policy: vec![],
7167 admin_policy: vec![],
7168 },
7169 role: "lead".to_string(),
7170 iteration: 2,
7171 total_iterations: 3,
7172 team_size: 1,
7173 is_sub_agent: false,
7174 live: false,
7175 progress: None,
7176 cancel: None,
7177 };
7178 let claude = run_claude("definitely-missing-claude-test-bin", &claude_options);
7179 assert!(!claude.args.iter().any(|arg| arg == "--bare"));
7180 assert!(claude
7181 .args
7182 .windows(2)
7183 .any(|pair| pair == ["--permission-mode", "bypassPermissions"]));
7184 assert!(claude
7185 .args
7186 .windows(2)
7187 .any(|pair| pair == ["--model", "sonnet"]));
7188 assert!(claude
7189 .args
7190 .windows(2)
7191 .any(|pair| pair == ["--effort", "max"]));
7192 assert!(claude
7193 .args
7194 .windows(3)
7195 .any(|triple| triple == ["--allowedTools", "Read", "Edit"]));
7196 assert!(claude
7197 .args
7198 .windows(2)
7199 .any(|pair| pair == ["--agent", "lead-reviewer"]));
7200 assert!(claude.args.iter().any(|arg| arg == "--strict-mcp-config"));
7201 assert!(claude
7202 .args
7203 .iter()
7204 .any(|arg| arg == "--disable-slash-commands"));
7205
7206 let gemini_options = EngineRunOptions {
7207 prompt: "gemini prompt".to_string(),
7208 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7209 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7210 cwd,
7211 timeout_ms: 1,
7212 effort: Some("high".to_string()),
7213 model: Some("gemini-2.5-pro".to_string()),
7214 permission: Some("auto_edit".to_string()),
7215 auth: DEFAULT_AUTH_MODE.to_string(),
7216 capability: ProviderCapability {
7217 mode: CAPABILITY_OVERRIDE.to_string(),
7218 config: vec![],
7219 mcp_profile: None,
7220 mcp_config: vec![],
7221 allowed_tools: vec![],
7222 disallowed_tools: vec![],
7223 tools: vec![],
7224 agent: None,
7225 agents_json: None,
7226 plugin_dirs: vec![],
7227 strict_mcp_config: false,
7228 disable_slash_commands: false,
7229 settings: None,
7230 tools_profile: vec!["repo".to_string(), "ci".to_string()],
7231 allowed_mcp_servers: vec!["linear".to_string()],
7232 policy: vec!["default".to_string()],
7233 admin_policy: vec!["locked".to_string()],
7234 },
7235 role: "executor".to_string(),
7236 iteration: 2,
7237 total_iterations: 3,
7238 team_size: 0,
7239 is_sub_agent: false,
7240 live: false,
7241 progress: None,
7242 cancel: None,
7243 };
7244 let gemini = run_gemini("definitely-missing-gemini-test-bin", &gemini_options);
7245 assert!(gemini
7246 .args
7247 .windows(2)
7248 .any(|pair| pair == ["--model", "gemini-2.5-pro"]));
7249 assert!(gemini
7250 .args
7251 .windows(3)
7252 .any(|triple| triple == ["--extensions", "repo", "ci"]));
7253 assert!(gemini
7254 .args
7255 .windows(2)
7256 .any(|pair| pair == ["--allowed-mcp-server-names", "linear"]));
7257 assert!(gemini
7258 .args
7259 .windows(2)
7260 .any(|pair| pair == ["--policy", "default"]));
7261 assert!(gemini
7262 .args
7263 .windows(2)
7264 .any(|pair| pair == ["--admin-policy", "locked"]));
7265 assert!(gemini.args.windows(2).any(|pair| pair == ["-p", ""]));
7266 assert!(gemini
7267 .args
7268 .windows(2)
7269 .any(|pair| pair == ["--approval-mode", "auto_edit"]));
7270 }
7271
7272 #[test]
7273 #[cfg(unix)]
7274 fn gemini_prompt_is_sent_on_stdin_to_avoid_arg_limit() {
7275 use std::os::unix::fs::PermissionsExt;
7276
7277 let temp = tempfile::tempdir().unwrap();
7278 let fake_gemini = temp.path().join("gemini-fake");
7279 fs::write(
7280 &fake_gemini,
7281 "#!/bin/sh\nprintf 'ARGS:'\nfor arg in \"$@\"; do printf '<%s>' \"$arg\"; done\nprintf '\\nSTDIN:'\ncat\n",
7282 )
7283 .unwrap();
7284 let mut permissions = fs::metadata(&fake_gemini).unwrap().permissions();
7285 permissions.set_mode(0o755);
7286 fs::set_permissions(&fake_gemini, permissions).unwrap();
7287
7288 let prompt = "x".repeat(300_000);
7289 let options = EngineRunOptions {
7290 prompt: prompt.clone(),
7291 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7292 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7293 cwd: std::env::current_dir().unwrap(),
7294 timeout_ms: 5_000,
7295 effort: None,
7296 model: Some("gemini-test".to_string()),
7297 permission: None,
7298 auth: DEFAULT_AUTH_MODE.to_string(),
7299 capability: ProviderCapability {
7300 mode: CAPABILITY_INHERIT.to_string(),
7301 config: vec![],
7302 mcp_profile: None,
7303 mcp_config: vec![],
7304 allowed_tools: vec![],
7305 disallowed_tools: vec![],
7306 tools: vec![],
7307 agent: None,
7308 agents_json: None,
7309 plugin_dirs: vec![],
7310 strict_mcp_config: false,
7311 disable_slash_commands: false,
7312 settings: None,
7313 tools_profile: vec![],
7314 allowed_mcp_servers: vec![],
7315 policy: vec![],
7316 admin_policy: vec![],
7317 },
7318 role: "executor".to_string(),
7319 iteration: 1,
7320 total_iterations: 1,
7321 team_size: 0,
7322 is_sub_agent: false,
7323 live: false,
7324 progress: None,
7325 cancel: None,
7326 };
7327 let result = run_gemini(fake_gemini.to_str().unwrap(), &options);
7328
7329 assert_eq!(result.code, Some(0));
7330 assert!(result.args.iter().any(|arg| arg == "-p"));
7331 assert!(!result.args.iter().any(|arg| arg == &prompt));
7332 assert!(result.stdout.contains("ARGS:<--model><gemini-test><-p><>"));
7333 assert!(result.stdout.contains("<--approval-mode><plan>"));
7334 assert!(result.stdout.contains("<--output-format><stream-json>"));
7335 assert!(result.stdout.ends_with(&prompt));
7336 }
7337
7338 #[test]
7339 fn spawn_failure_status_distinguishes_missing_from_other_spawn_errors() {
7340 assert_eq!(spawn_failure_status("No such file or directory"), "missing");
7341 assert_eq!(
7342 spawn_failure_status("Argument list too long (os error 7)"),
7343 "error"
7344 );
7345 }
7346
7347 #[test]
7348 fn builds_member_prompt_with_roles_and_handoff() {
7349 let workflow = Workflow {
7350 handoff: true,
7351 lead: Some("claude".to_string()),
7352 planner: Some("codex".to_string()),
7353 planner_mode: PLANNER_MODE_BLOCKING.to_string(),
7354 owner_map: OWNER_MAP_STRICT.to_string(),
7355 handshake: false,
7356 handshake_agents: vec![],
7357 iterations: 2,
7358 team_work: 1,
7359 teams: HashMap::new(),
7360 };
7361 let prompt = build_member_prompt(MemberPromptInput {
7362 query: "Fix the bug",
7363 role: "planner",
7364 workflow: &workflow,
7365 iteration: 1,
7366 team_size: 1,
7367 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
7368 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
7369 previous_iteration: &[],
7370 handoff_results: &[],
7371 plan_output: "",
7372 });
7373 assert!(prompt.contains("Amon Hen workflow: iteration 1 of 2."));
7374 assert!(prompt.contains("Lead model: claude."));
7375 assert!(prompt.contains("Planner model: codex."));
7376 assert!(prompt.contains("Your assigned role: planner."));
7377 assert!(prompt.contains("Strict ownership is enabled."));
7378 assert!(prompt.contains("Current user query:"));
7379 }
7380
7381 #[test]
7382 fn member_prompt_respects_prompt_budget_and_handoff_caps() {
7383 let workflow = Workflow {
7384 handoff: true,
7385 lead: Some("claude".to_string()),
7386 planner: Some("codex".to_string()),
7387 planner_mode: PLANNER_MODE_REVIEW_CHAIN.to_string(),
7388 owner_map: OWNER_MAP_FLEXIBLE.to_string(),
7389 handshake: false,
7390 handshake_agents: vec![],
7391 iterations: 10,
7392 team_work: 3,
7393 teams: HashMap::new(),
7394 };
7395 let prior = vec![EngineResult {
7396 name: "codex".to_string(),
7397 bin: Some("codex".to_string()),
7398 status: "ok".to_string(),
7399 duration_ms: 1,
7400 detail: String::new(),
7401 exit_code: Some(0),
7402 stdout: String::new(),
7403 stderr: String::new(),
7404 output: "prior-output ".repeat(20_000),
7405 command: "codex".to_string(),
7406 token_usage: token_usage("prompt", "output"),
7407 tool_calls: vec![],
7408 sub_agents: vec![],
7409 role: "planner".to_string(),
7410 iteration: 3,
7411 total_iterations: 10,
7412 team_size: 3,
7413 }];
7414
7415 let prompt = build_member_prompt(MemberPromptInput {
7416 query: &"main-query ".repeat(20_000),
7417 role: "lead",
7418 workflow: &workflow,
7419 iteration: 4,
7420 team_size: 0,
7421 max_member_chars: 2_000,
7422 max_prompt_chars: 16_384,
7423 previous_iteration: &prior,
7424 handoff_results: &[],
7425 plan_output: &"plan-output ".repeat(20_000),
7426 });
7427
7428 assert!(prompt.chars().count() <= 16_384);
7429 assert!(prompt.contains("planner handoff truncated"));
7430 assert!(prompt.contains("handoff truncated"));
7431 assert!(prompt.contains("provider prompt truncated"));
7432 }
7433
7434 #[test]
7435 fn team_prompts_are_rebudgeted_before_provider_launch() {
7436 let options = EngineRunOptions {
7437 prompt: "original ".repeat(30_000),
7438 max_prompt_chars: 16_384,
7439 max_member_chars: 1_000,
7440 cwd: std::env::current_dir().unwrap(),
7441 timeout_ms: 1_000,
7442 effort: None,
7443 model: None,
7444 permission: None,
7445 auth: DEFAULT_AUTH_MODE.to_string(),
7446 capability: ProviderCapability {
7447 mode: CAPABILITY_INHERIT.to_string(),
7448 config: vec![],
7449 mcp_profile: None,
7450 mcp_config: vec![],
7451 allowed_tools: vec![],
7452 disallowed_tools: vec![],
7453 tools: vec![],
7454 agent: None,
7455 agents_json: None,
7456 plugin_dirs: vec![],
7457 strict_mcp_config: false,
7458 disable_slash_commands: false,
7459 settings: None,
7460 tools_profile: vec![],
7461 allowed_mcp_servers: vec![],
7462 policy: vec![],
7463 admin_policy: vec![],
7464 },
7465 role: "planner".to_string(),
7466 iteration: 1,
7467 total_iterations: 1,
7468 team_size: 2,
7469 is_sub_agent: false,
7470 live: false,
7471 progress: None,
7472 cancel: None,
7473 };
7474 let sub_prompt = limit_provider_prompt(
7475 &build_sub_agent_prompt(&options.prompt, &options.role, 1, 2),
7476 options.max_prompt_chars,
7477 );
7478 assert!(sub_prompt.chars().count() <= options.max_prompt_chars);
7479
7480 let sub_agent = EngineResult {
7481 name: "codex".to_string(),
7482 bin: Some("codex".to_string()),
7483 status: "ok".to_string(),
7484 duration_ms: 1,
7485 detail: String::new(),
7486 exit_code: Some(0),
7487 stdout: String::new(),
7488 stderr: String::new(),
7489 output: "sub-agent-output ".repeat(20_000),
7490 command: "codex".to_string(),
7491 token_usage: token_usage("prompt", "output"),
7492 tool_calls: vec![],
7493 sub_agents: vec![],
7494 role: "planner:sub-agent-1".to_string(),
7495 iteration: 1,
7496 total_iterations: 1,
7497 team_size: 0,
7498 };
7499 let lead_prompt = limit_provider_prompt(
7500 &build_team_lead_prompt(&options.prompt, &[sub_agent], options.max_member_chars),
7501 options.max_prompt_chars,
7502 );
7503 assert!(lead_prompt.chars().count() <= options.max_prompt_chars);
7504 assert!(lead_prompt.contains("sub-agent handoff truncated"));
7505 }
7506
7507 #[test]
7508 fn parses_claude_stream_json_result() {
7509 let text = r#"{"type":"system","subtype":"status","status":"requesting"}
7510{"type":"result","result":"done"}"#;
7511 assert_eq!(parse_claude_output(text), "done");
7512 }
7513
7514 #[test]
7515 fn parses_gemini_json_response() {
7516 assert_eq!(parse_gemini_output(r#"{"response":"hello"}"#), "hello");
7517 }
7518
7519 #[test]
7520 fn parses_gemini_stream_json_response_and_error() {
7521 let stream = r#"{"type":"init","model":"gemini-3.1-pro-preview"}
7522{"type":"message","role":"assistant","content":"Reviewing gate evidence ","delta":true}
7523{"type":"message","role":"assistant","content":"and readiness blockers.","delta":true}
7524{"type":"result","status":"success","stats":{"total_tokens":30,"input_tokens":20,"output_tokens":10}}"#;
7525 assert_eq!(
7526 parse_gemini_output(stream),
7527 "Reviewing gate evidence and readiness blockers."
7528 );
7529
7530 let error = r#"{"type":"result","status":"error","error":{"type":"QuotaError","message":"You exceeded your current quota."},"stats":{"total_tokens":101400,"input_tokens":101400,"output_tokens":0}}"#;
7531 assert_eq!(
7532 parse_gemini_output(error),
7533 "You exceeded your current quota."
7534 );
7535 assert_eq!(
7536 provider_failure_detail(error).as_deref(),
7537 Some("You exceeded your current quota.")
7538 );
7539 }
7540
7541 #[test]
7542 fn estimates_tokens_with_ceiling_chunks() {
7543 assert_eq!(estimate_tokens(""), 0);
7544 assert_eq!(estimate_tokens("abc"), 1);
7545 assert_eq!(estimate_tokens("abcd"), 1);
7546 assert_eq!(estimate_tokens("abcde"), 2);
7547 }
7548
7549 #[test]
7550 fn status_detail_strips_real_and_escaped_terminal_sequences() {
7551 let escaped =
7552 sanitize_status_detail(r"\u001b[26;107Hstatus \u001b[38;2;246;196;83mready\u001b[0m");
7553 let actual = sanitize_status_detail("\x1b[31mfailed\x1b[0m after check");
7554 let double_escaped = sanitize_status_detail(r"before \\u001b[39mready\\u001b[0m\\nnext");
7555 let bare_escaped = sanitize_status_detail("before u001b[39mreadyu001b[0m next");
7556
7557 assert_eq!(escaped, "status ready");
7558 assert_eq!(actual, "failed after check");
7559 assert_eq!(double_escaped, "before ready next");
7560 assert_eq!(bare_escaped, "before ready next");
7561 assert!(!escaped.contains(r"\u001b"));
7562 assert!(!actual.contains('\x1b'));
7563 }
7564
7565 #[test]
7566 fn extracts_social_login_urls() {
7567 assert_eq!(
7568 extract_auth_urls("Open https://example.com/callback?code=123, then continue"),
7569 vec!["https://example.com/callback?code=123"]
7570 );
7571 assert_eq!(
7572 extract_auth_urls("deeplink: claude://login/complete."),
7573 vec!["claude://login/complete"]
7574 );
7575 }
7576
7577 #[test]
7578 fn parses_provider_token_usage() {
7579 let stdout = r#"{"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}"#;
7580 let usage = extract_token_usage(stdout, "hello", "world").unwrap();
7581 assert_eq!(usage.input, 12);
7582 assert_eq!(usage.output, 8);
7583 assert_eq!(usage.total, 20);
7584 assert!(!usage.estimated);
7585 }
7586
7587 #[test]
7588 fn extracts_tool_usage_from_provider_streams() {
7589 let stdout = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}"#;
7590 let tools = extract_tool_usage("claude", stdout, "");
7591 assert!(tools.iter().any(|tool| tool.name == "Bash"));
7592 }
7593
7594 #[test]
7595 fn provider_stream_json_renders_readable_messages() {
7596 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"}}]}}"#;
7597 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"}}"#;
7598 let gemini = r#"{"candidates":[{"content":{"parts":[{"text":"Gate waterfall evidence is still thin."}]}}]}"#;
7599 let gemini_stream = r#"{"type":"message","role":"assistant","content":"Reviewing the patch as a read-only critic.","delta":true}"#;
7600 let gemini_error = r#"{"type":"result","status":"error","error":{"type":"ModelError","message":"Gemini model failed after the prompt was accepted."}}"#;
7601
7602 assert_eq!(
7603 provider_visible_stream(Some("claude"), claude, &[]),
7604 StreamDisplay::Visible(
7605 "assistant: Inspecting the truth predicates now. | tool: Bash {\"command\":\"git status -sb\"}"
7606 .to_string()
7607 )
7608 );
7609 let codex_visible = match provider_visible_stream(Some("codex"), codex, &[]) {
7610 StreamDisplay::Visible(text) => text,
7611 StreamDisplay::Suppress => panic!("codex command event should be visible"),
7612 };
7613 assert!(codex_visible.contains("tool: shell"));
7614 assert!(codex_visible.contains("execution/live_router.py"));
7615 assert!(!codex_visible.contains("\"type\":\"item.completed\""));
7616 assert_eq!(
7617 provider_visible_stream(Some("gemini"), gemini, &[]),
7618 StreamDisplay::Visible("assistant: Gate waterfall evidence is still thin.".to_string())
7619 );
7620 assert_eq!(
7621 provider_visible_stream(Some("gemini"), gemini_stream, &[]),
7622 StreamDisplay::Visible(
7623 "assistant: Reviewing the patch as a read-only critic.".to_string()
7624 )
7625 );
7626 assert_eq!(
7627 provider_visible_stream(Some("gemini"), gemini_error, &[]),
7628 StreamDisplay::Visible(
7629 "error: Gemini model failed after the prompt was accepted.".to_string()
7630 )
7631 );
7632 }
7633
7634 #[test]
7635 fn provider_stream_strips_escaped_terminal_sequences() {
7636 let raw = r"\u001b[26;107Hstatus \u001b[38;2;246;196;83mready\u001b[0m";
7637 assert_eq!(
7638 provider_visible_stream(Some("codex"), raw, &[]),
7639 StreamDisplay::Visible("status ready".to_string())
7640 );
7641
7642 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"}}"#;
7643 let visible = match provider_visible_stream(Some("codex"), codex, &[]) {
7644 StreamDisplay::Visible(text) => text,
7645 StreamDisplay::Suppress => panic!("codex command output should be visible"),
7646 };
7647
7648 assert!(visible.contains("tool: shell"));
7649 assert!(visible.contains("total used free"));
7650 assert!(!visible.contains(r"\u001b"));
7651 assert!(!visible.contains("[26;107H"));
7652 }
7653
7654 #[test]
7655 fn provider_stream_suppresses_partial_provider_chunks() {
7656 let events = Arc::new(Mutex::new(Vec::new()));
7657 let captured = Arc::clone(&events);
7658 let progress = Some(CommandProgress {
7659 label: "codex planner:sub-agent-1 iteration 1/10".to_string(),
7660 sink: Some(Arc::new(move |event| {
7661 captured.lock().unwrap().push(event);
7662 })),
7663 input_tokens: 100,
7664 });
7665 let mut last_stream_event = Instant::now() - PROVIDER_STREAM_EVENT_MIN_INTERVAL;
7666
7667 maybe_emit_provider_stream_chunk(
7668 &progress,
7669 "stdout",
7670 r#"{"type":"item.completed","item":{"type":"command_execution","aggregated_output":"\\u001b[39m"#,
7671 r#"{"type":"item.completed","item":{"type":"command_execution","aggregated_output":"\\u001b[39m"#,
7672 &mut last_stream_event,
7673 );
7674
7675 assert!(
7676 events.lock().unwrap().is_empty(),
7677 "partial provider JSON chunks should not reach Studio"
7678 );
7679 }
7680
7681 #[test]
7682 fn claude_stream_event_json_suppresses_input_shards() {
7683 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"}}"#;
7684 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"}}"#;
7685 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"}}"#;
7686
7687 assert_eq!(
7688 provider_visible_stream(Some("claude"), input_delta, &[]),
7689 StreamDisplay::Suppress
7690 );
7691 assert!(extract_tool_usage("claude", input_delta, "").is_empty());
7692
7693 let visible_tool = match provider_visible_stream(Some("claude"), tool_start, &[]) {
7694 StreamDisplay::Visible(text) => text,
7695 StreamDisplay::Suppress => panic!("tool start should be visible"),
7696 };
7697 assert!(visible_tool.contains("tool: Agent"));
7698 assert!(visible_tool.contains("CLOB/WS Timing"));
7699 assert!(!visible_tool.contains("stream_event"));
7700 assert!(!visible_tool.contains("partial_json"));
7701
7702 assert_eq!(
7703 provider_visible_stream(Some("claude"), text_delta, &[]),
7704 StreamDisplay::Visible("assistant: I found the first timing issue.".to_string())
7705 );
7706 assert_eq!(
7707 parse_claude_output(&format!("{text_delta}\n{text_delta}\n")),
7708 "I found the first timing issue.I found the first timing issue."
7709 );
7710 }
7711
7712 #[test]
7713 fn provider_stream_json_suppresses_session_plumbing() {
7714 let claude_hook = r#"{"type":"system","subtype":"hook_response","hook_name":"SessionStart:startup","output":"noise"}"#;
7715 let codex_session = r#"{"type":"session.started","session_id":"abc"}"#;
7716
7717 assert_eq!(
7718 provider_visible_stream(Some("claude"), claude_hook, &[]),
7719 StreamDisplay::Suppress
7720 );
7721 assert_eq!(
7722 provider_visible_stream(Some("codex"), codex_session, &[]),
7723 StreamDisplay::Suppress
7724 );
7725 }
7726
7727 #[test]
7728 fn builds_real_sub_agent_handoff_prompt() {
7729 let agent = EngineResult {
7730 name: "codex".to_string(),
7731 bin: Some("codex".to_string()),
7732 status: "ok".to_string(),
7733 duration_ms: 1,
7734 detail: String::new(),
7735 exit_code: Some(0),
7736 stdout: String::new(),
7737 stderr: String::new(),
7738 output: "inspect parser".to_string(),
7739 command: "codex exec".to_string(),
7740 token_usage: token_usage("a", "b"),
7741 tool_calls: vec![],
7742 sub_agents: vec![],
7743 role: "executor:sub-agent-1".to_string(),
7744 iteration: 1,
7745 total_iterations: 1,
7746 team_size: 0,
7747 };
7748 let prompt = build_team_lead_prompt("ship it", &[agent], DEFAULT_MAX_MEMBER_CHARS);
7749 assert!(prompt.contains("Sub-agent handoffs"));
7750 assert!(prompt.contains("inspect parser"));
7751 assert!(prompt.contains("Original provider prompt"));
7752 }
7753
7754 #[test]
7755 fn json_stream_events_emit_progress_before_final_result() {
7756 let bus = RuntimeEventBus::new();
7757 let events = Arc::new(Mutex::new(Vec::new()));
7758 let captured = Arc::clone(&events);
7759 bus.add_sink(Arc::new(move |event| {
7760 captured.lock().unwrap().push(event);
7761 }));
7762 let progress = bus.progress_sink();
7763
7764 progress(progress_event_with_context(
7765 None,
7766 None,
7767 ProgressStage::Start,
7768 None,
7769 Some(1),
7770 Some(1),
7771 false,
7772 None,
7773 None,
7774 vec![],
7775 "[amon-hen] iteration 1/1 started",
7776 ));
7777 progress(progress_event_with_context(
7778 Some("codex"),
7779 Some("planner"),
7780 ProgressStage::Done,
7781 Some("ok"),
7782 Some(1),
7783 Some(1),
7784 false,
7785 Some(25),
7786 Some(token_usage("prompt", "answer")),
7787 vec![],
7788 "[amon-hen] done codex role=planner status=ok",
7789 ));
7790 let result = test_amon_hen_result(vec![test_engine_result("codex", "planner", 1)], 1);
7791 bus.emit_result(&result);
7792
7793 let events = events.lock().unwrap();
7794 assert!(events.len() >= 3);
7795 assert!(events[..events.len() - 1]
7796 .iter()
7797 .any(|event| event.progress.stage != ProgressStage::Done));
7798 assert!(events
7799 .iter()
7800 .any(|event| event.progress.kind == RuntimeEventKind::TokenUsage));
7801 assert_eq!(
7802 events.last().unwrap().progress.kind,
7803 RuntimeEventKind::Result
7804 );
7805 assert!(events.last().unwrap().result.is_some());
7806 for event in events.iter() {
7807 serde_json::to_string(event).expect("json-stream event should serialize");
7808 }
7809 }
7810
7811 #[test]
7812 fn runtime_event_bus_preserves_stream_sequence_order_under_fanout() {
7813 let bus = RuntimeEventBus::new();
7814 let events = Arc::new(Mutex::new(Vec::new()));
7815 let captured = Arc::clone(&events);
7816 bus.add_sink(Arc::new(move |event| {
7817 captured.lock().unwrap().push(event.sequence);
7818 }));
7819 let barrier = Arc::new(std::sync::Barrier::new(16));
7820 let mut handles = Vec::new();
7821
7822 for index in 0..16 {
7823 let progress = bus.progress_sink();
7824 let barrier = Arc::clone(&barrier);
7825 handles.push(thread::spawn(move || {
7826 barrier.wait();
7827 progress(progress_event(
7828 Some("codex"),
7829 Some("planner"),
7830 ProgressStage::Heartbeat,
7831 Some("running"),
7832 format!("[amon-hen] fanout event {index}"),
7833 ));
7834 }));
7835 }
7836 for handle in handles {
7837 handle.join().unwrap();
7838 }
7839
7840 let sequences = events.lock().unwrap().clone();
7841 assert_eq!(sequences, (1..=16).collect::<Vec<_>>());
7842 }
7843
7844 #[test]
7845 fn multiple_iterations_preserve_timeline_entries() {
7846 let first = vec![test_engine_result("codex", "planner", 1)];
7847 let second = vec![test_engine_result("claude", "lead", 2)];
7848 let timeline = vec![
7849 iteration_record(1, 2, first.clone(), 10, None, None),
7850 iteration_record(
7851 2,
7852 2,
7853 second.clone(),
7854 15,
7855 iteration_handoff_context(&first, DEFAULT_MAX_MEMBER_CHARS),
7856 Some("summary context".to_string()),
7857 ),
7858 ];
7859 let result = test_amon_hen_result_with_iterations(second, timeline);
7860
7861 assert_eq!(result.iterations.len(), 2);
7862 assert_eq!(result.iterations[0].iteration, 1);
7863 assert_eq!(result.iterations[1].iteration, 2);
7864 assert!(result.iterations[1].handoff_context.is_some());
7865 assert_eq!(result.members[0].name, "claude");
7866 }
7867
7868 #[test]
7869 fn sub_agent_roles_remain_correct_in_timeline() {
7870 let mut member = test_engine_result("gemini", "lead", 1);
7871 member.sub_agents = vec![
7872 test_engine_result("gemini", "lead:sub-agent-1", 1),
7873 test_engine_result("gemini", "lead:sub-agent-2", 1),
7874 ];
7875 let timeline = vec![iteration_record(1, 1, vec![member], 20, None, None)];
7876
7877 assert_eq!(
7878 timeline[0]
7879 .sub_agents
7880 .iter()
7881 .map(|agent| agent.role.as_str())
7882 .collect::<Vec<_>>(),
7883 vec!["lead:sub-agent-1", "lead:sub-agent-2"]
7884 );
7885 assert_eq!(timeline[0].members[0].role, "lead");
7886 }
7887
7888 #[test]
7889 fn runtime_role_matrix_reaches_json_result_and_stream_events() {
7890 let _lock = ENV_LOCK.lock().unwrap();
7891 let Some(_env) = ProviderEnvGuard::install_echo_bins() else {
7892 return;
7893 };
7894 let args = CliArgs::try_parse_from([
7895 "amon-hen",
7896 "--members",
7897 "codex,claude,gemini",
7898 "--planner",
7899 "codex",
7900 "--lead",
7901 "claude",
7902 "--handoff",
7903 "--iterations",
7904 "2",
7905 "--team-work",
7906 "1",
7907 "--codex-sub-agents",
7908 "2",
7909 "--claude-sub-agents",
7910 "1",
7911 "--gemini-sub-agents",
7912 "0",
7913 "--timeout",
7914 "5",
7915 "role matrix smoke",
7916 ])
7917 .unwrap();
7918 let resolved = resolve_args(args).unwrap();
7919 let bus = RuntimeEventBus::new();
7920 let events = Arc::new(Mutex::new(Vec::new()));
7921 let captured = Arc::clone(&events);
7922 bus.add_sink(Arc::new(move |event| {
7923 captured.lock().unwrap().push(event);
7924 }));
7925 let prompt_context =
7926 build_prompt_context_with_progress(&resolved, Some(bus.progress_sink())).unwrap();
7927 let result = run_amon_hen_with_progress_and_cancel(
7928 &resolved,
7929 prompt_context.prompt,
7930 prompt_context.commands,
7931 Some(bus.progress_sink()),
7932 None,
7933 );
7934 bus.emit_result(&result);
7935
7936 assert_eq!(result.workflow.iterations, 2);
7937 assert_eq!(result.workflow.teams.get("codex"), Some(&2));
7938 assert_eq!(result.workflow.teams.get("claude"), Some(&1));
7939 assert_eq!(result.workflow.teams.get("gemini"), Some(&0));
7940 assert_eq!(result.iterations.len(), 2);
7941 for iteration in &result.iterations {
7942 assert_eq!(
7943 iteration
7944 .members
7945 .iter()
7946 .map(|member| (member.name.as_str(), member.role.as_str()))
7947 .collect::<Vec<_>>(),
7948 vec![
7949 ("codex", "planner"),
7950 ("gemini", "executor"),
7951 ("claude", "lead")
7952 ]
7953 );
7954 assert_eq!(
7955 iteration
7956 .sub_agents
7957 .iter()
7958 .map(|agent| (agent.name.as_str(), agent.role.as_str()))
7959 .collect::<Vec<_>>(),
7960 vec![
7961 ("codex", "planner:sub-agent-1"),
7962 ("codex", "planner:sub-agent-2"),
7963 ("claude", "lead:sub-agent-1")
7964 ]
7965 );
7966 }
7967
7968 let events = events.lock().unwrap();
7969 assert!(events.iter().any(|event| {
7970 event.progress.provider.as_deref() == Some("codex")
7971 && event.progress.role.as_deref() == Some("planner")
7972 && !event.progress.is_sub_agent
7973 }));
7974 assert!(events.iter().any(|event| {
7975 event.progress.provider.as_deref() == Some("claude")
7976 && event.progress.role.as_deref() == Some("lead:sub-agent-1")
7977 && event.progress.is_sub_agent
7978 }));
7979 let final_event = events.last().expect("final result event should exist");
7980 assert_eq!(final_event.progress.kind, RuntimeEventKind::Result);
7981 assert!(final_event.result.is_some());
7982 }
7983
7984 #[test]
7985 fn per_provider_sub_agent_overrides_are_reflected_in_prompts() {
7986 let args = CliArgs::try_parse_from([
7987 "amon-hen",
7988 "--members",
7989 "codex,gemini",
7990 "--team-work",
7991 "2",
7992 "--codex-sub-agents",
7993 "0",
7994 "--gemini-sub-agents",
7995 "3",
7996 "ship",
7997 ])
7998 .unwrap();
7999 let resolved = resolve_args(args).unwrap();
8000 let workflow = build_workflow(&resolved);
8001 let codex_prompt = build_member_prompt(MemberPromptInput {
8002 query: "ship",
8003 role: "executor",
8004 workflow: &workflow,
8005 iteration: 1,
8006 team_size: effective_team_size(&workflow, "codex"),
8007 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
8008 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
8009 previous_iteration: &[],
8010 handoff_results: &[],
8011 plan_output: "",
8012 });
8013 let gemini_prompt = build_member_prompt(MemberPromptInput {
8014 query: "ship",
8015 role: "executor",
8016 workflow: &workflow,
8017 iteration: 1,
8018 team_size: effective_team_size(&workflow, "gemini"),
8019 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
8020 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
8021 previous_iteration: &[],
8022 handoff_results: &[],
8023 plan_output: "",
8024 });
8025
8026 assert!(!codex_prompt.contains("Team work:"));
8027 assert!(gemini_prompt.contains("up to 3 internal sub-agents"));
8028 }
8029
8030 #[test]
8031 fn declarative_handshake_agents_build_role_slots() {
8032 let args = CliArgs::try_parse_from([
8033 "amon-hen",
8034 "--handshake-agents",
8035 "planner=codex:2,executor=claude,reviewer=gemini:1",
8036 "--team-work",
8037 "4",
8038 "ship",
8039 ])
8040 .unwrap();
8041 let resolved = resolve_args(args).unwrap();
8042 let workflow = build_workflow(&resolved);
8043
8044 assert_eq!(resolved.members, vec!["codex", "claude", "gemini"]);
8045 assert!(workflow.handshake);
8046 assert_eq!(
8047 workflow
8048 .handshake_agents
8049 .iter()
8050 .map(|agent| (
8051 agent.provider.as_str(),
8052 agent.role.as_str(),
8053 agent.team_size
8054 ))
8055 .collect::<Vec<_>>(),
8056 vec![
8057 ("codex", "planner", 2),
8058 ("claude", "executor", 4),
8059 ("gemini", "reviewer", 1)
8060 ]
8061 );
8062 }
8063
8064 #[test]
8065 fn single_provider_handshake_runs_distinct_agent_roles() {
8066 let _lock = ENV_LOCK.lock().unwrap();
8067 let Some(_env) = ProviderEnvGuard::install_echo_bins() else {
8068 return;
8069 };
8070 let args = CliArgs::try_parse_from([
8071 "amon-hen",
8072 "--handshake",
8073 "--handshake-provider",
8074 "codex",
8075 "--handshake-sub-agents",
8076 "1",
8077 "--timeout",
8078 "5",
8079 "ship from one model",
8080 ])
8081 .unwrap();
8082 let resolved = resolve_args(args).unwrap();
8083 let workflow = build_workflow(&resolved);
8084
8085 assert_eq!(resolved.members, vec!["codex"]);
8086 assert!(workflow.handshake);
8087 assert_eq!(
8088 workflow
8089 .handshake_agents
8090 .iter()
8091 .map(|agent| (
8092 agent.provider.as_str(),
8093 agent.role.as_str(),
8094 agent.team_size
8095 ))
8096 .collect::<Vec<_>>(),
8097 vec![
8098 ("codex", "planner", 1),
8099 ("codex", "executor", 1),
8100 ("codex", "reviewer", 1)
8101 ]
8102 );
8103
8104 let result = run_amon_hen_with_progress_and_cancel(
8105 &resolved,
8106 resolved.prompt.clone(),
8107 vec![],
8108 None,
8109 None,
8110 );
8111 let iteration = result.iterations.first().expect("handshake iteration");
8112 assert_eq!(
8113 iteration
8114 .members
8115 .iter()
8116 .map(|member| (member.name.as_str(), member.role.as_str()))
8117 .collect::<Vec<_>>(),
8118 vec![
8119 ("codex", "planner"),
8120 ("codex", "executor"),
8121 ("codex", "reviewer")
8122 ]
8123 );
8124 assert!(iteration
8125 .members
8126 .iter()
8127 .all(|member| member.sub_agents.len() == 1));
8128 }
8129
8130 #[test]
8131 fn duplicate_members_are_deduped_in_input_order() {
8132 let args = CliArgs::try_parse_from([
8133 "amon-hen",
8134 "--members",
8135 "codex,claude,codex,gemini,claude",
8136 "hello",
8137 ])
8138 .unwrap();
8139 let resolved = resolve_args(args).unwrap();
8140 assert_eq!(resolved.members, vec!["codex", "claude", "gemini"]);
8141 }
8142
8143 #[test]
8144 fn command_cancel_stops_child_process_promptly() {
8145 if !command_available("sh") {
8146 return;
8147 }
8148 let cwd = std::env::current_dir().unwrap();
8149 let cancel = Arc::new(AtomicBool::new(true));
8150 let started = Instant::now();
8151 let args = ["-c".to_string(), "sleep 30".to_string()];
8152 let result =
8153 run_command(CommandRequest::new("sh", &args, &cwd, 30_000).cancel(Some(cancel)));
8154
8155 assert!(result.cancelled);
8156 assert_eq!(result.error.as_deref(), Some("cancelled"));
8157 assert!(
8158 started.elapsed() < Duration::from_secs(3),
8159 "cancelled command took {:?}",
8160 started.elapsed()
8161 );
8162 }
8163
8164 #[test]
8165 fn command_streams_visible_output_and_live_token_usage() {
8166 if !command_available("sh") {
8167 return;
8168 }
8169 let cwd = std::env::current_dir().unwrap();
8170 let events = Arc::new(Mutex::new(Vec::new()));
8171 let captured = Arc::clone(&events);
8172 let progress: ProgressSink = Arc::new(move |event| {
8173 captured.lock().unwrap().push(event);
8174 });
8175 let args = [
8176 "-c".to_string(),
8177 "printf 'visible-work\\n'; printf '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"name\":\"Bash\"}]}}\\n'"
8178 .to_string(),
8179 ];
8180
8181 let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
8182 CommandProgress {
8183 label: "codex planner:sub-agent-1 iteration 1/1".to_string(),
8184 sink: Some(progress),
8185 input_tokens: 10,
8186 },
8187 )));
8188
8189 assert_eq!(result.code, Some(0));
8190 let events = events.lock().unwrap();
8191 assert!(events.iter().any(|event| {
8192 event.message.contains("visible-work")
8193 && event
8194 .token_usage
8195 .as_ref()
8196 .is_some_and(|usage| usage.total > 10)
8197 }));
8198 assert!(events.iter().any(|event| {
8199 event.kind == RuntimeEventKind::ToolUsage
8200 && event.tool_calls.iter().any(|tool| tool.name == "Bash")
8201 }));
8202 }
8203
8204 #[test]
8205 fn command_stream_sampling_preserves_important_tool_events() {
8206 if !command_available("sh") {
8207 return;
8208 }
8209 let cwd = std::env::current_dir().unwrap();
8210 let events = Arc::new(Mutex::new(Vec::new()));
8211 let captured = Arc::clone(&events);
8212 let progress: ProgressSink = Arc::new(move |event| {
8213 captured.lock().unwrap().push(event);
8214 });
8215 let args = [
8216 "-c".to_string(),
8217 "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'"
8218 .to_string(),
8219 ];
8220
8221 let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
8222 CommandProgress {
8223 label: "codex planner iteration 1/1".to_string(),
8224 sink: Some(progress),
8225 input_tokens: 10,
8226 },
8227 )));
8228
8229 assert_eq!(result.code, Some(0));
8230 let events = events.lock().unwrap();
8231 let streaming_events = events
8232 .iter()
8233 .filter(|event| event.status.as_deref() == Some("streaming"))
8234 .count();
8235 assert!(
8236 streaming_events < 20,
8237 "expected sampled streaming events, got {streaming_events}"
8238 );
8239 assert!(events.iter().any(|event| {
8240 event.kind == RuntimeEventKind::ToolUsage
8241 && event.tool_calls.iter().any(|tool| tool.name == "Bash")
8242 }));
8243 }
8244
8245 #[test]
8246 fn command_stream_decodes_provider_json_before_dashboard_events() {
8247 if !command_available("sh") {
8248 return;
8249 }
8250 let cwd = std::env::current_dir().unwrap();
8251 let events = Arc::new(Mutex::new(Vec::new()));
8252 let captured = Arc::clone(&events);
8253 let progress: ProgressSink = Arc::new(move |event| {
8254 captured.lock().unwrap().push(event);
8255 });
8256 let args = [
8257 "-c".to_string(),
8258 "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\"}}'"
8259 .to_string(),
8260 ];
8261
8262 let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
8263 CommandProgress {
8264 label: "codex executor iteration 1/1".to_string(),
8265 sink: Some(progress),
8266 input_tokens: 10,
8267 },
8268 )));
8269
8270 assert_eq!(result.code, Some(0));
8271 let events = events.lock().unwrap();
8272 assert!(!events
8273 .iter()
8274 .any(|event| event.message.contains("session.started")));
8275 assert!(events.iter().any(|event| {
8276 event.message.contains("tool: shell")
8277 && event.message.contains("line one")
8278 && !event.message.contains("\"type\":\"item.completed\"")
8279 }));
8280 }
8281
8282 #[test]
8283 fn claude_text_deltas_stream_as_coalesced_live_answer() {
8284 if !command_available("sh") {
8285 return;
8286 }
8287 let cwd = std::env::current_dir().unwrap();
8288 let events = Arc::new(Mutex::new(Vec::new()));
8289 let captured = Arc::clone(&events);
8290 let progress: ProgressSink = Arc::new(move |event| {
8291 captured.lock().unwrap().push(event);
8292 });
8293 let script = concat!(
8294 "printf '%s\\n' '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Reviewing \"}}}';",
8295 "printf '%s\\n' '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"gates now.\"}}}'"
8296 );
8297 let args = ["-c".to_string(), script.to_string()];
8298
8299 let result = run_command(CommandRequest::new("sh", &args, &cwd, 5_000).progress(Some(
8300 CommandProgress {
8301 label: "claude lead+planner iteration 1/1".to_string(),
8302 sink: Some(progress),
8303 input_tokens: 10,
8304 },
8305 )));
8306
8307 assert_eq!(result.code, Some(0));
8308 let events = events.lock().unwrap();
8309 assert!(events.iter().any(|event| {
8310 event
8311 .message
8312 .contains("assistant live: Reviewing gates now.")
8313 && event.provider.as_deref() == Some("claude")
8314 }));
8315 assert!(!events.iter().any(|event| {
8316 event.message.contains("stdout: assistant: Reviewing ")
8317 || event.message.contains("stdout: assistant: gates now.")
8318 }));
8319 }
8320
8321 #[test]
8322 fn missing_provider_preflight_avoids_team_fanout() {
8323 let args = CliArgs::try_parse_from([
8324 "amon-hen",
8325 "--members",
8326 "codex",
8327 "--team-work",
8328 "5",
8329 "hello",
8330 ])
8331 .unwrap();
8332 let resolved = resolve_args(args).unwrap();
8333 let options = EngineRunOptions {
8334 prompt: "hello".to_string(),
8335 max_prompt_chars: DEFAULT_MAX_PROMPT_CHARS,
8336 max_member_chars: DEFAULT_MAX_MEMBER_CHARS,
8337 cwd: resolved.cwd.clone(),
8338 timeout_ms: 1_000,
8339 effort: None,
8340 model: None,
8341 permission: None,
8342 auth: DEFAULT_AUTH_MODE.to_string(),
8343 capability: provider_capability(&resolved, "codex"),
8344 role: "planner".to_string(),
8345 iteration: 1,
8346 total_iterations: 1,
8347 team_size: 5,
8348 is_sub_agent: false,
8349 live: false,
8350 progress: None,
8351 cancel: None,
8352 };
8353
8354 let result = run_engine("definitely-missing-amon-hen-test-bin", options);
8355
8356 assert_eq!(result.status, "missing");
8357 assert!(result.sub_agents.is_empty());
8358 assert!(result.detail.contains("not found in PATH"));
8359 }
8360
8361 #[test]
8362 fn system_time_is_available_for_test_environment() {
8363 assert!(std::time::SystemTime::now()
8364 .duration_since(std::time::UNIX_EPOCH)
8365 .is_ok());
8366 }
8367
8368 #[test]
8369 fn cli_hygiene_has_no_legacy_npm_cli_or_council_binary_references() {
8370 let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
8371 let repo_root = crate_dir
8372 .parent()
8373 .and_then(Path::parent)
8374 .expect("crate lives under crates/amon-hen");
8375 assert!(!repo_root.join("package.json").exists());
8376 assert!(!repo_root.join("package-lock.json").exists());
8377 assert!(!repo_root.join("npm").exists());
8378 assert!(!repo_root.join("bin").join("council").exists());
8379
8380 let docs = [
8381 repo_root.join("README.md"),
8382 crate_dir.join("README.md"),
8383 crate_dir.join("CHANGELOG.md"),
8384 ];
8385 for path in docs {
8386 let text = fs::read_to_string(&path).unwrap();
8387 assert!(
8388 !text.contains("`council`") && !text.contains(" council "),
8389 "legacy binary reference remains in {}",
8390 path.display()
8391 );
8392 }
8393
8394 let help = render_cli_help();
8395 assert!(!help.contains("council"));
8396 assert!(help.contains("amon-hen"));
8397 }
8398
8399 fn test_amon_hen_result(members: Vec<EngineResult>, total_iterations: usize) -> AmonHenResult {
8400 let timeline = vec![iteration_record(
8401 total_iterations,
8402 total_iterations,
8403 members.clone(),
8404 25,
8405 None,
8406 Some("summary context".to_string()),
8407 )];
8408 test_amon_hen_result_with_iterations(members, timeline)
8409 }
8410
8411 fn test_amon_hen_result_with_iterations(
8412 members: Vec<EngineResult>,
8413 iterations: Vec<IterationRecord>,
8414 ) -> AmonHenResult {
8415 let mut teams = HashMap::new();
8416 teams.insert("codex".to_string(), 0);
8417 teams.insert("claude".to_string(), 0);
8418 teams.insert("gemini".to_string(), 0);
8419 let workflow = Workflow {
8420 handoff: true,
8421 lead: Some("claude".to_string()),
8422 planner: Some("codex".to_string()),
8423 planner_mode: PLANNER_MODE_BLOCKING.to_string(),
8424 owner_map: OWNER_MAP_FLEXIBLE.to_string(),
8425 handshake: false,
8426 handshake_agents: vec![],
8427 iterations: iterations.len().max(1),
8428 team_work: 0,
8429 teams,
8430 };
8431 AmonHenResult {
8432 query: "ship it".to_string(),
8433 cwd: ".".to_string(),
8434 members_requested: members.iter().map(|member| member.name.clone()).collect(),
8435 summarizer_requested: "auto".to_string(),
8436 workflow,
8437 prompt_commands: vec![],
8438 iterations,
8439 members,
8440 summary: test_engine_result("codex", "summary", 1),
8441 consensus: None,
8442 }
8443 }
8444
8445 fn test_engine_result(name: &str, role: &str, iteration: usize) -> EngineResult {
8446 EngineResult {
8447 name: name.to_string(),
8448 bin: Some(name.to_string()),
8449 status: "ok".to_string(),
8450 duration_ms: 10,
8451 detail: String::new(),
8452 exit_code: Some(0),
8453 stdout: String::new(),
8454 stderr: String::new(),
8455 output: format!("{name} output"),
8456 command: format!("{name} exec"),
8457 token_usage: token_usage("prompt", "output"),
8458 tool_calls: vec![ToolUsage {
8459 name: "tool".to_string(),
8460 kind: name.to_string(),
8461 status: "observed".to_string(),
8462 detail: "detail".to_string(),
8463 }],
8464 sub_agents: vec![],
8465 role: role.to_string(),
8466 iteration,
8467 total_iterations: iteration.max(1),
8468 team_size: 0,
8469 }
8470 }
8471}