diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 15:24:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 15:33:45 +0300 |
| commit | 36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (patch) | |
| tree | 4a2d100eef7f26af7feaa93ce661423c3994c54e /src/detect | |
| parent | 8d13492926829c5cf0ea42c8613344cd65ff59fa (diff) | |
feat: distribute agent detection manifests
Diffstat (limited to 'src/detect')
39 files changed, 3556 insertions, 3566 deletions
diff --git a/src/detect/agents/amp.rs b/src/detect/agents/amp.rs deleted file mode 100644 index b1fcba4d..00000000 --- a/src/detect/agents/amp.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::super::AgentState; - -/// Amp (Sourcegraph) detection. -/// -/// Blocked approval prompts use a shared footer with options like -/// "Approve", "Allow All for This Session", "Allow All for Every Session", -/// "Allow File for Every Session", and "Deny with feedback". The header varies -/// by approval type, for example "Invoke tool ...?", "Run this command?", -/// "Allow editing file:", or "Allow creating file:". -/// -/// Working layout: -/// ```text -/// ✓ Search Map the core runtime architecture... -/// ⋯ Oracle ▼ -/// ≈ Running tools... Esc to cancel -/// ``` -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - if lower.contains("esc to cancel") { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_waiting_for_approval = lower.contains("waiting for approval"); - let has_approval_header = lower.contains("invoke tool") - || lower.contains("run this command?") - || lower.contains("allow editing file:") - || lower.contains("allow creating file:") - || lower.contains("confirm tool call"); - let has_approval_actions = lower.contains("approve") - && (lower.contains("allow all for this session") - || lower.contains("allow all for every session") - || lower.contains("allow file for every session") - || lower.contains("deny with feedback")); - - has_approval_actions && (has_waiting_for_approval || has_approval_header) -} diff --git a/src/detect/agents/antigravity.rs b/src/detect/agents/antigravity.rs deleted file mode 100644 index 9790c5a4..00000000 --- a/src/detect/agents/antigravity.rs +++ /dev/null @@ -1,76 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - if has_antigravity_spinner(content) || has_antigravity_background_tasks(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_permission_request = lower.contains("requesting permission for:"); - let has_permission_question = lower.contains("do you want to proceed?"); - let has_permission_controls = lower.contains("tab amend") && lower.contains("edit command"); - has_permission_request && (has_permission_question || has_permission_controls) -} - -fn has_antigravity_spinner(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim_start(); - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !('\u{2800}'..='\u{28FF}').contains(&first) { - return false; - } - - let rest = chars - .as_str() - .trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) - .trim_start(); - status_word_is_active(rest) - }) -} - -fn has_antigravity_background_tasks(content: &str) -> bool { - let bottom_lines: Vec<&str> = content - .lines() - .rev() - .filter(|line| !line.trim().is_empty()) - .take(5) - .collect(); - - bottom_lines.into_iter().any(|line| { - let line = line.trim().to_lowercase(); - line.contains("/tasks") && antigravity_task_count(&line).is_some_and(|count| count > 0) - }) -} - -fn antigravity_task_count(line: &str) -> Option<u32> { - for marker in [" task(s)", " tasks", " task"] { - let Some((before, _)) = line.split_once(marker) else { - continue; - }; - let raw_count = before.split_whitespace().last()?.trim_matches(|c| c == '·'); - if let Ok(count) = raw_count.parse() { - return Some(count); - } - } - None -} - -fn status_word_is_active(rest: &str) -> bool { - let Some(word) = rest.split_whitespace().next() else { - return false; - }; - word.trim_end_matches(|c: char| !c.is_alphabetic()) - .to_ascii_lowercase() - .ends_with("ing") -} diff --git a/src/detect/agents/claude_code.rs b/src/detect/agents/claude_code.rs deleted file mode 100644 index b159fede..00000000 --- a/src/detect/agents/claude_code.rs +++ /dev/null @@ -1,434 +0,0 @@ -use super::super::{has_confirmation_prompt, has_selection_prompt, AgentState}; - -/// Claude Code detection. The most complex — it has a structured prompt box UI. -/// -/// Screen layout: -/// ```text -/// (agent output / tool results) -/// ───────────────────────── (top border) -/// ❯ _ (prompt line) -/// ───────────────────────── (bottom border) -/// ``` -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_live_blocked_form(content) { - return AgentState::Blocked; - } - - if has_dynamic_workflow_prompt(&lower) { - return AgentState::Blocked; - } - - if has_working_chrome(content) { - return AgentState::Working; - } - - if !has_prompt_box(content) && has_claude_blocked_prompt(content, &lower) { - return AgentState::Blocked; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - if has_live_input_prompt_box(content) { - return false; - } - - let lower = content.to_lowercase(); - has_live_blocked_form(content) - || has_dynamic_workflow_prompt(&lower) - || lower.contains("do you want to proceed?") - && has_claude_yes_no_choice(content) - && (lower.contains("bash command") - || lower.contains("bash(") - || lower.contains("contains expansion") - || lower.contains("tab to amend") - || lower.contains("ctrl+e to explain")) -} - -pub(in crate::detect) fn has_idle_recap_notice(content: &str) -> bool { - if !has_prompt_box(content) || has_visible_blocker(content) { - return false; - } - - let above_prompt = content_above_prompt_box(content); - let bottom_lines = bottom_non_empty_lines(above_prompt, 8); - let Some(last_line) = bottom_lines.last() else { - return false; - }; - if !last_line - .to_ascii_lowercase() - .contains("(disable recaps in /config)") - { - return false; - } - let bottom = normalize_lines(&bottom_lines).to_ascii_lowercase(); - - bottom.contains("※ recap:") - && !bottom.contains("esc to interrupt") - && !bottom.contains("ctrl+c to interrupt") -} - -pub(super) fn has_working_chrome(content: &str) -> bool { - let above = content_above_prompt_box(content); - let above_lower = above.to_lowercase(); - above_lower.contains("esc to interrupt") - || above_lower.contains("ctrl+c to interrupt") - || has_running_status_line(above) - || has_spinner_activity(above) -} - -pub(super) fn is_transcript_viewer(content: &str) -> bool { - let bottom_lines = bottom_non_empty_lines(content, 3); - let Some(last_line) = bottom_lines.last() else { - return false; - }; - let bottom_text = normalize_lines(&bottom_lines); - - bottom_text.contains("showing detailed transcript") - && bottom_text.contains("ctrl+o to toggle") - && (bottom_text.contains("ctrl+e to show all") - || bottom_text.contains("ctrl+e to collapse")) - && transcript_control_tail(last_line) -} - -pub(super) fn has_prompt_box(content: &str) -> bool { - prompt_box_body_lines(content) - .is_some_and(|lines| lines.iter().any(|line| line.trim_start().starts_with('❯'))) -} - -fn has_live_input_prompt_box(content: &str) -> bool { - prompt_box_body_lines(content).is_some_and(|lines| { - let non_empty: Vec<&str> = lines - .iter() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect(); - let Some(first) = non_empty.first() else { - return false; - }; - first.trim_start().starts_with('❯') - && !non_empty - .iter() - .any(|line| claude_prompt_box_line_is_selector_chrome(line)) - }) -} - -fn claude_prompt_box_line_is_selector_chrome(line: &str) -> bool { - let trimmed = line.trim().trim_start_matches('❯').trim_start(); - let lower = trimmed.to_ascii_lowercase(); - - lower.contains("enter to select") - || lower.contains("enter to confirm") - || lower.contains("enter to submit") - || lower.contains("esc to cancel") - || lower.contains("tab/arrow") - || lower.contains("arrow keys") - || lower.contains("↑/↓") - || lower.contains("↑↓") - || lower.contains("ctrl+g to edit") - || lower.contains("ctrl+e to explain") - || trimmed - .split_once('.') - .is_some_and(|(prefix, _)| prefix.trim().parse::<u32>().is_ok()) -} - -fn prompt_box_body_lines(content: &str) -> Option<Vec<&str>> { - let lines: Vec<&str> = content.lines().collect(); - let top_border_index = claude_prompt_box_top_border_index(&lines)?; - - Some( - lines[top_border_index + 1..] - .iter() - .take_while(|line| !is_horizontal_rule(line)) - .copied() - .collect(), - ) -} - -/// Claude uses the same generic Select and Dialog widgets for both -/// permission flows and ordinary slash/settings menus. Match only the -/// permission and interview prompts that actually need user input. -fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool { - has_confirmation_prompt(lower_content) - || lower_content.contains("do you want to proceed?") - || lower_content.contains("would you like to proceed?") - || has_dynamic_workflow_prompt(lower_content) - || lower_content.contains("waiting for permission") - || lower_content.contains("do you want to allow this connection?") - || lower_content.contains("tab to amend") - || lower_content.contains("ctrl+e to explain") - || lower_content.contains("review your answers") - || lower_content.contains("skip interview and plan immediately") - || (has_selection_prompt(content) && has_claude_yes_no_choice(content)) -} - -fn has_dynamic_workflow_prompt(lower_content: &str) -> bool { - lower_content.contains("run a dynamic workflow?") && lower_content.contains("esc to cancel") -} - -fn has_live_blocked_form(content: &str) -> bool { - let region = content_after_last_horizontal_rule(content); - region.lines().any(|line| { - let lower = line.to_lowercase(); - lower.contains("enter to select") - && lower.contains("esc to cancel") - && (lower.contains("tab/arrow keys to navigate") - || lower.contains("arrow keys to navigate") - || lower.contains("arrows to navigate") - || lower.contains("↑/↓ to navigate") - || lower.contains("↑↓ to navigate")) - }) -} - -fn has_running_status_line(content_above_prompt: &str) -> bool { - let Some(line) = content_above_prompt - .lines() - .rev() - .find(|line| !line.trim().is_empty()) - else { - return false; - }; - - is_background_agent_wait_line(line) || is_still_running_status_line(line) -} - -fn is_background_agent_wait_line(line: &str) -> bool { - let mut text = line.trim(); - if !text.starts_with("Waiting for ") && !text.starts_with("waiting for ") { - let mut chars = text.chars(); - let Some(first) = chars.next() else { - return false; - }; - if first.is_alphanumeric() { - return false; - } - text = chars.as_str().trim_start(); - } - - let lower = text.to_ascii_lowercase(); - let Some(rest) = lower.strip_prefix("waiting for ") else { - return false; - }; - let Some((count, rest)) = rest.split_once(' ') else { - return false; - }; - if count.parse::<u32>().ok().is_none_or(|count| count == 0) { - return false; - } - - rest == "background agent to finish" || rest == "background agents to finish" -} - -fn is_still_running_status_line(line: &str) -> bool { - let lower = line.to_ascii_lowercase(); - let words: Vec<&str> = lower.split_whitespace().collect(); - - for (index, word) in words.iter().enumerate() { - let Ok(count) = word.parse::<u32>() else { - continue; - }; - if count == 0 { - continue; - } - - if matches!( - words.get(index + 1..index + 4), - Some(["shell" | "shells", "still", "running"]) - ) { - return true; - } - - if matches!( - words.get(index + 1..index + 5), - Some(["local", "agent" | "agents", "still", "running"]) - ) { - return true; - } - } - - false -} - -fn has_claude_yes_no_choice(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line - .trim() - .trim_start_matches('❯') - .trim_start() - .to_lowercase(); - trimmed == "yes" - || trimmed == "no" - || trimmed.starts_with("1. yes") - || trimmed.starts_with("2. no") - || trimmed.starts_with("yes, and ") - || trimmed.starts_with("no, and tell claude") - }) -} - -/// Claude Code spinner characters + activity label. -/// The verb changes frequently ("Processing…", "Pouncing…", etc.), so rely -/// on the spinner glyph + trailing ellipsis rather than specific wording. -/// Include Claude's narrow-pane middle-dot frame too. -pub(in crate::detect) fn has_spinner_activity(content: &str) -> bool { - const SPINNER_CHARS: &str = "·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆"; - for line in content.lines() { - let trimmed = line.trim(); - let mut chars = trimmed.chars(); - if let Some(first) = chars.next() { - if SPINNER_CHARS.contains(first) { - let rest: String = chars.collect(); - if rest.starts_with(' ') - && rest.contains('\u{2026}') - && rest.chars().any(|c| c.is_alphanumeric()) - { - return true; - } - } - } - } - false -} - -/// Extract content above Claude's prompt box. -/// The prompt box is two ─── border lines with ❯ between them. -pub(in crate::detect) fn content_above_prompt_box(content: &str) -> &str { - let lines: Vec<&str> = content.lines().collect(); - - if let Some(i) = claude_prompt_box_top_border_index(&lines) { - let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum(); - return &content[..byte_offset.min(content.len())]; - } - - // No prompt box found, return all content - content -} - -fn content_after_last_horizontal_rule(content: &str) -> &str { - let mut last_rule_end = 0usize; - let mut offset = 0usize; - for line in content.lines() { - let next_offset = offset + line.len() + 1; - if is_horizontal_rule(line) { - last_rule_end = next_offset.min(content.len()); - } - offset = next_offset; - } - - &content[last_rule_end..] -} - -fn claude_prompt_box_top_border_index(lines: &[&str]) -> Option<usize> { - let mut border_count = 0; - - for i in (0..lines.len()).rev() { - if is_horizontal_rule(lines[i]) { - border_count += 1; - if border_count == 2 { - return Some(i); - } - } - } - - None -} - -fn is_horizontal_rule(line: &str) -> bool { - let trimmed = line.trim(); - if trimmed.is_empty() { - return false; - } - - let rule_chars = trimmed.chars().take_while(|&c| c == '─').count(); - if rule_chars == 0 { - return false; - } - - let rule_bytes = trimmed - .char_indices() - .nth(rule_chars) - .map(|(index, _)| index) - .unwrap_or(trimmed.len()); - let suffix = trimmed[rule_bytes..].trim_start(); - - suffix.is_empty() || rule_chars >= 3 -} - -fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> { - let mut lines: Vec<&str> = content - .lines() - .rev() - .filter(|line| !line.trim().is_empty()) - .take(max_lines) - .collect(); - lines.reverse(); - lines -} - -fn normalize_lines(lines: &[&str]) -> String { - lines - .iter() - .flat_map(|line| line.split_whitespace()) - .collect::<Vec<_>>() - .join(" ") - .to_lowercase() -} - -fn transcript_control_tail(line: &str) -> bool { - let lower = line.to_lowercase(); - lower.contains("ctrl+e") - || lower.contains("show all") - || lower.contains("collapse") - || lower.contains("verbose") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn prompt_box_below(content_above_prompt: &str) -> String { - format!( - "{content_above_prompt}\n────────────────────────────────\n❯ \n────────────────────────────────\n" - ) - } - - #[test] - fn shell_still_running_status_line_is_working() { - let content = prompt_box_below( - "● Started. I'll tell you when it finishes.\n\n✻ Crunched for 7s · 1 shell still running", - ); - - assert_eq!(detect(&content), AgentState::Working); - assert!(has_working_chrome(&content)); - } - - #[test] - fn local_agent_still_running_status_line_is_working() { - let content = prompt_box_below( - "● Hey. What do you want to work on?\n\n✻ Worked for 4s · 2 local agents still running", - ); - - assert_eq!(detect(&content), AgentState::Working); - assert!(has_working_chrome(&content)); - } - - #[test] - fn lower_agent_picker_shell_count_is_not_working_chrome() { - let content = prompt_box_below(" ~/P/herdr ⎇ master ▱▱▱▱▱ 0%\n 1 shell · ← for agents"); - - assert_eq!(detect(&content), AgentState::Idle); - assert!(!has_working_chrome(&content)); - } - - #[test] - fn stale_shell_running_line_above_newer_output_is_not_working_chrome() { - let content = prompt_box_below( - "● Started. I'll tell you when it finishes.\n\n✻ Crunched for 7s · 1 shell still running\n\n● hi", - ); - - assert_eq!(detect(&content), AgentState::Idle); - assert!(!has_working_chrome(&content)); - } -} diff --git a/src/detect/agents/cline.rs b/src/detect/agents/cline.rs deleted file mode 100644 index c0ce6f6e..00000000 --- a/src/detect/agents/cline.rs +++ /dev/null @@ -1,19 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - // Blocked - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Cline defaults to working (unlike most agents that default to idle) - AgentState::Working -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - lower.contains("let cline use this tool") - || ((lower.contains("[act mode]") || lower.contains("[plan mode]")) - && (lower.contains("execute command?") || lower.contains("use this tool?")) - && lower.contains("yes")) -} diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs deleted file mode 100644 index 93d2998c..00000000 --- a/src/detect/agents/codex.rs +++ /dev/null @@ -1,258 +0,0 @@ -use super::super::{has_confirmation_prompt, has_interrupt_pattern, AgentState}; - -pub(super) fn detect(content: &str) -> AgentState { - // Strong blocked patterns are structural Codex UI chrome, so they can win - // even when the prompt region is visible. - if has_codex_strong_blocked_prompt(content) { - return AgentState::Blocked; - } - - let lower = content.to_lowercase(); - - // Working - if has_codex_working_status_at_current_prompt(content) { - return AgentState::Working; - } - - // Weak blocked patterns are too broad to become visible blockers, but the - // legacy state detector still reports them as blocked for compatibility. - if has_codex_weak_blocked_prompt(&lower) { - return AgentState::Blocked; - } - - // Fallback working signals for narrow captures where the footer scrolled - // out or the working row is the only Codex chrome visible. - if has_interrupt_pattern(&lower) || has_codex_working_header(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - has_codex_strong_blocked_prompt(content) -} - -pub(super) fn has_visible_working(content: &str) -> bool { - has_codex_live_working_at_current_prompt(content) - || (!has_codex_current_prompt(content) && has_codex_visible_working_without_prompt(content)) -} - -pub(super) fn is_transcript_viewer(content: &str) -> bool { - let bottom_lines = bottom_non_empty_lines(content, 3); - let Some(last_line) = bottom_lines.last() else { - return false; - }; - let bottom_text = normalize_lines(&bottom_lines); - - bottom_text.contains("↑/↓ to scroll") - && bottom_text.contains("pgup/pgdn to page") - && bottom_text.contains("home/end to jump") - && bottom_text.contains("q to quit") - && has_codex_edit_prev_controls(&bottom_text) - && transcript_control_tail(last_line) -} - -fn has_codex_edit_prev_controls(bottom_text: &str) -> bool { - bottom_text.contains("esc to edit prev") || bottom_text.contains("esc/← to edit prev") -} - -fn has_codex_visible_working_without_prompt(content: &str) -> bool { - let mut recent_lines = content.lines().rev().filter(|line| !line.trim().is_empty()); - let Some(last_line) = recent_lines.next() else { - return false; - }; - - if codex_live_working_line(last_line) { - return true; - } - - codex_status_detail_line(last_line) - && recent_lines - .take(4) - .find(|line| codex_block_marker_line(line)) - .is_some_and(codex_live_working_line) -} - -fn has_codex_strong_blocked_prompt(content: &str) -> bool { - let lines: Vec<&str> = content.lines().collect(); - let live_region = lines - .iter() - .rposition(|line| codex_prompt_line(line)) - .map(|prompt_index| lines[prompt_index + 1..].join("\n")) - .unwrap_or_else(|| content.to_string()); - let lower_content = live_region.to_lowercase(); - - lower_content.contains("press enter to confirm or esc to cancel") - || lower_content.contains("enter to submit answer") - || lower_content.contains("enter to submit all") - || lower_content.contains("allow command?") -} - -fn has_codex_weak_blocked_prompt(lower_content: &str) -> bool { - lower_content.contains("[y/n]") - || lower_content.contains("yes (y)") - || has_confirmation_prompt(lower_content) -} - -fn has_codex_live_working_at_current_prompt(content: &str) -> bool { - codex_last_block_marker_before_current_prompt(content).is_some_and(codex_live_working_line) -} - -fn has_codex_working_status_at_current_prompt(content: &str) -> bool { - codex_last_block_marker_before_current_prompt(content).is_some_and(codex_working_status_line) -} - -fn codex_last_block_marker_before_current_prompt(content: &str) -> Option<&str> { - let (lines, prompt_index) = codex_current_prompt_region(content)?; - - lines[..prompt_index] - .iter() - .rev() - .find(|line| codex_block_marker_line(line)) - .copied() -} - -fn has_codex_working_header(content: &str) -> bool { - content.lines().any(codex_working_status_line) -} - -fn codex_live_working_line(line: &str) -> bool { - if codex_queued_input_header_line(line) { - return true; - } - - let trimmed = line.trim_start(); - let lower = trimmed.to_lowercase(); - codex_working_status_line(line) - && (trimmed.contains("Waiting for background terminal") - || has_codex_status_interrupt_hint(&lower) - || lower.contains("background terminal running") - || lower.contains("/ps to view") - || lower.contains("/stop to close")) -} - -fn codex_working_status_line(line: &str) -> bool { - if codex_queued_input_header_line(line) { - return true; - } - - let trimmed = line.trim_start(); - let lower = trimmed.to_lowercase(); - trimmed.starts_with('•') - && (trimmed.contains("Working (") - || trimmed.contains("Waiting for background terminal (") - || has_codex_status_interrupt_hint(&lower) - || lower.contains("reviewing approval request (") - || (lower.contains("reviewing ") && lower.contains(" approval requests (")) - || trimmed.contains("Booting MCP server:")) -} - -fn has_codex_status_interrupt_hint(lower_line: &str) -> bool { - let Some((before_escape, after_escape)) = lower_line.split_once(" • esc") else { - return false; - }; - - has_codex_status_elapsed(before_escape) && has_codex_status_escape_suffix(after_escape) -} - -fn has_codex_status_elapsed(before_escape: &str) -> bool { - let Some((_, elapsed)) = before_escape.rsplit_once('(') else { - return false; - }; - - let parts: Vec<&str> = elapsed.split_whitespace().collect(); - (1..=3).contains(&parts.len()) - && parts.iter().all(|part| { - part.len() >= 2 - && matches!(part.as_bytes().last(), Some(b'h' | b'm' | b's')) - && part[..part.len() - 1].chars().all(|ch| ch.is_ascii_digit()) - }) -} - -fn has_codex_status_escape_suffix(after_escape: &str) -> bool { - let suffix = after_escape.trim_start(); - if suffix.starts_with('…') || suffix.starts_with("to interrupt") { - return true; - } - - let Some(rest) = suffix.strip_prefix("to ") else { - return false; - }; - if rest.starts_with('…') { - return true; - } - - rest.split_once('…') - .map(|(fragment, _)| !fragment.is_empty() && "interrupt".starts_with(fragment.trim_end())) - .unwrap_or(false) -} - -fn has_codex_current_prompt(content: &str) -> bool { - codex_current_prompt_region(content).is_some() -} - -fn codex_current_prompt_region(content: &str) -> Option<(Vec<&str>, usize)> { - let lines: Vec<&str> = content.lines().collect(); - let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?; - - if lines[prompt_index + 1..] - .iter() - .any(|line| codex_block_marker_line(line)) - { - return None; - } - - Some((lines, prompt_index)) -} - -fn codex_prompt_line(line: &str) -> bool { - line == "›" || line.starts_with("› ") -} - -fn codex_block_marker_line(line: &str) -> bool { - line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓') -} - -fn codex_status_detail_line(line: &str) -> bool { - line.trim_start().starts_with('└') -} - -fn codex_queued_input_header_line(line: &str) -> bool { - let trimmed = line.trim_start(); - if !trimmed.starts_with('•') { - return false; - } - - let lower = trimmed.to_lowercase(); - lower.starts_with("• queued follow-up inputs") - || lower.starts_with("• messages to be submitted after next tool call") -} - -fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> { - let mut lines: Vec<&str> = content - .lines() - .rev() - .filter(|line| !line.trim().is_empty()) - .take(max_lines) - .collect(); - lines.reverse(); - lines -} - -fn normalize_lines(lines: &[&str]) -> String { - lines - .iter() - .flat_map(|line| line.split_whitespace()) - .collect::<Vec<_>>() - .join(" ") - .to_lowercase() -} - -fn transcript_control_tail(line: &str) -> bool { - let lower = line.to_lowercase(); - lower.contains("q to quit") - || lower.contains("esc to edit") - || lower.contains("esc/← to edit") - || lower.contains("edit message") -} diff --git a/src/detect/agents/cursor.rs b/src/detect/agents/cursor.rs deleted file mode 100644 index f0f00b9b..00000000 --- a/src/detect/agents/cursor.rs +++ /dev/null @@ -1,79 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - // Blocked - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working - if lower.contains("ctrl+c to stop") { - return AgentState::Working; - } - if has_cursor_spinner(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - if lower.contains("waiting for approval") - && lower.contains("run this command?") - && (lower.contains("run (once) (y)") || lower.contains("skip (esc or n)")) - { - return true; - } - - if lower.contains("(y) (enter)") - || lower.contains("keep (n)") - || lower.contains("skip (esc or n)") - { - return true; - } - - content.lines().any(|line| { - let line = line.trim().to_lowercase(); - let has_yes_action = line.contains("(y)"); - has_yes_action - && (line.contains("allow") - || line.contains("run (once)") - || line.contains("→ run") - || line.starts_with("run ")) - }) -} - -/// Cursor status line: spinner glyphs followed by a live action label. -pub(in crate::detect) fn has_cursor_spinner(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim_start(); - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - return false; - }; - let rest = chars.as_str().trim_start(); - - if matches!(first, '⬡' | '⬢') { - return cursor_status_word_is_active(rest); - } - - if ('\u{2800}'..='\u{28FF}').contains(&first) { - let rest = rest.trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c)); - return cursor_status_word_is_active(rest.trim_start()); - } - - false - }) -} - -fn cursor_status_word_is_active(rest: &str) -> bool { - let Some(word) = rest.split_whitespace().next() else { - return false; - }; - word.trim_end_matches(|c: char| !c.is_alphabetic()) - .to_ascii_lowercase() - .ends_with("ing") -} diff --git a/src/detect/agents/droid.rs b/src/detect/agents/droid.rs deleted file mode 100644 index 78dc811e..00000000 --- a/src/detect/agents/droid.rs +++ /dev/null @@ -1,39 +0,0 @@ -use super::super::{has_braille_spinner, AgentState}; - -/// Droid detection. -/// -/// Working: braille spinner line (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) + "Thinking..." + "(Press ESC to stop)" -/// Blocked: EXECUTE prompt with selection box ("Yes, allow" / "No, cancel") + -/// "Use ↑↓ to navigate, Enter to select" -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working: braille spinner character at start of a line + "Thinking..." - // The braille chars (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) are very specific — won't appear in normal content - if has_braille_spinner(content) && lower.contains("esc to stop") { - return AgentState::Working; - } - // Fallback: "ESC to stop" alone is still a strong signal (it's UI chrome) - if lower.contains("esc to stop") { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - // Primary (AND): structural keyword + chrome text = certain - let has_execute = content.contains("EXECUTE"); - let has_selection_chrome = lower.contains("enter to select") - || lower.contains("↑↓ to navigate") - || lower.contains("esc to cancel"); - let has_selection_options = lower.contains("> yes, allow") || lower.contains("> no, cancel"); - - (has_execute && (has_selection_chrome || has_selection_options)) - || (has_selection_chrome && has_selection_options) -} diff --git a/src/detect/agents/gemini.rs b/src/detect/agents/gemini.rs deleted file mode 100644 index 86cf3bca..00000000 --- a/src/detect/agents/gemini.rs +++ /dev/null @@ -1,31 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - let lower = content.to_lowercase(); - // Working - if lower.contains("esc to cancel") { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_choice = lower.contains("yes") || lower.contains("no"); - - content.contains("│ Apply this change") - || content.contains("│ Allow execution") - || (has_choice - && (lower.contains("waiting for user confirmation") - || content.contains("│ Do you want to proceed") - || lower.contains("do you want to proceed?"))) - || content.lines().any(|line| { - let line = line.trim().to_ascii_lowercase(); - line.starts_with("❯") && (line.contains("yes") || line.contains("allow")) - }) -} diff --git a/src/detect/agents/github_copilot.rs b/src/detect/agents/github_copilot.rs deleted file mode 100644 index 0eece95b..00000000 --- a/src/detect/agents/github_copilot.rs +++ /dev/null @@ -1,28 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - // Blocked - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working - if lower.contains("esc to cancel") - || lower.contains("esc cancel") - || lower.contains("esc again to cancel") - { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - lower.contains("esc to cancel") - && (lower.contains("enter to select") - || lower.contains("enter to confirm") - || lower.contains("enter to submit")) -} diff --git a/src/detect/agents/grok.rs b/src/detect/agents/grok.rs deleted file mode 100644 index 8a5834a2..00000000 --- a/src/detect/agents/grok.rs +++ /dev/null @@ -1,37 +0,0 @@ -use super::super::{has_braille_spinner, AgentState}; - -/// Grok Build detection. -/// -/// Blocked permission prompts display a whitelist scope selector with choices -/// like "Yes, proceed" and "No, reject". Working turns show a braille spinner -/// status line such as "⠋ Waiting… 1.8s" plus live controls like -/// "Ctrl+c:cancel" and "Ctrl+Enter:interject". -pub(super) fn detect(content: &str) -> AgentState { - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - let lower = content.to_lowercase(); - if has_braille_spinner(content) - && (lower.contains("waiting") - || lower.contains("run ") - || lower.contains("read ") - || lower.contains("search ") - || lower.contains("list ")) - { - return AgentState::Working; - } - - if lower.contains("ctrl+c:cancel") && lower.contains("ctrl+enter:interject") { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_scope_selector = lower.contains("use ← → to choose permission whitelist scope") - || lower.contains("←/→:scope"); - has_scope_selector && lower.contains("yes, proceed") && lower.contains("no, reject") -} diff --git a/src/detect/agents/hermes.rs b/src/detect/agents/hermes.rs deleted file mode 100644 index 474dda5c..00000000 --- a/src/detect/agents/hermes.rs +++ /dev/null @@ -1,30 +0,0 @@ -use super::super::AgentState; - -/// Hermes Agent detection. -/// -/// Hermes shows a bottom status bar while turns are active and modal approval -/// dialogs for dangerous terminal commands. Prefer the modal controls for -/// blocked detection, then the live interrupt/status controls for working. -pub(super) fn detect(content: &str) -> AgentState { - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - let lower = content.to_lowercase(); - if lower.contains("msg=interrupt") || lower.contains("ctrl+c cancel") { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_approval_options = lower.contains("allow once") - && lower.contains("allow for this session") - && lower.contains("deny"); - let has_approval_controls = lower.contains("enter to confirm") - || lower.contains("↑/↓ to select") - || lower.contains("show full command"); - (lower.contains("dangerous command") || has_approval_options) && has_approval_controls -} diff --git a/src/detect/agents/kilo.rs b/src/detect/agents/kilo.rs deleted file mode 100644 index 46a5601a..00000000 --- a/src/detect/agents/kilo.rs +++ /dev/null @@ -1,13 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - if content.to_lowercase().contains("esc interrupt") { - return AgentState::Working; - } - - super::opencode::detect(content) -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - super::opencode::has_visible_blocker(content) -} diff --git a/src/detect/agents/kimi.rs b/src/detect/agents/kimi.rs deleted file mode 100644 index 9d3821ed..00000000 --- a/src/detect/agents/kimi.rs +++ /dev/null @@ -1,97 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - if has_kimi_blocked_prompt(content) { - return AgentState::Blocked; - } - - if has_kimi_working_status(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - has_current_approval_panel(content) || has_question_panel(content) -} - -pub(super) fn has_visible_working(content: &str) -> bool { - has_kimi_working_status(content) -} - -fn has_kimi_blocked_prompt(content: &str) -> bool { - if has_visible_blocker(content) { - return true; - } - - let lower = content.to_lowercase(); - lower.contains("requesting approval") - && (lower.contains("approve once") || lower.contains("approve for this session")) - && lower.contains("reject") - && (lower.contains("1/2/3/4 choose") || lower.contains("↵ confirm")) -} - -fn has_kimi_working_status(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim(); - if matches!( - trimmed, - "🌕" | "🌖" | "🌗" | "🌘" | "🌑" | "🌒" | "🌓" | "🌔" - ) { - return true; - } - - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !('\u{2800}'..='\u{28FF}').contains(&first) { - return false; - } - - let rest = chars - .as_str() - .trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) - .trim_start() - .to_lowercase(); - rest.starts_with("thinking...") - || rest.starts_with("working...") - || rest.starts_with("using ") - }) -} - -fn has_current_approval_panel(content: &str) -> bool { - let lower = content.to_lowercase(); - has_approval_title(&lower) - && has_numeric_choose_hint(&lower) - && lower.contains("↵ confirm") - && (lower.contains("approve") || lower.contains("reject") || lower.contains("revise")) -} - -fn has_approval_title(lower_content: &str) -> bool { - lower_content.contains("run this command?") - || lower_content.contains("write this file?") - || lower_content.contains("apply these edits?") - || lower_content.contains("stop this task?") - || lower_content.contains("ready to build with this plan?") - || lower_content.lines().any(|line| { - let trimmed = line.trim_start_matches(|c: char| c == '▶' || c.is_whitespace()); - trimmed.starts_with("approve ") && trimmed.ends_with('?') - }) -} - -fn has_question_panel(content: &str) -> bool { - let lower = content.to_lowercase(); - content.lines().any(|line| line.trim() == "question") - && content - .lines() - .any(|line| line.trim_start().starts_with("? ")) - && lower.contains("↑↓ select") - && (lower.contains("↵ choose") || lower.contains("↵ toggle") || lower.contains("↵ save")) - && lower.contains("esc cancel") -} - -fn has_numeric_choose_hint(lower_content: &str) -> bool { - lower_content.contains(" choose") && lower_content.contains('1') && lower_content.contains('2') -} diff --git a/src/detect/agents/kiro.rs b/src/detect/agents/kiro.rs deleted file mode 100644 index 889f5315..00000000 --- a/src/detect/agents/kiro.rs +++ /dev/null @@ -1,60 +0,0 @@ -use super::super::AgentState; - -/// Kiro CLI detection. -/// -/// Kiro exposes reliable working and idle terminal markers. Tool approval -/// prompts render with stable approval wording and an action menu. -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - if lower.contains("kiro is working") - || (lower.contains("esc to cancel") && has_kiro_tool_spinner(content)) - { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower_content = content.to_lowercase(); - has_tool_approval_prompt(&lower_content) || has_subagent_approval_prompt(&lower_content) -} - -fn has_tool_approval_prompt(lower_content: &str) -> bool { - let has_approval_request = lower_content.contains("requires approval"); - let has_approval_actions = lower_content.contains("yes, single permission") - || lower_content.contains("trust, always allow") - || lower_content.contains("no (tab to edit)") - || lower_content.contains("esc to close"); - has_approval_request && has_approval_actions -} - -fn has_subagent_approval_prompt(lower_content: &str) -> bool { - let has_approval_request = (lower_content.contains("tool approval") - || lower_content.contains("tool approvals")) - && lower_content.contains("pending from subagents"); - let has_approval_actions = lower_content.contains("approve all pending") - || lower_content.contains("configure individually") - || lower_content.contains("exit (cancel subagents)"); - has_approval_request && has_approval_actions -} - -fn has_kiro_tool_spinner(content: &str) -> bool { - content.lines().any(|line| { - let trimmed = line.trim_start(); - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !matches!(first, '◔' | '◑' | '◕' | '●') { - return false; - } - let rest = chars.as_str().trim_start(); - rest.chars().next().is_some_and(char::is_alphabetic) - }) -} diff --git a/src/detect/agents/mod.rs b/src/detect/agents/mod.rs deleted file mode 100644 index 72aaf674..00000000 --- a/src/detect/agents/mod.rs +++ /dev/null @@ -1,105 +0,0 @@ -pub(super) mod amp; -pub(super) mod antigravity; -pub(super) mod claude_code; -pub(super) mod cline; -pub(super) mod codex; -pub(super) mod cursor; -pub(super) mod droid; -pub(super) mod gemini; -pub(super) mod github_copilot; -pub(super) mod grok; -pub(super) mod hermes; -pub(super) mod kilo; -pub(super) mod kimi; -pub(super) mod kiro; -pub(super) mod opencode; -pub(super) mod pi; -pub(super) mod qodercli; - -use super::{Agent, AgentDetection, AgentState}; - -pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection { - let state = match agent { - Agent::Pi => pi::detect(screen_content), - Agent::Claude => claude_code::detect(screen_content), - Agent::Codex => codex::detect(screen_content), - Agent::Gemini => gemini::detect(screen_content), - Agent::Cursor => cursor::detect(screen_content), - Agent::Antigravity => antigravity::detect(screen_content), - Agent::Cline => cline::detect(screen_content), - Agent::OpenCode => opencode::detect(screen_content), - Agent::GithubCopilot => github_copilot::detect(screen_content), - Agent::Kimi => kimi::detect(screen_content), - Agent::Kiro => kiro::detect(screen_content), - Agent::Droid => droid::detect(screen_content), - Agent::Amp => amp::detect(screen_content), - Agent::Grok => grok::detect(screen_content), - Agent::Hermes => hermes::detect(screen_content), - Agent::Kilo => kilo::detect(screen_content), - Agent::Qodercli => qodercli::detect(screen_content), - }; - - let skip_state_update = should_skip_state_update(agent, screen_content); - if skip_state_update { - return AgentDetection { - state, - skip_state_update: true, - visible_blocker: false, - visible_working: false, - }; - } - - AgentDetection { - state, - skip_state_update: false, - visible_blocker: has_visible_blocker(agent, screen_content), - visible_working: has_visible_working(agent, screen_content, state), - } -} - -pub(super) fn should_skip_state_update(agent: Agent, content: &str) -> bool { - match agent { - Agent::Claude => claude_code::is_transcript_viewer(content), - Agent::Codex => codex::is_transcript_viewer(content), - _ => false, - } -} - -fn has_visible_blocker(agent: Agent, content: &str) -> bool { - match agent { - // Strong visible blockers are opt-in because this flag can override - // hook authority. Plain blocked heuristics remain valid fallback state, - // but they must not become hook overrides unless the current UI chrome - // is known to be structural and live. - Agent::Claude => claude_code::has_visible_blocker(content), - Agent::Codex => codex::has_visible_blocker(content), - Agent::Gemini => gemini::has_visible_blocker(content), - Agent::Cursor => cursor::has_visible_blocker(content), - Agent::Antigravity => antigravity::has_visible_blocker(content), - Agent::Cline => cline::has_visible_blocker(content), - Agent::OpenCode => opencode::has_visible_blocker(content), - Agent::GithubCopilot => github_copilot::has_visible_blocker(content), - Agent::Kimi => kimi::has_visible_blocker(content), - Agent::Kiro => kiro::has_visible_blocker(content), - Agent::Droid => droid::has_visible_blocker(content), - Agent::Amp => amp::has_visible_blocker(content), - Agent::Grok => grok::has_visible_blocker(content), - Agent::Hermes => hermes::has_visible_blocker(content), - Agent::Kilo => kilo::has_visible_blocker(content), - Agent::Qodercli => qodercli::has_visible_blocker(content), - _ => false, - } -} - -fn has_visible_working(agent: Agent, content: &str, state: AgentState) -> bool { - if state != AgentState::Working { - return false; - } - - match agent { - Agent::Claude => claude_code::has_working_chrome(content), - Agent::Codex => codex::has_visible_working(content), - Agent::Kimi => kimi::has_visible_working(content), - _ => false, - } -} diff --git a/src/detect/agents/opencode.rs b/src/detect/agents/opencode.rs deleted file mode 100644 index ab8a424f..00000000 --- a/src/detect/agents/opencode.rs +++ /dev/null @@ -1,58 +0,0 @@ -use super::super::{has_interrupt_pattern, AgentState}; - -pub(super) fn detect(content: &str) -> AgentState { - // Blocked - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working - if has_interrupt_pattern(&content.to_lowercase()) - || has_opencode_interrupt_footer(content) - || has_opencode_progress_run(content) - { - return AgentState::Working; - } - - AgentState::Idle -} - -pub(super) fn has_visible_blocker(content: &str) -> bool { - content.contains("△ Permission required") || has_opencode_question_prompt(content) -} - -fn has_opencode_question_prompt(content: &str) -> bool { - let lower = content.to_lowercase(); - let has_enter_action = lower.contains("enter confirm") - || lower.contains("enter submit") - || lower.contains("enter toggle"); - let has_question_nav = content.contains("↑↓ select") || content.contains("⇆ tab"); - - lower.contains("esc dismiss") && has_enter_action && has_question_nav -} - -fn has_opencode_interrupt_footer(content: &str) -> bool { - content.lines().any(|line| { - let lower = line.to_lowercase(); - if !(lower.contains("esc interrupt") || lower.contains("esc again to interrupt")) { - return false; - } - - lower.contains("opencode") - }) -} - -fn has_opencode_progress_run(line: &str) -> bool { - let mut run = 0usize; - for ch in line.chars() { - if matches!(ch, '■' | '⬝') { - run += 1; - if run >= 4 { - return true; - } - } else { - run = 0; - } - } - false -} diff --git a/src/detect/agents/pi.rs b/src/detect/agents/pi.rs deleted file mode 100644 index 272231f5..00000000 --- a/src/detect/agents/pi.rs +++ /dev/null @@ -1,9 +0,0 @@ -use super::super::AgentState; - -pub(super) fn detect(content: &str) -> AgentState { - // pi shows "Working..." when the agent is processing - if content.contains("Working...") { - return AgentState::Working; - } - AgentState::Idle -} diff --git a/src/detect/agents/qodercli.rs b/src/detect/agents/qodercli.rs deleted file mode 100644 index d2802464..00000000 --- a/src/detect/agents/qodercli.rs +++ /dev/null @@ -1,86 +0,0 @@ -use super::super::AgentState; - -/// Qodercli detection. -/// -/// Qodercli is a Node.js coding-agent CLI. It surfaces a confirmation prompt -/// while awaiting tool approval and a braille spinner while working. -pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if has_visible_blocker(content) { - return AgentState::Blocked; - } - - // Working: explicit "(esc to cancel, …)" hint or an active spinner row. - if has_qodercli_working_hint(&lower) || has_qodercli_spinner_row(content) { - return AgentState::Working; - } - - AgentState::Idle -} - -/// Working hints qodercli prints alongside the spinner while the model is -/// responding. The "(esc to cancel, …)" suffix is unique to qodercli's loading -/// indicator and survives even when the spinner glyph is masked (e.g. by -/// a hook icon). -fn has_qodercli_working_hint(lower_content: &str) -> bool { - lower_content.contains("(esc to cancel,") -} - -/// Strict spinner-row detection for qodercli. -/// -/// Matches a line whose first non-whitespace glyph is a braille pattern -/// (U+2800–U+28FF, the cli-spinners "dots" set qodercli renders), followed by -/// a space and at least one alphabetic character on the same line. This avoids -/// flagging the pane as Working when the scrollback merely contains a stale -/// braille glyph from an earlier frame. -fn has_qodercli_spinner_row(content: &str) -> bool { - for line in content.lines() { - let trimmed = line.trim_start(); - let mut chars = trimmed.chars(); - let Some(first) = chars.next() else { - continue; - }; - if !('\u{2800}'..='\u{28FF}').contains(&first) { - continue; - } - let rest: String = chars.collect(); - if rest.starts_with(' ') && rest.chars().any(|c| c.is_alphabetic()) { - return true; - } - } - false -} - -/// Blocked patterns specific to qodercli. -/// -/// Mirrors the helper structure used by Claude blocked prompt matching so the -/// pattern surface stays a single, easy-to-extend list. -/// -/// Covered states: -/// * Tool-call confirmation banners ("Waiting for user confirmation", -/// "Awaiting approval"). -/// * The "Permission Required / Allow once or always?" approval dialog. -/// * The `ask-user` tool's interactive prompt. "Asking User" is the dialog's -/// stable BaseTabDialog title and covers every form (single-select, -/// multi-select, free-form input, review tab). The "Enter your response" -/// placeholder and "Review your answers:" review heading are kept as -/// defensive fallbacks in case the title row scrolls off-screen. -/// * The interactive shell waiting hint emitted by qodercli when an agent -/// spawns a shell that is now parked for user keystrokes. -pub(super) fn has_visible_blocker(content: &str) -> bool { - let lower_content = content.to_lowercase(); - (lower_content.contains("waiting for user confirmation") - && (lower_content.contains("yes") - || lower_content.contains("no") - || lower_content.contains("allow") - || lower_content.contains("reject"))) - || (lower_content.contains("awaiting approval") - && (lower_content.contains("allow") || lower_content.contains("reject"))) - || lower_content.contains("permission required") - || lower_content.contains("allow once or always?") - || lower_content.contains("asking user") - || lower_content.contains("enter your response") - || lower_content.contains("review your answers:") - || lower_content.contains("shell awaiting input") -} diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs new file mode 100644 index 00000000..6f660367 --- /dev/null +++ b/src/detect/manifest.rs @@ -0,0 +1,1411 @@ +use std::{ + path::{Path, PathBuf}, + sync::{Mutex, OnceLock, RwLock}, +}; + +use regex::Regex; +use serde::Deserialize; + +use super::{ + agent_label, manifest_update::ManifestVersion, parse_agent_label, Agent, AgentDetection, + AgentState, +}; + +pub const DEFAULT_KNOWN_AGENT_IDLE_FALLBACK: &str = "default_known_agent_idle_fallback"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectionExplain { + pub agent: Option<String>, + pub state: AgentState, + pub source: Option<ManifestSource>, + pub matched_rule: Option<MatchedRule>, + pub screen_detection_skipped: bool, + pub visible_idle: bool, + pub visible_blocker: bool, + pub visible_working: bool, + pub skip_state_update: bool, + pub skipped_update_reason: Option<String>, + pub fallback_reason: Option<String>, + pub evaluated_rules: Vec<EvaluatedRule>, + pub warning: Option<String>, + pub manifest_version: Option<String>, + pub cached_remote_version: Option<String>, + pub local_override_shadowing_remote: bool, + pub remote_update_status: Option<String>, + pub remote_update_error: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManifestSource { + Bundled, + Remote { path: PathBuf, version: String }, + Override(PathBuf), +} + +impl ManifestSource { + pub fn label(&self) -> String { + match self { + Self::Bundled => "bundled".to_string(), + Self::Remote { path, .. } => format!("remote:{}", path.display()), + Self::Override(path) => path.display().to_string(), + } + } + + pub fn kind(&self) -> &'static str { + match self { + Self::Bundled => "bundled", + Self::Remote { .. } => "remote", + Self::Override(_) => "local override", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentManifestSummary { + pub(crate) agent: Agent, + pub(crate) active_source: ManifestSource, + pub(crate) active_version: Option<String>, + pub(crate) cached_remote_version: Option<String>, + pub(crate) local_override_shadowing_remote: bool, + pub(crate) warning: Option<String>, +} + +pub(crate) fn manifest_summaries() -> Vec<AgentManifestSummary> { + let lock = manifest_cache(); + let guard = match lock.read() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + manifest_summaries_from_cache(&guard) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchedRule { + pub id: String, + pub priority: i32, + pub region: String, + pub state: AgentState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvaluatedRule { + pub id: String, + pub priority: i32, + pub region: String, + pub evidence: RuleEvidence, + pub state: AgentState, + pub matched: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleEvidence { + pub contains: Vec<String>, + pub regex: Vec<String>, + pub line_regex: Vec<String>, + pub all_count: usize, + pub any_count: usize, + pub not_count: usize, + pub region_bytes: usize, + pub region_preview: String, +} + +#[derive(Debug, Clone)] +struct LoadedManifest { + manifest: AgentManifest, + compiled_rules: Vec<CompiledRule>, + source: ManifestSource, + warning: Option<String>, + cached_remote_version: Option<String>, + local_override_shadowing_remote: bool, +} + +#[derive(Debug, Clone)] +struct ManifestCache { + manifests: Vec<(Agent, Option<LoadedManifest>)>, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub(crate) struct AgentManifest { + id: String, + version: Option<ManifestVersion>, + min_engine_version: Option<u32>, + #[serde(rename = "updated_at")] + _updated_at: Option<String>, + #[serde(default)] + aliases: Vec<String>, + #[serde(default)] + rules: Vec<ManifestRule>, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestRule { + id: String, + state: Option<ManifestState>, + #[serde(default)] + priority: i32, + #[serde(default = "default_region")] + region: String, + #[serde(default)] + visible_idle: bool, + #[serde(default)] + visible_blocker: bool, + #[serde(default)] + visible_working: bool, + #[serde(default)] + skip_state_update: bool, + #[serde(default)] + all: Vec<ManifestGate>, + #[serde(default)] + any: Vec<ManifestGate>, + #[serde(default, rename = "not")] + not_gate: Vec<ManifestGate>, + #[serde(default)] + contains: Vec<String>, + #[serde(default)] + regex: Vec<String>, + #[serde(default)] + line_regex: Vec<String>, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestGate { + #[serde(default)] + all: Vec<ManifestGate>, + #[serde(default)] + any: Vec<ManifestGate>, + #[serde(default, rename = "not")] + not_gate: Vec<ManifestGate>, + #[serde(default)] + contains: Vec<String>, + #[serde(default)] + regex: Vec<String>, + #[serde(default)] + line_regex: Vec<String>, +} + +#[derive(Debug, Clone)] +struct CompiledRule { + gate: CompiledGate, +} + +#[derive(Debug, Clone)] +struct CompiledGate { + all: Vec<CompiledGate>, + any: Vec<CompiledGate>, + not_gate: Vec<CompiledGate>, + contains: Vec<String>, + regex: Vec<Regex>, + line_regex: Vec<Regex>, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ManifestState { + Idle, + Working, + Blocked, + Unknown, +} + +impl From<ManifestState> for AgentState { + fn from(value: ManifestState) -> Self { + match value { + ManifestState::Idle => AgentState::Idle, + ManifestState::Working => AgentState::Working, + ManifestState::Blocked => AgentState::Blocked, + ManifestState::Unknown => AgentState::Unknown, + } + } +} + +fn default_region() -> String { + "whole_recent".to_string() +} + +const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ + ("amp", include_str!("manifests/amp.toml")), + ("agy", include_str!("manifests/antigravity.toml")), + ("claude", include_str!("manifests/claude.toml")), + ("cline", include_str!("manifests/cline.toml")), + ("codex", include_str!("manifests/codex.toml")), + ("cursor", include_str!("manifests/cursor.toml")), + ("droid", include_str!("manifests/droid.toml")), + ("gemini", include_str!("manifests/gemini.toml")), + ("grok", include_str!("manifests/grok.toml")), + ("hermes", include_str!("manifests/hermes.toml")), + ("kilo", include_str!("manifests/kilo.toml")), + ("kimi", include_str!("manifests/kimi.toml")), + ("kiro", include_str!("manifests/kiro.toml")), + ("opencode", include_str!("manifests/opencode.toml")), + ("pi", include_str!("manifests/pi.toml")), + ("qodercli", include_str!("manifests/qodercli.toml")), + ("copilot", include_str!("manifests/github-copilot.toml")), +]; + +static MANIFEST_CACHE: OnceLock<RwLock<ManifestCache>> = OnceLock::new(); +static MANIFEST_RELOAD_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + +const MAX_RULES_PER_MANIFEST: usize = 128; +const MAX_GATE_DEPTH: usize = 8; +const MAX_TOTAL_GATES: usize = 512; +const MAX_MATCHERS_PER_GATE: usize = 32; +const MAX_TOTAL_MATCHERS: usize = 1024; +const MAX_MATCHER_CHARS: usize = 512; + +pub(crate) fn reload_manifests() -> Vec<AgentManifestSummary> { + let _reload_guard = MANIFEST_RELOAD_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let cache = build_manifest_cache(); + let summaries = manifest_summaries_from_cache(&cache); + let lock = MANIFEST_CACHE.get_or_init(|| RwLock::new(cache.clone())); + match lock.write() { + Ok(mut guard) => *guard = cache, + Err(poisoned) => *poisoned.into_inner() = cache, + } + summaries +} + +fn manifest_cache() -> &'static RwLock<ManifestCache> { + MANIFEST_CACHE.get_or_init(|| RwLock::new(build_manifest_cache())) +} + +fn build_manifest_cache() -> ManifestCache { + ManifestCache { + manifests: Agent::ALL + .into_iter() + .map(|agent| (agent, load_manifest_uncached(agent))) + .collect(), + } +} + +fn manifest_summaries_from_cache(cache: &ManifestCache) -> Vec<AgentManifestSummary> { + cache + .manifests + .iter() + .filter_map(|(agent, loaded)| { + loaded + .clone() + .map(|loaded| manifest_summary_from_loaded(*agent, loaded)) + }) + .collect() +} + +fn manifest_summary_from_loaded(agent: Agent, loaded: LoadedManifest) -> AgentManifestSummary { + AgentManifestSummary { + agent, + active_version: loaded.manifest.version.as_ref().map(ToString::to_string), + active_source: loaded.source, + cached_remote_version: loaded.cached_remote_version, + local_override_shadowing_remote: loaded.local_override_shadowing_remote, + warning: loaded.warning, + } +} + +pub fn detect(agent: Agent, screen_content: &str) -> AgentDetection { + let Some(loaded) = load_manifest(agent) else { + return fallback_explain(Some(agent), None, false).into_detection(); + }; + evaluate_loaded_manifest(agent, screen_content, loaded, false).into_detection() +} + +pub fn explain(agent: Agent, screen_content: &str) -> DetectionExplain { + let Some(loaded) = load_manifest(agent) else { + return fallback_explain(Some(agent), None, true); + }; + evaluate_loaded_manifest(agent, screen_content, loaded, true) +} + +pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain { + let Some(agent) = parse_agent_label(agent_label) else { + return DetectionExplain { + agent: Some(agent_label.to_string()), + state: AgentState::Unknown, + source: None, + matched_rule: None, + screen_detection_skipped: false, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + skipped_update_reason: None, + fallback_reason: Some("unknown_agent".to_string()), + evaluated_rules: Vec::new(), + warning: None, + manifest_version: None, + cached_remote_version: None, + local_override_shadowing_remote: false, + remote_update_status: None, + remote_update_error: None, + }; + }; + explain(agent, screen_content) +} + +pub fn should_skip_state_update(agent: Agent, screen_content: &str) -> bool { + let Some(loaded) = load_manifest(agent) else { + return false; + }; + evaluate_loaded_manifest(agent, screen_content, loaded, false).skip_state_update +} + +impl DetectionExplain { + fn into_detection(self) -> AgentDetection { + AgentDetection { + state: self.state, + skip_state_update: self.skip_state_update, + visible_idle: self.visible_idle, + visible_blocker: self.visible_blocker, + visible_working: self.visible_working, + } + } +} + +fn evaluate_loaded_manifest( + agent: Agent, + screen_content: &str, + loaded: LoadedManifest, + include_update_status: bool, +) -> DetectionExplain { + let mut matched: Option<(&ManifestRule, String)> = None; + let mut evaluated_rules = Vec::new(); + + for (rule, compiled_rule) in loaded.manifest.rules.iter().zip(&loaded.compiled_rules) { + let region_text = region(screen_content, &rule.region); + let matched_rule = compiled_rule_matches(compiled_rule, region_text); + evaluated_rules.push(EvaluatedRule { + id: rule.id.clone(), + priority: rule.priority, + region: rule.region.clone(), + evidence: rule_evidence(rule, region_text), + state: rule + .state + .map(AgentState::from) + .unwrap_or(AgentState::Unknown), + matched: matched_rule, + }); + + if !matched_rule { + continue; + } + + match matched { + Some((previous, _)) if previous.priority >= rule.priority => {} + _ => matched = Some((rule, rule.region.clone())), + } + } + + let Some((rule, region_name)) = matched else { + return fallback_explain( + Some(agent), + Some((loaded, evaluated_rules)), + include_update_status, + ); + }; + + let state = rule + .state + .map(AgentState::from) + .unwrap_or(AgentState::Unknown); + let skipped_update_reason = rule + .skip_state_update + .then(|| format!("matched_rule:{}", rule.id)); + + let remote_update_status = include_update_status + .then(|| remote_update_status(agent)) + .flatten(); + + DetectionExplain { + agent: Some(agent_label(agent).to_string()), + state, + source: Some(loaded.source), + matched_rule: Some(MatchedRule { + id: rule.id.clone(), + priority: rule.priority, + region: region_name, + state, + }), + screen_detection_skipped: false, + visible_idle: rule.visible_idle && state == AgentState::Idle, + visible_blocker: rule.visible_blocker && state == AgentState::Blocked, + visible_working: rule.visible_working && state == AgentState::Working, + skip_state_update: rule.skip_state_update, + skipped_update_reason, + fallback_reason: None, + evaluated_rules, + warning: loaded.warning, + manifest_version: loaded.manifest.version.as_ref().map(ToString::to_string), + cached_remote_version: loaded.cached_remote_version, + local_override_shadowing_remote: loaded.local_override_shadowing_remote, + remote_update_status: remote_update_status + .as_ref() + .map(|status| status.last_result.clone()), + remote_update_error: remote_update_status.and_then(|status| status.last_error), + } +} + +fn fallback_explain( + agent: Option<Agent>, + context: Option<(LoadedManifest, Vec<EvaluatedRule>)>, + include_update_status: bool, +) -> DetectionExplain { + let ( + source, + evaluated_rules, + warning, + manifest_version, + cached_remote_version, + local_override_shadowing_remote, + ) = context + .map(|(loaded, evaluated)| { + ( + Some(loaded.source), + evaluated, + loaded.warning, + loaded.manifest.version.as_ref().map(ToString::to_string), + loaded.cached_remote_version, + loaded.local_override_shadowing_remote, + ) + }) + .unwrap_or((None, Vec::new(), None, None, None, false)); + let known_agent = agent.is_some(); + let remote_update_status = include_update_status + .then(|| agent.and_then(remote_update_status)) + .flatten(); + + DetectionExplain { + agent: agent.map(|agent| agent_label(agent).to_string()), + state: if known_agent { + AgentState::Idle + } else { + AgentState::Unknown + }, + source, + matched_rule: None, + screen_detection_skipped: false, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + skipped_update_reason: None, + fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()), + evaluated_rules, + warning, + manifest_version, + cached_remote_version, + local_override_shadowing_remote, + remote_update_status: remote_update_status + .as_ref() + .map(|status| status.last_result.clone()), + remote_update_error: remote_update_status.and_then(|status| status.last_error), + } +} + +fn load_manifest(agent: Agent) -> Option<LoadedManifest> { + let lock = manifest_cache(); + let guard = match lock.read() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + guard + .manifests + .iter() + .find(|(cached_agent, _)| *cached_agent == agent) + .and_then(|(_, loaded)| loaded.clone()) +} + +fn load_manifest_uncached(agent: Agent) -> Option<LoadedManifest> { + let bundled = bundled_manifest(agent)?; + let mut remote = read_remote_manifest(agent, &bundled); + let cached_remote_version = remote.as_ref().and_then(|loaded| match &loaded.source { + _ if loaded.cached_remote_version.is_some() => loaded.cached_remote_version.clone(), + ManifestSource::Remote { version, .. } => Some(version.clone()), + _ => None, + }); + let Some(path) = override_path(agent) else { + if let Some(loaded) = remote.as_mut() { + loaded.cached_remote_version = cached_remote_version.clone(); + } + return Some(remote.unwrap_or_else(|| { + bundled_loaded_manifest(agent, bundled, None, cached_remote_version, false) + })); + }; + let local_override_shadowing_remote = path.exists() && cached_remote_version.is_some(); + if let Some(loaded) = remote.as_mut() { + loaded.cached_remote_version = cached_remote_version.clone(); + loaded.local_override_shadowing_remote = local_override_shadowing_remote; + } + + if !path.exists() { + return Some(remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + })); + } + + match read_override_manifest(&path) { + Ok(manifest) if manifest_matches_agent(&manifest, agent) => { + match loaded_manifest( + manifest, + ManifestSource::Override(path.clone()), + None, + cached_remote_version.clone(), + local_override_shadowing_remote, + ) { + Ok(loaded) => Some(loaded), + Err(err) => { + let mut loaded = remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + }); + loaded.warning = Some(format!( + "ignored override {} because it could not be compiled: {err}", + path.display() + )); + Some(loaded) + } + } + } + Ok(manifest) => { + let mut loaded = remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + }); + loaded.warning = Some(format!( + "ignored override {} because manifest id {} does not match {}", + path.display(), + manifest.id, + agent_label(agent) + )); + Some(loaded) + } + Err(err) => { + let mut loaded = remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + }); + loaded.warning = Some(format!( + "ignored override {} because it could not be loaded: {err}", + path.display() + )); + Some(loaded) + } + } +} + +fn loaded_manifest( + manifest: AgentManifest, + source: ManifestSource, + warning: Option<String>, + cached_remote_version: Option<String>, + local_override_shadowing_remote: bool, +) -> Result<LoadedManifest, String> { + let compiled_rules = compile_manifest(&manifest)?; + Ok(LoadedManifest { + manifest, + compiled_rules, + source, + warning, + cached_remote_version, + local_override_shadowing_remote, + }) +} + +fn bundled_loaded_manifest( + agent: Agent, + manifest: AgentManifest, + warning: Option<String>, + cached_remote_version: Option<String>, + local_override_shadowing_remote: bool, +) -> LoadedManifest { + loaded_manifest( + manifest, + ManifestSource::Bundled, + warning, + cached_remote_version, + local_override_shadowing_remote, + ) + .unwrap_or_else(|err| { + panic!( + "bundled {} manifest could not be compiled: {err}", + agent_label(agent) + ) + }) +} + +fn bundled_manifest(agent: Agent) -> Option<AgentManifest> { + let id = agent_label(agent); + BUNDLED_MANIFESTS + .iter() + .find(|(manifest_id, _)| *manifest_id == id) + .map(|(_, content)| { + parse_manifest(content) + .unwrap_or_else(|err| panic!("bundled {id} manifest is invalid: {err}")) + }) +} + +fn read_override_manifest(path: &Path) -> Result<AgentManifest, String> { + let content = std::fs::read_to_string(path).map_err(|err| err.to_string())?; + parse_manifest(&content) +} + +fn read_remote_manifest(agent: Agent, bundled: &AgentManifest) -> Option<LoadedManifest> { + let path = super::manifest_update::remote_manifest_path(agent); + if !path.exists() { + return None; + } + match std::fs::read_to_string(&path) + .map_err(|err| err.to_string()) + .and_then(|content| { + parse_remote_manifest_for_agent(agent, &content).map(|parsed| parsed.manifest) + }) { + Ok(manifest) => { + let version = manifest + .version + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "unknown".to_string()); + if let (Some(remote_version), Some(bundled_version)) = + (manifest.version.as_ref(), bundled.version.as_ref()) + { + if remote_version < bundled_version { + return Some(bundled_loaded_manifest( + agent, + bundled.clone(), + Some(format!( + "ignored remote manifest {} because cached version {remote_version} is older than bundled {bundled_version}", + path.display() + )), + Some(remote_version.to_string()), + false, + )); + } + } + match loaded_manifest( + manifest, + ManifestSource::Remote { + path: path.clone(), + version, + }, + None, + None, + false, + ) { + Ok(loaded) => Some(loaded), + Err(err) => Some(bundled_loaded_manifest( + agent, + bundled.clone(), + Some(format!( + "ignored remote manifest {} because it could not be compiled: {err}", + path.display() + )), + None, + false, + )), + } + } + Err(err) => Some(bundled_loaded_manifest( + agent, + bundled.clone(), + Some(format!( + "ignored remote manifest {} because it could not be loaded: {err}", + path.display() + )), + None, + false, + )), + } +} + +pub fn agent_state_label(state: AgentState) -> &'static str { + match state { + AgentState::Idle => "idle", + AgentState::Working => "working", + AgentState::Blocked => "blocked", + AgentState::Unknown => "unknown", + } +} + +pub fn explain_to_json_value(explain: &DetectionExplain) -> serde_json::Value { + let matched_rule = explain.matched_rule.as_ref().map(|rule| { + serde_json::json!({ + "id": rule.id, + "priority": rule.priority, + "region": rule.region, + "state": agent_state_label(rule.state), + }) + }); + let evaluated_rules: Vec<_> = explain + .evaluated_rules + .iter() + .map(|rule| { + serde_json::json!({ + "id": rule.id, + "priority": rule.priority, + "region": rule.region, + "state": agent_state_label(rule.state), + "matched": rule.matched, + "evidence": { + "contains": &rule.evidence.contains, + "regex": &rule.evidence.regex, + "line_regex": &rule.evidence.line_regex, + "all_count": rule.evidence.all_count, + "any_count": rule.evidence.any_count, + "not_count": rule.evidence.not_count, + "region_bytes": rule.evidence.region_bytes, + "region_preview": &rule.evidence.region_preview, + }, + }) + }) + .collect(); + + serde_json::json!({ + "agent": explain.agent, + "state": agent_state_label(explain.state), + "manifest_source": explain.source.as_ref().map(|source| source.label()), + "manifest_version": &explain.manifest_version, + "cached_remote_version": &explain.cached_remote_version, + "local_override_shadowing_remote": explain.local_override_shadowing_remote, + "remote_update_status": &explain.remote_update_status, + "remote_update_error": &explain.remote_update_error, + "matched_rule": matched_rule, + "visible_idle": explain.visible_idle, + "visible_blocker": explain.visible_blocker, + "visible_working": explain.visible_working, + "screen_detection_skipped": explain.screen_detection_skipped, + "skip_state_update": explain.skip_state_update, + "skipped_update_reason": explain.skipped_update_reason, + "fallback_reason": explain.fallback_reason, + "warning": explain.warning, + "evaluated_rules": evaluated_rules, + }) +} + +pub(crate) struct ParsedRemoteManifest { + pub(crate) manifest: AgentManifest, + pub(crate) version: ManifestVersion, +} + +pub(crate) fn parse_manifest(content: &str) -> Result<AgentManifest, String> { + let manifest = toml::from_str::<AgentManifest>(content).map_err(|err| err.to_string())?; + validate_manifest(&manifest)?; + Ok(manifest) +} + +pub(crate) fn parse_remote_manifest_for_agent( + agent: Agent, + content: &str, +) -> Result<ParsedRemoteManifest, String> { + let manifest = parse_manifest(content)?; + if !manifest_matches_agent(&manifest, agent) { + return Err(format!( + "manifest id {} does not match {}", + manifest.id, + agent_label(agent) + )); + } + let version = manifest + .version + .clone() + .ok_or("remote manifest must include version")?; + let min_engine_version = manifest + .min_engine_version + .ok_or("remote manifest must include min_engine_version")?; + if min_engine_version > super::manifest_update::MANIFEST_ENGINE_VERSION { + return Err(format!( + "manifest requires engine {min_engine_version}, current engine is {}", + super::manifest_update::MANIFEST_ENGINE_VERSION + )); + } + Ok(ParsedRemoteManifest { manifest, version }) +} + +fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> { + if manifest.rules.is_empty() { + return Err("manifest must contain at least one rule".to_string()); + } + if manifest.rules.len() > MAX_RULES_PER_MANIFEST { + return Err(format!( + "manifest contains {} rules, max is {MAX_RULES_PER_MANIFEST}", + manifest.rules.len() + )); + } + + let mut complexity = ManifestComplexity::default(); + for rule in &manifest.rules { + if rule.id.trim().is_empty() { + return Err("manifest rule id must not be empty".to_string()); + } + if rule.skip_state_update { + if rule.state != Some(ManifestState::Unknown) { + return Err(format!( + "rule {} uses skip_state_update without state = \"unknown\"", + rule.id + )); + } + if rule.visible_idle || rule.visible_blocker || rule.visible_working { + return Err(format!( + "rule {} uses skip_state_update with visible state evidence", + rule.id + )); + } + } + validate_region_name(&rule.region) + .map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?; + validate_rule_gate(rule, &mut complexity) + .map_err(|err| format!("rule {} has invalid matcher gates: {err}", rule.id))?; + } + + Ok(()) +} + +#[derive(Default)] +struct ManifestComplexity { + total_gates: usize, + total_matchers: usize, +} + +fn validate_rule_gate( + rule: &ManifestRule, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + validate_gate(&manifest_gate_from_rule(rule), "rule", 0, complexity) +} + +fn validate_gate( + gate: &ManifestGate, + context: &str, + depth: usize, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + if depth > MAX_GATE_DEPTH { + return Err(format!("{context} exceeds max gate depth {MAX_GATE_DEPTH}")); + } + complexity.total_gates += 1; + if complexity.total_gates > MAX_TOTAL_GATES { + return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}")); + } + validate_matcher_limits(gate, context, complexity)?; + if !gate_has_positive_matcher(gate) { + return Err(format!("{context} must contain a positive matcher")); + } + validate_regex_patterns(&gate.regex, context, "regex")?; + validate_regex_patterns(&gate.line_regex, context, "line_regex")?; + for nested in &gate.all { + validate_gate(nested, "all gate", depth + 1, complexity)?; + } + for nested in &gate.any { + validate_gate(nested, "any gate", depth + 1, complexity)?; + } + for nested in &gate.not_gate { + if !gate_has_any_matcher(nested) { + return Err(format!("{context} contains an empty not gate")); + } + validate_not_gate(nested, depth + 1, complexity)?; + } + Ok(()) +} + +fn validate_not_gate( + gate: &ManifestGate, + depth: usize, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + if depth > MAX_GATE_DEPTH { + return Err(format!("not gate exceeds max gate depth {MAX_GATE_DEPTH}")); + } + complexity.total_gates += 1; + if complexity.total_gates > MAX_TOTAL_GATES { + return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}")); + } + validate_matcher_limits(gate, "not gate", complexity)?; + if !gate_has_any_matcher(gate) { + return Err("not gate must contain a matcher".to_string()); + } + validate_regex_patterns(&gate.regex, "not gate", "regex")?; + validate_regex_patterns(&gate.line_regex, "not gate", "line_regex")?; + for nested in &gate.all { + validate_gate(nested, "not all gate", depth + 1, complexity)?; + } + for nested in &gate.any { + validate_gate(nested, "not any gate", depth + 1, complexity)?; + } + for nested in &gate.not_gate { + validate_not_gate(nested, depth + 1, complexity)?; + } + Ok(()) +} + +fn validate_matcher_limits( + gate: &ManifestGate, + context: &str, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + let matcher_count = gate.contains.len() + gate.regex.len() + gate.line_regex.len(); + if matcher_count > MAX_MATCHERS_PER_GATE { + return Err(format!( + "{context} has {matcher_count} direct matchers, max is {MAX_MATCHERS_PER_GATE}" + )); + } + complexity.total_matchers += matcher_count; + if complexity.total_matchers > MAX_TOTAL_MATCHERS { + return Err(format!( + "manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}" + )); + } + for value in gate + .contains + .iter() + .chain(gate.regex.iter()) + .chain(gate.line_regex.iter()) + { + if value.chars().count() > MAX_MATCHER_CHARS { + return Err(format!( + "{context} matcher exceeds max length {MAX_MATCHER_CHARS}" + )); + } + } + Ok(()) +} + +fn validate_regex_patterns(patterns: &[String], context: &str, field: &str) -> Result<(), String> { + for pattern in patterns { + Regex::new(pattern).map_err(|err| { + format!("{context} contains invalid {field} pattern {pattern:?}: {err}") + })?; + } + Ok(()) +} + +fn gate_has_positive_matcher(gate: &ManifestGate) -> bool { + !gate.contains.is_empty() + || !gate.regex.is_empty() + || !gate.line_regex.is_empty() + || !gate.all.is_empty() + || !gate.any.is_empty() +} + +fn gate_has_any_matcher(gate: &ManifestGate) -> bool { + gate_has_positive_matcher(gate) || !gate.not_gate.is_empty() +} + +fn validate_region_name(spec: &str) -> Result<(), String> { + let trimmed = spec.trim(); + match trimmed { + "whole_recent" + | "after_last_prompt_marker" + | "before_current_prompt_marker" + | "whole_recent_without_current_prompt_marker" + | "current_prompt_block_marker" + | "after_current_prompt_block_marker" + | "prompt_box_body" + | "above_prompt_box" + | "last_non_empty_above_prompt_box" + | "after_last_horizontal_rule" => Ok(()), + _ if region_count(trimmed, "bottom_lines").is_some() + || region_count(trimmed, "bottom_non_empty_lines").is_some() => + { + Ok(()) + } + _ => Err(trimmed.to_string()), + } +} + +fn override_path(agent: Agent) -> Option<PathBuf> { + Some( + crate::config::config_dir() + .join("agent-detection") + .join(format!("{}.toml", agent_label(agent))), + ) +} + +fn remote_update_status(agent: Agent) -> Option<super::manifest_update::AgentRemoteStatus> { + super::manifest_update::load_status().agent_status(agent) +} + +fn manifest_matches_agent(manifest: &AgentManifest, agent: Agent) -> bool { + let id = agent_label(agent); + manifest.id == id + || manifest.aliases.iter().any(|alias| alias == id) + || parse_agent_label(&manifest.id) == Some(agent) + || manifest + .aliases + .iter() + .any(|alias| parse_agent_label(alias) == Some(agent)) +} + +fn manifest_gate_from_rule(rule: &ManifestRule) -> ManifestGate { + ManifestGate { + all: rule.all.clone(), + any: rule.any.clone(), + not_gate: rule.not_gate.clone(), + contains: rule.contains.clone(), + regex: rule.regex.clone(), + line_regex: rule.line_regex.clone(), + } +} + +fn compile_manifest(manifest: &AgentManifest) -> Result<Vec<CompiledRule>, String> { + manifest + .rules + .iter() + .map(|rule| { + compile_gate(&manifest_gate_from_rule(rule)) + .map(|gate| CompiledRule { gate }) + .map_err(|err| format!("rule {} could not be compiled: {err}", rule.id)) + }) + .collect() +} + +fn compile_gate(gate: &ManifestGate) -> Result<CompiledGate, String> { + Ok(CompiledGate { + all: gate + .all + .iter() + .map(compile_gate) + .collect::<Result<_, _>>()?, + any: gate + .any + .iter() + .map(compile_gate) + .collect::<Result<_, _>>()?, + not_gate: gate + .not_gate + .iter() + .map(compile_gate) + .collect::<Result<_, _>>()?, + contains: gate + .contains + .iter() + .map(|needle| needle.to_lowercase()) + .collect(), + regex: gate + .regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::<Result<_, _>>()?, + line_regex: gate + .line_regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::<Result<_, _>>()?, + }) +} + +fn compiled_rule_matches(rule: &CompiledRule, text: &str) -> bool { + let lower_text = text.to_lowercase(); + compiled_gate_matches(&rule.gate, text, &lower_text) +} + +fn rule_evidence(rule: &ManifestRule, region_text: &str) -> RuleEvidence { + RuleEvidence { + contains: rule.contains.clone(), + regex: rule.regex.clone(), + line_regex: rule.line_regex.clone(), + all_count: rule.all.len(), + any_count: rule.any.len(), + not_count: rule.not_gate.len(), + region_bytes: region_text.len(), + region_preview: bounded_preview(region_text), + } +} + +fn bounded_preview(text: &str) -> String { + const MAX_CHARS: usize = 240; + let mut preview: String = text.chars().take(MAX_CHARS).collect(); + if text.chars().count() > MAX_CHARS { + preview.push_str("..."); + } + preview +} + +fn compiled_gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> bool { + if !gate + .contains + .iter() + .all(|needle| lower_text.contains(needle)) + { + return false; + } + + if !gate.regex.iter().all(|regex| regex.is_match(text)) { + return false; + } + + if !gate + .line_regex + .iter() + .all(|regex| text.lines().any(|line| regex.is_match(line))) + { + return false; + } + + if !gate + .all + .iter() + .all(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + + if !gate.any.is_empty() + && !gate + .any + .iter() + .any(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + + if gate + .not_gate + .iter() + .any(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + + true +} + +fn region<'a>(content: &'a str, spec: &str) -> &'a str { + let trimmed = spec.trim(); + match trimmed { + "whole_recent" => content, + "after_last_prompt_marker" => after_last_prompt_marker(content), + "before_current_prompt_marker" => before_current_prompt_marker(content), + "whole_recent_without_current_prompt_marker" => { + whole_recent_without_current_prompt_marker(content) + } + "current_prompt_block_marker" => current_prompt_block_marker(content).unwrap_or(""), + "after_current_prompt_block_marker" => { + after_current_prompt_block_marker(content).unwrap_or("") + } + "prompt_box_body" => prompt_box_body(content).unwrap_or(""), + "above_prompt_box" => above_prompt_box(content), + "last_non_empty_above_prompt_box" => last_non_empty_line(above_prompt_box(content)), + "after_last_horizontal_rule" => after_last_horizontal_rule(content), + _ => { + if let Some(count) = region_count(trimmed, "bottom_lines") { + return bottom_lines(content, count); + } + if let Some(count) = region_count(trimmed, "bottom_non_empty_lines") { + return bottom_non_empty_lines(content, count); + } + "" + } + } +} + +fn region_count(spec: &str, name: &str) -> Option<usize> { + spec.strip_prefix(name) + .and_then(|rest| rest.strip_prefix('(')) + .and_then(|rest| rest.strip_suffix(')')) + .and_then(|count| count.parse::<usize>().ok()) +} + +fn bottom_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(count); + slice_from_line_index(content, &lines, start) +} + +fn bottom_non_empty_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(start_index) = lines + .iter() + .enumerate() + .rev() + .filter(|(_, line)| !line.trim().is_empty()) + .take(count) + .last() + .map(|(index, _)| index) + else { + return ""; + }; + slice_from_line_index(content, &lines, start_index) +} + +fn after_last_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(index) = lines.iter().rposition(|line| codex_prompt_line(line)) else { + return content; + }; + slice_from_line_index(content, &lines, index + 1) +} + +fn before_current_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(index) = current_codex_prompt_index(&lines) else { + return content; + }; + let byte_offset = lines[..index] + .iter() + .map(|line| line.len() + 1) + .sum::<usize>(); + &content[..byte_offset.min(content.len())] +} + +fn whole_recent_without_current_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + if current_codex_prompt_index(&lines).is_some() { + "" + } else { + content + } +} + +fn current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + lines[..prompt_index] + .iter() + .rev() + .find(|line| codex_block_marker_line(line)) + .copied() +} + +fn after_current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + let block_index = lines[..prompt_index] + .iter() + .rposition(|line| codex_block_marker_line(line))?; + Some(slice_from_line_index(content, &lines, block_index)) +} + +fn current_codex_prompt_index(lines: &[&str]) -> Option<usize> { + let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?; + if lines[prompt_index + 1..] + .iter() + .any(|line| codex_block_marker_line(line)) + { + return None; + } + Some(prompt_index) +} + +fn codex_prompt_line(line: &str) -> bool { + line == "›" || line.starts_with("› ") +} + +fn codex_block_marker_line(line: &str) -> bool { + line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓') +} + +fn prompt_box_body(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let top = prompt_box_top_border_index(&lines)?; + let start = line_start_offset(content, &lines, top + 1); + let end_index = lines[top + 1..] + .iter() + .position(|line| is_horizontal_rule(line)) + .map(|relative| top + 1 + relative) + .unwrap_or(lines.len()); + let end = line_start_offset(content, &lines, end_index); + Some(&content[start.min(content.len())..end.min(content.len())]) +} + +fn above_prompt_box(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(top) = prompt_box_top_border_index(&lines) else { + return content; + }; + let end = line_start_offset(content, &lines, top); + &content[..end.min(content.len())] +} + +fn after_last_horizontal_rule(content: &str) -> &str { + let mut last_rule_end = 0usize; + let mut offset = 0usize; + for line in content.lines() { + let next_offset = offset + line.len() + 1; + if is_horizontal_rule(line) { + last_rule_end = next_offset.min(content.len()); + } + offset = next_offset; + } + &content[last_rule_end..] +} + +fn last_non_empty_line(content: &str) -> &str { + content + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("") +} + +fn prompt_box_top_border_index(lines: &[&str]) -> Option<usize> { + let mut border_count = 0; + for index in (0..lines.len()).rev() { + if is_horizontal_rule(lines[index]) { + border_count += 1; + if border_count == 2 { + return Some(index); + } + } + } + None +} + +fn is_horizontal_rule(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + + let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count(); + if rule_chars == 0 { + return false; + } + + let rule_bytes = trimmed + .char_indices() + .nth(rule_chars) + .map(|(index, _)| index) + .unwrap_or(trimmed.len()); + let suffix = trimmed[rule_bytes..].trim_start(); + + suffix.is_empty() || rule_chars >= 3 +} + +fn slice_from_line_index<'a>(content: &'a str, lines: &[&str], index: usize) -> &'a str { + let byte_offset = line_start_offset(content, lines, index); + &content[byte_offset.min(content.len())..] +} + +fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize { + lines[..index.min(lines.len())] + .iter() + .map(|line| line.len() + 1) + .sum::<usize>() + .min(content.len()) +} + +#[cfg(test)] +mod tests; diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs new file mode 100644 index 00000000..e5b03c0b --- /dev/null +++ b/src/detect/manifest/tests.rs @@ -0,0 +1,498 @@ +use super::*; + +fn remote_manifest(version: &str, state: &str, contains: &str) -> String { + format!( + r#" +id = "codex" +version = "{version}" +min_engine_version = 1 +updated_at = "2026-06-10T12:00:00Z" + +[[rules]] +id = "test" +state = "{state}" +contains = ["{contains}"] +"# + ) +} + +fn local_manifest(state: &str, contains: &str) -> String { + format!( + r#" +id = "codex" + +[[rules]] +id = "test" +state = "{state}" +contains = ["{contains}"] +"# + ) +} + +fn rules_manifest(rules: &str) -> String { + format!( + r#" +id = "codex" + +{rules} +"# + ) +} + +fn with_manifest_dirs<T>(name: &str, f: impl FnOnce() -> T) -> T { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let old_config = std::env::var_os("XDG_CONFIG_HOME"); + let old_state = std::env::var_os("XDG_STATE_HOME"); + let base = std::env::temp_dir().join(format!( + "herdr-manifest-loader-{name}-{}", + std::process::id() + )); + let config_dir = base.join("config"); + let state_dir = base.join("state"); + let _ = std::fs::remove_dir_all(&base); + std::env::set_var("XDG_CONFIG_HOME", &config_dir); + std::env::set_var("XDG_STATE_HOME", &state_dir); + reload_manifests(); + let result = f(); + match old_config { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match old_state { + Some(value) => std::env::set_var("XDG_STATE_HOME", value), + None => std::env::remove_var("XDG_STATE_HOME"), + } + reload_manifests(); + let _ = std::fs::remove_dir_all(&base); + result +} + +fn write_remote_codex(content: &str) { + let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + reload_manifests(); +} + +fn write_remote_codex_without_reload(content: &str) { + let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); +} + +fn write_local_codex(content: &str) { + let path = override_path(Agent::Codex).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + reload_manifests(); +} + +#[test] +fn known_agent_no_match_defaults_to_idle_fallback() { + let explain = explain(Agent::Codex, "ordinary prompt text"); + + assert_eq!(explain.state, AgentState::Idle); + assert!(!explain.visible_idle); + assert_eq!( + explain.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); +} + +#[test] +fn rule_semantics_apply_gates_priority_and_line_regex() { + with_manifest_dirs("rule-semantics", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "low_contains" +state = "idle" +priority = 1 +contains = ["match"] + +[[rules]] +id = "high_nested_gates" +state = "working" +priority = 10 +contains = ["match"] +all = [ + { any = [{ regex = ["w[io]n"] }, { contains = ["fallback"] }] }, +] +not = [ + { contains = ["blocked"] }, +] + +[[rules]] +id = "line_regex" +state = "blocked" +priority = 20 +line_regex = ["^exact line$"] +"#, + )); + + let high = explain(Agent::Codex, "match win"); + assert_eq!(high.state, AgentState::Working); + assert_eq!( + high.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("high_nested_gates") + ); + + let not_gate = explain(Agent::Codex, "match win blocked"); + assert_eq!(not_gate.state, AgentState::Idle); + assert_eq!( + not_gate.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("low_contains") + ); + + let line = explain(Agent::Codex, "before\nexact line\nafter"); + assert_eq!(line.state, AgentState::Blocked); + assert_eq!( + line.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("line_regex") + ); + }); +} + +#[test] +fn remote_manifest_loads_between_local_override_and_bundled() { + with_manifest_dirs("remote-source", || { + write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready")); + + let explain = explain(Agent::Codex, "remote-ready"); + + assert_eq!(explain.state, AgentState::Blocked); + assert!(matches!( + explain.source, + Some(ManifestSource::Remote { .. }) + )); + assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1")); + assert_eq!( + explain.cached_remote_version.as_deref(), + Some("2026.06.10.1") + ); + }); +} + +#[test] +fn fallback_explain_preserves_active_manifest_version() { + with_manifest_dirs("fallback-version", || { + write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready")); + + let explain = explain(Agent::Codex, "ordinary prompt text"); + + assert_eq!(explain.state, AgentState::Idle); + assert_eq!( + explain.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); + assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1")); + assert!(matches!( + explain.source, + Some(ManifestSource::Remote { .. }) + )); + }); +} + +#[test] +fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() { + with_manifest_dirs("older-remote-bundled-fallback", || { + write_remote_codex(&remote_manifest("2026.06.10.0", "blocked", "remote-ready")); + + let explain = explain(Agent::Codex, "remote-ready"); + + assert_eq!(explain.state, AgentState::Idle); + assert!(matches!(explain.source, Some(ManifestSource::Bundled))); + assert_eq!( + explain.cached_remote_version.as_deref(), + Some("2026.06.10.0") + ); + assert!(explain + .warning + .as_deref() + .is_some_and(|warning| warning.contains("older than bundled"))); + }); +} + +#[test] +fn local_override_shadows_cached_remote_manifest() { + with_manifest_dirs("local-shadows-remote", || { + write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready")); + write_local_codex(&local_manifest("idle", "local-ready")); + + let explain = explain(Agent::Codex, "local-ready"); + + assert_eq!(explain.state, AgentState::Idle); + assert!(matches!(explain.source, Some(ManifestSource::Override(_)))); + assert!(explain.local_override_shadowing_remote); + assert_eq!( + explain.cached_remote_version.as_deref(), + Some("2026.06.10.1") + ); + }); +} + +#[test] +fn invalid_local_override_falls_back_to_cached_remote_manifest() { + with_manifest_dirs("invalid-local-remote-fallback", || { + write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready")); + write_local_codex("id = "); + + let explain = explain(Agent::Codex, "remote-ready"); + + assert_eq!(explain.state, AgentState::Blocked); + assert!(matches!( + explain.source, + Some(ManifestSource::Remote { .. }) + )); + assert!(explain.warning.is_some()); + }); +} + +#[test] +fn detection_uses_cached_manifest_until_explicit_reload() { + with_manifest_dirs("cache-boundary", || { + write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "cached-ready")); + + let cached = explain(Agent::Codex, "cached-ready"); + assert_eq!(cached.state, AgentState::Blocked); + assert!(matches!(cached.source, Some(ManifestSource::Remote { .. }))); + assert_eq!( + cached.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("test") + ); + + write_remote_codex_without_reload(&remote_manifest("2026.06.10.2", "working", "new-ready")); + + let unchanged = explain(Agent::Codex, "new-ready"); + assert_eq!(unchanged.state, AgentState::Idle); + assert_eq!( + unchanged.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); + assert_eq!( + unchanged.cached_remote_version.as_deref(), + Some("2026.06.10.1") + ); + + reload_manifests(); + + let reloaded = explain(Agent::Codex, "new-ready"); + assert_eq!(reloaded.state, AgentState::Working); + assert_eq!( + reloaded.cached_remote_version.as_deref(), + Some("2026.06.10.2") + ); + assert_eq!( + reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("test") + ); + }); +} + +#[test] +fn all_bundled_manifests_parse_and_validate() { + let agents = [ + Agent::Pi, + Agent::Claude, + Agent::Codex, + Agent::Gemini, + Agent::Cursor, + Agent::Antigravity, + Agent::Cline, + Agent::OpenCode, + Agent::GithubCopilot, + Agent::Kimi, + Agent::Kiro, + Agent::Droid, + Agent::Amp, + Agent::Grok, + Agent::Hermes, + Agent::Kilo, + Agent::Qodercli, + ]; + + for agent in agents { + assert!( + bundled_manifest(agent).is_some(), + "missing bundled manifest for {}", + agent_label(agent) + ); + } +} + +#[test] +fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() { + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "typo" +state = "working" +contain = ["Working"] +"# + ) + .is_err()); + + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "empty" +state = "working" +"# + ) + .is_err()); + + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_region" +state = "working" +region = "after_last_promt_marker" +contains = ["Working"] +"# + ) + .is_err()); + + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_regex" +state = "working" +regex = ["["] +"# + ) + .is_err()); + + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_nested_regex" +state = "working" +any = [{ line_regex = ["["] }] +"# + ) + .is_err()); +} + +#[test] +fn manifest_validation_keeps_skip_rules_neutral() { + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_skip_state" +state = "idle" +skip_state_update = true +contains = ["menu"] +"# + ) + .is_err()); + + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_skip_visible" +state = "unknown" +skip_state_update = true +visible_blocker = true +contains = ["menu"] +"# + ) + .is_err()); +} + +#[test] +fn manifest_validation_rejects_excessive_rule_count() { + let mut manifest = String::from( + r#" +id = "codex" +"#, + ); + for index in 0..129 { + manifest.push_str(&format!( + r#" +[[rules]] +id = "rule_{index}" +state = "idle" +contains = ["ready"] +"# + )); + } + + assert!(parse_manifest(&manifest).is_err()); +} + +#[test] +fn manifest_validation_rejects_excessive_gate_depth() { + let manifest = r#" +id = "codex" + +[[rules]] +id = "deep" +state = "idle" +contains = ["ready"] +all = [ + { contains = ["1"], all = [ + { contains = ["2"], all = [ + { contains = ["3"], all = [ + { contains = ["4"], all = [ + { contains = ["5"], all = [ + { contains = ["6"], all = [ + { contains = ["7"], all = [ + { contains = ["8"], all = [ + { contains = ["9"] }, + ] }, + ] }, + ] }, + ] }, + ] }, + ] }, + ] }, + ] }, +] +"#; + + assert!(parse_manifest(manifest).is_err()); +} + +#[test] +fn manifest_validation_rejects_excessive_matchers() { + let matchers = (0..33) + .map(|index| format!(r#""m{index}""#)) + .collect::<Vec<_>>() + .join(", "); + let manifest = format!( + r#" +id = "codex" + +[[rules]] +id = "many" +state = "idle" +contains = [{matchers}] +"# + ); + + assert!(parse_manifest(&manifest).is_err()); +} + +#[test] +fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() { + let content = "marker\nold\n\nmiddle\nmarker\nnew\n"; + + assert_eq!( + region(content, "bottom_non_empty_lines(2)"), + "marker\nnew\n" + ); +} diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs new file mode 100644 index 00000000..6534b885 --- /dev/null +++ b/src/detect/manifest_update.rs @@ -0,0 +1,774 @@ +use std::{ + cmp::Ordering, + collections::{BTreeMap, BTreeSet}, + fmt, fs, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; + +use super::{agent_label, parse_agent_label, Agent}; + +pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 1; +const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml"; +const CATALOG_URL_ENV: &str = "HERDR_AGENT_DETECTION_MANIFEST_CATALOG_URL"; +const MAX_FETCH_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone)] +pub(crate) struct ManifestVersion(String); + +impl ManifestVersion { + pub(crate) fn parse(value: &str) -> Result<Self, String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("version must not be empty".to_string()); + } + for segment in trimmed.split('.') { + if segment.is_empty() { + return Err(format!("version {trimmed:?} contains an empty segment")); + } + if !segment.chars().all(|ch| ch.is_ascii_digit()) { + return Err(format!("version {trimmed:?} must be dotted numeric")); + } + segment + .parse::<u64>() + .map_err(|_| format!("version {trimmed:?} contains an oversized segment"))?; + } + Ok(Self(trimmed.to_string())) + } +} + +impl fmt::Display for ManifestVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ManifestVersion { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +impl Serialize for ManifestVersion { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl Ord for ManifestVersion { + fn cmp(&self, other: &Self) -> Ordering { + let mut left = self.0.split('.'); + let mut right = other.0.split('.'); + + loop { + match (left.next(), right.next()) { + (Some(left), Some(right)) => { + let left = left.parse::<u64>().unwrap_or(0); + let right = right.parse::<u64>().unwrap_or(0); + match left.cmp(&right) { + Ordering::Equal => {} + ordering => return ordering, + } + } + (Some(left), None) => { + let left = left.parse::<u64>().unwrap_or(0); + if left == 0 { + continue; + } + return Ordering::Greater; + } + (None, Some(right)) => { + let right = right.parse::<u64>().unwrap_or(0); + if right == 0 { + continue; + } + return Ordering::Less; + } + (None, None) => return Ordering::Equal, + } + } + } +} + +impl PartialOrd for ManifestVersion { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +impl PartialEq for ManifestVersion { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for ManifestVersion {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ManifestUpdateCommit { + pub(crate) agent: Agent, + pub(crate) version: ManifestVersion, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub(crate) struct ManifestUpdateStatus { + pub(crate) last_check_unix: Option<u64>, + pub(crate) last_result: Option<String>, + #[serde(default)] + pub(crate) agents: BTreeMap<String, AgentRemoteStatus>, +} + +impl ManifestUpdateStatus { + pub(crate) fn agent_status(&self, agent: Agent) -> Option<AgentRemoteStatus> { + self.agents.get(agent_label(agent)).cloned() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AgentRemoteStatus { + pub(crate) cached_version: Option<String>, + pub(crate) attempted_version: Option<String>, + pub(crate) last_checked_unix: Option<u64>, + pub(crate) last_result: String, + pub(crate) last_error: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestCatalog { + schema_version: u32, + #[serde(default)] + agents: Vec<ManifestCatalogAgent>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestCatalogAgent { + id: String, + path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CatalogAgent { + agent: Agent, + path: String, +} + +pub(crate) fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) { + let result = check_and_update(); + let status = match result { + Ok(output) => { + if !output.updated.is_empty() { + super::manifest::reload_manifests(); + } + let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated { + updated: output.updated, + status: output.status, + }); + return; + } + Err(err) => { + tracing::warn!("agent detection manifest update failed: {err}"); + let mut status = load_status(); + status.last_check_unix = Some(now_unix()); + status.last_result = Some(format!("failed: {err}")); + let _ = save_status(&status); + status + } + }; + let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated { + updated: Vec::new(), + status, + }); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ManifestUpdateOutput { + pub(crate) updated: Vec<ManifestUpdateCommit>, + pub(crate) status: ManifestUpdateStatus, +} + +pub(crate) fn check_and_update() -> Result<ManifestUpdateOutput, String> { + check_and_update_from_url(&catalog_url()) +} + +fn check_and_update_from_url(url: &str) -> Result<ManifestUpdateOutput, String> { + let catalog = parse_catalog(&fetch_text(url)?)?; + let base_url = base_url(url)?; + let mut status = load_status(); + let check_time = now_unix(); + status.last_check_unix = Some(check_time); + status.last_result = Some("checked".to_string()); + + let mut updated = Vec::new(); + for entry in catalog { + let agent_id = agent_label(entry.agent).to_string(); + let manifest_url = join_url(&base_url, &entry.path)?; + match fetch_text(&manifest_url) + .map_err(|err| format!("fetch failed: {err}")) + .and_then(|content| process_agent_manifest(entry.agent, &content, check_time)) + { + Ok(Some(commit)) => { + status.agents.insert( + agent_id, + AgentRemoteStatus { + cached_version: Some(commit.version.to_string()), + attempted_version: Some(commit.version.to_string()), + last_checked_unix: Some(check_time), + last_result: "updated".to_string(), + last_error: None, + }, + ); + updated.push(commit); + } + Ok(None) => { + let cached_version = cached_remote_version(entry.agent); + status.agents.insert( + agent_id, + AgentRemoteStatus { + cached_version: cached_version.map(|version| version.to_string()), + attempted_version: None, + last_checked_unix: Some(check_time), + last_result: "current".to_string(), + last_error: None, + }, + ); + } + Err(err) => { + tracing::warn!( + agent = agent_label(entry.agent), + error = %err, + "agent detection manifest update failed for agent" + ); + let cached_version = cached_remote_version(entry.agent); + status.agents.insert( + agent_id, + AgentRemoteStatus { + cached_version: cached_version.map(|version| version.to_string()), + attempted_version: None, + last_checked_unix: Some(check_time), + last_result: "failed".to_string(), + last_error: Some(err), + }, + ); + } + } + } + + if let Err(err) = save_status(&status) { + tracing::warn!("failed to save agent detection manifest update status: {err}"); + status.last_result = Some(format!("failed_to_save_status: {err}")); + } + Ok(ManifestUpdateOutput { updated, status }) +} + +fn process_agent_manifest( + agent: Agent, + content: &str, + _check_time: u64, +) -> Result<Option<ManifestUpdateCommit>, String> { + let parsed = super::manifest::parse_remote_manifest_for_agent(agent, content)?; + if let Some(current) = cached_remote_version(agent) { + match parsed.version.cmp(¤t) { + Ordering::Less => { + return Err(format!( + "remote version {} is older than cached {current}", + parsed.version + )) + } + Ordering::Equal => { + let committed = fs::read_to_string(remote_manifest_path(agent)).unwrap_or_default(); + if committed != content { + return Err(format!( + "remote version {} changed content without a version bump", + parsed.version + )); + } + return Ok(None); + } + Ordering::Greater => {} + } + } + + commit_remote_manifest(agent, content)?; + Ok(Some(ManifestUpdateCommit { + agent, + version: parsed.version, + })) +} + +fn parse_catalog(content: &str) -> Result<Vec<CatalogAgent>, String> { + let catalog: ManifestCatalog = + toml::from_str(content).map_err(|err| format!("failed to parse catalog TOML: {err}"))?; + if catalog.schema_version != 1 { + return Err(format!( + "unsupported catalog schema_version {}", + catalog.schema_version + )); + } + + let mut seen = BTreeSet::new(); + let mut agents = Vec::new(); + for entry in catalog.agents { + let Some(agent) = parse_agent_label(&entry.id) else { + tracing::warn!(agent = entry.id, "skipping unknown remote manifest agent"); + continue; + }; + if entry.path.trim().is_empty() { + return Err(format!("catalog entry {} has an empty path", entry.id)); + } + if entry.path.contains("://") + || entry.path.starts_with('/') + || entry.path.split('/').any(|part| part == "..") + { + return Err(format!( + "catalog entry {} has an unsafe path {}", + entry.id, entry.path + )); + } + if !seen.insert(agent_label(agent).to_string()) { + return Err(format!("catalog contains duplicate agent {}", entry.id)); + } + agents.push(CatalogAgent { + agent, + path: entry.path, + }); + } + Ok(agents) +} + +pub(crate) fn load_status() -> ManifestUpdateStatus { + let path = status_path(); + let Ok(content) = fs::read_to_string(&path) else { + return ManifestUpdateStatus::default(); + }; + toml::from_str(&content).unwrap_or_else(|err| { + tracing::warn!( + path = %path.display(), + "failed to parse agent detection manifest status: {err}" + ); + ManifestUpdateStatus::default() + }) +} + +fn save_status(status: &ManifestUpdateStatus) -> Result<(), String> { + let path = status_path(); + let parent = path + .parent() + .ok_or_else(|| format!("status path {} has no parent", path.display()))?; + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + let content = toml::to_string_pretty(status).map_err(|err| err.to_string())?; + atomic_write(&path, content.as_bytes()) +} + +pub(crate) fn status_path() -> PathBuf { + state_root().join("status.toml") +} + +pub(crate) fn remote_manifest_path(agent: Agent) -> PathBuf { + state_root() + .join("remote") + .join(format!("{}.toml", agent_label(agent))) +} + +pub(crate) fn cached_remote_version(agent: Agent) -> Option<ManifestVersion> { + let content = fs::read_to_string(remote_manifest_path(agent)).ok()?; + super::manifest::parse_remote_manifest_for_agent(agent, &content) + .ok() + .map(|parsed| parsed.version) +} + +fn commit_remote_manifest(agent: Agent, content: &str) -> Result<(), String> { + let path = remote_manifest_path(agent); + let parent = path + .parent() + .ok_or_else(|| format!("remote manifest path {} has no parent", path.display()))?; + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + atomic_write(&path, content.as_bytes()) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("path {} has no parent", path.display()))?; + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + let tmp_path = parent.join(format!( + ".{}.{}.{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("manifest"), + std::process::id(), + now_nanos() + )); + { + let mut file = fs::File::create(&tmp_path).map_err(|err| err.to_string())?; + if let Err(err) = file.write_all(bytes).and_then(|_| file.sync_all()) { + let _ = fs::remove_file(&tmp_path); + return Err(err.to_string()); + } + } + fs::rename(&tmp_path, path).map_err(|err| { + let _ = fs::remove_file(&tmp_path); + err.to_string() + })?; + if let Err(err) = sync_parent_dir(parent) { + tracing::warn!( + path = %path.display(), + error = %err, + "agent detection manifest committed but parent directory sync failed" + ); + } + Ok(()) +} + +fn sync_parent_dir(parent: &Path) -> Result<(), String> { + let dir = match fs::File::open(parent) { + Ok(dir) => dir, + Err(err) if directory_sync_unsupported(&err) => return Ok(()), + Err(err) => return Err(format!("failed to open parent directory for sync: {err}")), + }; + match dir.sync_all() { + Ok(()) => Ok(()), + Err(err) if directory_sync_unsupported(&err) => Ok(()), + Err(err) => Err(format!("failed to sync parent directory: {err}")), + } +} + +fn directory_sync_unsupported(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::Unsupported | std::io::ErrorKind::InvalidInput + ) +} + +fn state_root() -> PathBuf { + crate::config::state_dir().join("agent-detection") +} + +fn catalog_url() -> String { + std::env::var(CATALOG_URL_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_CATALOG_URL.to_string()) +} + +fn fetch_text(url: &str) -> Result<String, String> { + let max_fetch_bytes = MAX_FETCH_BYTES.to_string(); + let mut child = Command::new("curl") + .args([ + "-sfL", + "--retry", + "2", + "--connect-timeout", + "5", + "--max-time", + "15", + "--max-filesize", + &max_fetch_bytes, + url, + ]) + .stdout(Stdio::piped()) + .spawn() + .map_err(|err| format!("curl failed: {err}"))?; + + let mut bytes = Vec::new(); + let Some(stdout) = child.stdout.as_mut() else { + let _ = child.kill(); + let _ = child.wait(); + return Err("curl stdout was not captured".to_string()); + }; + stdout + .take((MAX_FETCH_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|err| { + let _ = child.kill(); + let _ = child.wait(); + format!("failed to read curl response: {err}") + })?; + if bytes.len() > MAX_FETCH_BYTES { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "response from {url} exceeded {MAX_FETCH_BYTES} bytes" + )); + } + + let status = child + .wait() + .map_err(|err| format!("curl wait failed: {err}"))?; + if !status.success() { + return Err(format!("failed to fetch {url}")); + } + String::from_utf8(bytes).map_err(|err| format!("response was not UTF-8: {err}")) +} + +fn base_url(url: &str) -> Result<String, String> { + let Some((base, _)) = url.rsplit_once('/') else { + return Err(format!("catalog URL {url} has no base path")); + }; + Ok(base.to_string()) +} + +fn join_url(base: &str, path: &str) -> Result<String, String> { + if path.contains("://") || path.starts_with('/') || path.split('/').any(|part| part == "..") { + return Err(format!("unsafe manifest path {path}")); + } + Ok(format!("{}/{}", base.trim_end_matches('/'), path)) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + fn remote_manifest(version: &str, contains: &str) -> String { + format!( + r#" +id = "codex" +version = "{version}" +min_engine_version = 1 +updated_at = "2026-06-10T12:00:00Z" + +[[rules]] +id = "idle" +state = "idle" +contains = ["{contains}"] +"# + ) + } + + fn with_state_dir<T>(name: &str, f: impl FnOnce() -> T) -> T { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let old_config = std::env::var_os("XDG_CONFIG_HOME"); + let old_state = std::env::var_os("XDG_STATE_HOME"); + let dir = std::env::temp_dir().join(format!( + "herdr-manifest-update-{name}-{}", + std::process::id() + )); + let config_dir = dir.join("config"); + let state_dir = dir.join("state"); + let _ = fs::remove_dir_all(&dir); + std::env::set_var("XDG_CONFIG_HOME", &config_dir); + std::env::set_var("XDG_STATE_HOME", &state_dir); + crate::detect::manifest::reload_manifests(); + let result = f(); + match old_config { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match old_state { + Some(value) => std::env::set_var("XDG_STATE_HOME", value), + None => std::env::remove_var("XDG_STATE_HOME"), + } + crate::detect::manifest::reload_manifests(); + let _ = fs::remove_dir_all(&dir); + result + } + + #[test] + fn manifest_version_compares_dotted_numeric_segments() { + assert!( + ManifestVersion::parse("2026.6.10.1").unwrap() + > ManifestVersion::parse("2026.6.9.9").unwrap() + ); + assert!(ManifestVersion::parse("1.2.0").unwrap() == ManifestVersion::parse("1.2").unwrap()); + assert!(ManifestVersion::parse("1.2.1").unwrap() > ManifestVersion::parse("1.2").unwrap()); + } + + #[test] + fn manifest_version_rejects_non_numeric_segments() { + assert!(ManifestVersion::parse("").is_err()); + assert!(ManifestVersion::parse("2026.06.alpha").is_err()); + assert!(ManifestVersion::parse("2026..06").is_err()); + assert!(ManifestVersion::parse("2026.999999999999999999999999999999").is_err()); + } + + #[test] + fn process_agent_manifest_commits_newer_manifest_atomically() { + with_state_dir("commit-newer", || { + let content = remote_manifest("2026.06.10.1", "ready"); + let commit = process_agent_manifest(Agent::Codex, &content, 1) + .unwrap() + .unwrap(); + + assert_eq!(commit.agent, Agent::Codex); + assert_eq!( + commit.version, + ManifestVersion::parse("2026.06.10.1").unwrap() + ); + assert_eq!( + fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), + content + ); + }); + } + + #[test] + fn auto_update_reloads_manifest_cache_after_remote_commit() { + with_state_dir("auto-update-reloads-cache", || { + let old_catalog_url = std::env::var_os(CATALOG_URL_ENV); + let web_dir = std::env::temp_dir() + .join(format!("herdr-manifest-update-web-{}", std::process::id())); + let _ = fs::remove_dir_all(&web_dir); + fs::create_dir_all(&web_dir).unwrap(); + fs::write( + web_dir.join("index.toml"), + r#" +schema_version = 1 + +[[agents]] +id = "codex" +path = "codex.toml" +"#, + ) + .unwrap(); + fs::write( + web_dir.join("codex.toml"), + remote_manifest("2026.06.10.1", "auto-update-ready"), + ) + .unwrap(); + std::env::set_var( + CATALOG_URL_ENV, + format!("file://{}", web_dir.join("index.toml").display()), + ); + + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + auto_update(tx); + + let event = rx.try_recv().expect("manifest update event"); + let crate::events::AppEvent::AgentDetectionManifestsUpdated { updated, .. } = event + else { + panic!("unexpected event"); + }; + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].agent, Agent::Codex); + + let explain = crate::detect::manifest::explain(Agent::Codex, "auto-update-ready"); + assert_eq!(explain.state, crate::detect::AgentState::Idle); + assert!(matches!( + explain.source, + Some(crate::detect::manifest::ManifestSource::Remote { .. }) + )); + assert_eq!( + explain.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("idle") + ); + + match old_catalog_url { + Some(value) => std::env::set_var(CATALOG_URL_ENV, value), + None => std::env::remove_var(CATALOG_URL_ENV), + } + let _ = fs::remove_dir_all(&web_dir); + }); + } + + #[test] + fn process_agent_manifest_rejects_downgrade_and_keeps_cached_manifest() { + with_state_dir("reject-downgrade", || { + let current = remote_manifest("2026.06.10.2", "current"); + process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); + + let older = remote_manifest("2026.06.10.1", "older"); + assert!(process_agent_manifest(Agent::Codex, &older, 2).is_err()); + assert_eq!( + fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), + current + ); + }); + } + + #[test] + fn process_agent_manifest_rejects_equal_version_content_change() { + with_state_dir("reject-equal-change", || { + let current = remote_manifest("2026.06.10.1", "current"); + process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); + + let changed = remote_manifest("2026.06.10.1", "changed"); + assert!(process_agent_manifest(Agent::Codex, &changed, 2).is_err()); + assert_eq!( + fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), + current + ); + }); + } + + #[test] + fn process_agent_manifest_skips_same_version_same_content() { + with_state_dir("skip-same", || { + let current = remote_manifest("2026.06.10.1", "current"); + process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); + + let result = process_agent_manifest(Agent::Codex, ¤t, 2).unwrap(); + assert!(result.is_none()); + }); + } + + #[test] + fn catalog_parses_known_agents_and_rejects_duplicates() { + let catalog = parse_catalog( + r#" +schema_version = 1 + +[[agents]] +id = "codex" +path = "codex.toml" +"#, + ) + .unwrap(); + assert_eq!(catalog[0].agent, Agent::Codex); + assert_eq!(catalog[0].path, "codex.toml"); + + assert!(parse_catalog( + r#" +schema_version = 1 + +[[agents]] +id = "codex" +path = "codex.toml" + +[[agents]] +id = "codex" +path = "codex-2.toml" +"# + ) + .is_err()); + } + + #[test] + fn catalog_rejects_unsafe_paths() { + assert!(parse_catalog( + r#" +schema_version = 1 + +[[agents]] +id = "codex" +path = "../codex.toml" +"# + ) + .is_err()); + } +} diff --git a/src/detect/manifests/amp.toml b/src/detect/manifests/amp.toml new file mode 100644 index 00000000..97ac99e0 --- /dev/null +++ b/src/detect/manifests/amp.toml @@ -0,0 +1,29 @@ +id = "amp" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["amp-local"] + +[[rules]] +id = "approval_footer" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for approval"] }, + { contains = ["invoke tool"] }, + { contains = ["run this command?"] }, + { contains = ["allow editing file:"] }, + { contains = ["allow creating file:"] }, + { contains = ["confirm tool call"] }, + { contains = ["approve"], any = [{ contains = ["allow all for this session"] }, { contains = ["allow all for every session"] }, { contains = ["allow file for every session"] }, { contains = ["deny with feedback"] }] }, +] + +[[rules]] +id = "esc_cancel_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] diff --git a/src/detect/manifests/antigravity.toml b/src/detect/manifests/antigravity.toml new file mode 100644 index 00000000..4e6e70b5 --- /dev/null +++ b/src/detect/manifests/antigravity.toml @@ -0,0 +1,33 @@ +id = "agy" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["antigravity", "antigravity-cli"] + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["requesting permission for:"] +any = [ + { contains = ["do you want to proceed?"] }, + { contains = ["tab amend", "edit command"] }, +] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b'] + +[[rules]] +id = "background_tasks_working" +state = "working" +priority = 90 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i).*[1-9][0-9]*\s*·?\s+tasks?.*/tasks'] diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml new file mode 100644 index 00000000..84177af6 --- /dev/null +++ b/src/detect/manifests/claude.toml @@ -0,0 +1,179 @@ +id = "claude" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["claude-code"] + +[[rules]] +id = "transcript_viewer" +state = "unknown" +priority = 1000 +region = "bottom_non_empty_lines(3)" +skip_state_update = true +contains = ["showing detailed transcript"] +any = [ + { contains = ["ctrl+o", "to toggle"] }, + { contains = ["ctrl+e", "show all"] }, + { contains = ["ctrl+e", "collapse"] }, + { contains = ["↑↓ scroll"] }, + { contains = ["? for shortcuts"] }, +] + +[[rules]] +id = "live_prompt_box" +state = "idle" +priority = 950 +region = "prompt_box_body" +visible_idle = true +line_regex = ['^\s*❯'] +not = [ + { contains = ["enter to select"] }, + { contains = ["esc to cancel"] }, + { contains = ["tab/arrow keys"] }, + { contains = ["arrow keys to navigate"] }, + { contains = ["↑/↓ to navigate"] }, +] + +[[rules]] +id = "model_picker_menu" +state = "unknown" +priority = 900 +region = "whole_recent" +skip_state_update = true +contains = ["select model", "enter to set as default", "esc to cancel"] +not = [ + { contains = ["do you want to proceed?"] }, + { contains = ["enter to select"] }, +] + +[[rules]] +id = "live_blocked_form" +state = "blocked" +priority = 980 +region = "after_last_horizontal_rule" +visible_blocker = true +contains = ["enter to select", "esc to cancel"] +any = [ + { contains = ["tab/arrow keys to navigate"] }, + { contains = ["arrow keys to navigate"] }, + { contains = ["arrows to navigate"] }, + { contains = ["↑/↓ to navigate"] }, + { contains = ["↑↓ to navigate"] }, +] + +[[rules]] +id = "dynamic_workflow_prompt" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true +contains = ["run a dynamic workflow?", "esc to cancel"] + +[[rules]] +id = "generic_permission_prompt" +state = "blocked" +priority = 840 +region = "after_last_horizontal_rule" +visible_blocker = true +contains = ["do you want to proceed?", "esc to cancel"] +all = [ + { any = [ + { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*2\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*2\.\s*no\b'] }, + { line_regex = ['(?i)^\s*3\.\s*no\b'] }, + ] }, +] + +[[rules]] +id = "bash_permission_prompt" +state = "blocked" +priority = 850 +region = "whole_recent" +visible_blocker = true +contains = ["do you want to proceed?"] +any = [ + { contains = ["bash command"] }, + { contains = ["bash("] }, + { contains = ["contains expansion"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, +] +all = [ + { any = [{ line_regex = ['(?i)^\s*❯?\s*yes\b'] }, { line_regex = ['(?i)^\s*1\.\s*yes\b'] }, { line_regex = ['(?i)^\s*2\.\s*no\b'] }] }, +] + +[[rules]] +id = "interrupt_chrome_working" +state = "working" +priority = 970 +region = "above_prompt_box" +visible_working = true +any = [ + { contains = ["esc to interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, +] + +[[rules]] +id = "spinner_activity_working" +state = "working" +priority = 960 +region = "above_prompt_box" +visible_working = true +line_regex = ['^\s*[·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆]\s+.*\p{Alphabetic}.*…'] + +[[rules]] +id = "ascii_spinner_activity_working" +state = "working" +priority = 960 +region = "above_prompt_box" +visible_working = true +line_regex = ['^\s*\*\s+\p{Alphabetic}.*…\s*\([^)]*\b[1-9][0-9]*s\b[^)]*\)'] + +[[rules]] +id = "latest_background_status_working" +state = "working" +priority = 960 +region = "last_non_empty_above_prompt_box" +visible_working = true +any = [ + { regex = ['(?i)^\W*waiting for [1-9][0-9]* background agents? to finish$'] }, + { regex = ['(?i).*\b[1-9][0-9]* shells? still running\b'] }, + { regex = ['(?i).*\b[1-9][0-9]* local agents? still running\b'] }, +] + +[[rules]] +id = "bottom_agent_status_working" +state = "working" +priority = 955 +region = "bottom_non_empty_lines(10)" +visible_working = true +line_regex = ['^\s*◯\s+(?:Explore|Agent)\b.*\b[1-9][0-9]*s\b(?:\s*·.*)?$'] + +[[rules]] +id = "dynamic_workflow_progress_working" +state = "working" +priority = 965 +region = "bottom_non_empty_lines(10)" +visible_working = true +line_regex = ['^\s*[◯●]\s+.*\b(?:[0-9]+/[1-9][0-9]*) agents done\b.*\b[1-9][0-9]*s\b'] + +[[rules]] +id = "legacy_no_prompt_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +any = [ + { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["waiting for permission"] }, + { contains = ["do you want to allow this connection?"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, + { contains = ["do you want to proceed?", "esc to cancel"] }, + { contains = ["review your answers"] }, + { contains = ["skip interview and plan immediately"] }, +] +not = [ + { regex = ['(?m)^\s*❯\s*$'] }, +] diff --git a/src/detect/manifests/cline.toml b/src/detect/manifests/cline.toml new file mode 100644 index 00000000..b30f4417 --- /dev/null +++ b/src/detect/manifests/cline.toml @@ -0,0 +1,26 @@ +id = "cline" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "tool_permission" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["let cline use this tool"] }, + { contains = ["[act mode]", "execute command?", "yes"] }, + { contains = ["[act mode]", "use this tool?", "yes"] }, + { contains = ["[plan mode]", "execute command?", "yes"] }, + { contains = ["[plan mode]", "use this tool?", "yes"] }, +] + +[[rules]] +id = "default_cline_working" +state = "working" +priority = -10 +region = "whole_recent" +visible_working = true +regex = ['(?s).+'] diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml new file mode 100644 index 00000000..cd09e4b0 --- /dev/null +++ b/src/detect/manifests/codex.toml @@ -0,0 +1,105 @@ +id = "codex" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "transcript_viewer" +state = "unknown" +priority = 1000 +region = "after_last_prompt_marker" +skip_state_update = true +contains = ["↑/↓ to scroll", "pgup/pgdn to", "home/end to jump", "q to quit"] +any = [ + { contains = ["esc to edit prev"] }, + { contains = ["esc/← to edit prev"] }, +] + +[[rules]] +id = "live_strong_blocker" +state = "blocked" +priority = 900 +region = "after_last_prompt_marker" +visible_blocker = true +any = [ + { contains = ["press enter to confirm or esc to cancel"] }, + { contains = ["enter to submit answer"] }, + { contains = ["enter to submit all"] }, + { contains = ["allow command?"] }, +] + +[[rules]] +id = "current_live_working" +state = "working" +priority = 710 +region = "current_prompt_block_marker" +visible_working = true +any = [ + { line_regex = ['(?i)^\s*• queued follow-up inputs'] }, + { line_regex = ['(?i)^\s*• messages to be submitted after next tool call'] }, + { contains = ["Waiting for background terminal"] }, + { contains = ["background terminal running"] }, + { contains = ["/ps to view"] }, + { contains = ["/stop to close"] }, + { regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}.*esc\s*(…|to interrupt|to .+…)'] }, +] + +[[rules]] +id = "current_working_status" +state = "working" +priority = 700 +region = "current_prompt_block_marker" +any = [ + { line_regex = ['(?i)^\s*• queued follow-up inputs'] }, + { line_regex = ['(?i)^\s*• messages to be submitted after next tool call'] }, + { contains = ["Working ("] }, + { contains = ["Waiting for background terminal ("] }, + { contains = ["Booting MCP server:"] }, + { contains = ["reviewing approval request ("] }, + { regex = ['(?i)reviewing .* approval requests \('] }, + { regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}\).* • esc(…| to interrupt| to .+…)'] }, +] + +[[rules]] +id = "current_working_status_with_detail" +state = "working" +priority = 690 +region = "after_current_prompt_block_marker" +any = [ + { contains = ["Queued follow-up inputs"] }, + { contains = ["Messages to be submitted after next tool call"] }, +] + +[[rules]] +id = "weak_blocker" +state = "blocked" +priority = 600 +region = "whole_recent" +any = [ + { contains = ["[y/n]"] }, + { contains = ["yes (y)"] }, + { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, +] + +[[rules]] +id = "fallback_working" +state = "working" +priority = 500 +region = "whole_recent_without_current_prompt_marker" +visible_working = true +any = [ + { line_regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}.*esc\s*(…|to interrupt|to .+…)'] }, + { contains = ["esc to interrupt"] }, + { all = [{ contains = ["ctrl+c to interrupt"] }, { line_regex = ['(?i)^\s*•'] }] }, + { all = [{ contains = ["press esc to interrupt"] }, { line_regex = ['(?i)^\s*•'] }] }, +] + +[[rules]] +id = "fallback_status_working" +state = "working" +priority = 490 +region = "whole_recent_without_current_prompt_marker" +any = [ + { line_regex = ['(?i)^\s*•.*(Working \(|Waiting for background terminal \(|Booting MCP server:|reviewing approval request \(|reviewing .* approval requests \()'] }, +] diff --git a/src/detect/manifests/cursor.toml b/src/detect/manifests/cursor.toml new file mode 100644 index 00000000..c97c03d8 --- /dev/null +++ b/src/detect/manifests/cursor.toml @@ -0,0 +1,57 @@ +id = "cursor" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["cursor-agent"] + +[[rules]] +id = "write_file_approval" +state = "blocked" +priority = 320 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["write to this file?", "proceed (y)"] +any = [ + { contains = ["reject & propose changes"] }, + { contains = ["esc or n or p"] }, + { contains = ["add write("] }, +] + +[[rules]] +id = "approval_prompt" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for approval", "run this command?"], any = [{ contains = ["run (once) (y)"] }, { contains = ["skip (esc or n)"] }] }, + { contains = ["(y) (enter)"] }, + { line_regex = ['(?i)^\s*allow .*\(y\)'] }, + { contains = ["keep (n)"] }, + { contains = ["skip (esc or n)"] }, + { line_regex = ['(?i)^\s*(run |.*\(y\).*(allow|run \(once\)|→ run))'] }, +] + +[[rules]] +id = "stop_hint_working" +state = "working" +priority = 100 +region = "bottom_non_empty_lines(6)" +visible_working = true +contains = ["ctrl+c to stop"] + +[[rules]] +id = "background_task_status_working" +state = "working" +priority = 95 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i)\b[1-9][0-9]*\s+background\s+tasks?\b'] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 90 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(⬡|⬢|[\u2800-\u28FF]+)\s+\p{Alphabetic}+\w*ing\b'] diff --git a/src/detect/manifests/droid.toml b/src/detect/manifests/droid.toml new file mode 100644 index 00000000..c41d71b4 --- /dev/null +++ b/src/detect/manifests/droid.toml @@ -0,0 +1,48 @@ +id = "droid" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "execute_selection_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["enter to select", "esc to cancel"] +any = [ + { contains = ["↑↓ to navigate"] }, + { contains = ["use ↑↓ to navigate"] }, +] +all = [ + { any = [{ contains = ["> yes, allow"] }, { contains = ["> no, cancel"] }] }, +] + +[[rules]] +id = "selection_menu_blocker" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["enter select", "esc cancel"] +any = [ + { contains = ["↑/↓ navigate"] }, + { contains = ["↑↓ navigate"] }, +] + +[[rules]] +id = "spinner_stop_working" +state = "working" +priority = 110 +region = "whole_recent" +visible_working = true +contains = ["esc to stop"] +line_regex = ['^\s*[\u2800-\u28FF]'] + +[[rules]] +id = "stop_hint_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to stop"] diff --git a/src/detect/manifests/gemini.toml b/src/detect/manifests/gemini.toml new file mode 100644 index 00000000..9d7a28e1 --- /dev/null +++ b/src/detect/manifests/gemini.toml @@ -0,0 +1,25 @@ +id = "gemini" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "apply_or_allow_change" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["│ Apply this change"] }, + { contains = ["│ Allow execution"] }, + { all = [{ contains = ["yes"] }, { any = [{ contains = ["waiting for user confirmation"] }, { contains = ["│ Do you want to proceed"] }, { contains = ["do you want to proceed?"] }] }] }, + { line_regex = ['(?i)^\s*❯.*(yes|allow)'] }, +] + +[[rules]] +id = "esc_cancel_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] diff --git a/src/detect/manifests/github-copilot.toml b/src/detect/manifests/github-copilot.toml new file mode 100644 index 00000000..dcd8e2ee --- /dev/null +++ b/src/detect/manifests/github-copilot.toml @@ -0,0 +1,30 @@ +id = "copilot" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["github-copilot", "ghcs"] + +[[rules]] +id = "selection_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["esc to cancel"] +any = [ + { contains = ["enter to select"] }, + { contains = ["enter to confirm"] }, + { contains = ["enter to submit"] }, +] + +[[rules]] +id = "working_cancel_hint" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["esc to cancel"] }, + { contains = ["esc cancel"] }, + { contains = ["esc again to cancel"] }, +] diff --git a/src/detect/manifests/grok.toml b/src/detect/manifests/grok.toml new file mode 100644 index 00000000..f4b6537f --- /dev/null +++ b/src/detect/manifests/grok.toml @@ -0,0 +1,28 @@ +id = "grok" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["grok-build"] + +[[rules]] +id = "permission_scope_selector" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["yes, proceed", "no, reject"] +any = [ + { contains = ["use ← → to choose permission whitelist scope"] }, + { contains = ["←/→:scope"] }, +] + +[[rules]] +id = "waiting_tool_working" +state = "working" +priority = 120 +region = "whole_recent" +visible_working = true +any = [ + { all = [{ contains = ["ctrl+c:cancel", "ctrl+enter:interject"] }, { contains = ["waiting"] }] }, + { line_regex = ['^\s*[\u2800-\u28FF]\s+(Run|Read|Search|List)\b'] }, +] diff --git a/src/detect/manifests/hermes.toml b/src/detect/manifests/hermes.toml new file mode 100644 index 00000000..d45fe32a --- /dev/null +++ b/src/detect/manifests/hermes.toml @@ -0,0 +1,30 @@ +id = "hermes" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["hermes-agent"] + +[[rules]] +id = "dangerous_command_approval" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["dangerous command"] }, + { contains = ["allow once", "allow for this session", "deny"] }, +] +all = [ + { any = [{ contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] }, +] + +[[rules]] +id = "interrupt_status_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["msg=interrupt"] }, + { contains = ["ctrl+c cancel"] }, +] diff --git a/src/detect/manifests/kilo.toml b/src/detect/manifests/kilo.toml new file mode 100644 index 00000000..4ef004e1 --- /dev/null +++ b/src/detect/manifests/kilo.toml @@ -0,0 +1,24 @@ +id = "kilo" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["kilo-code", "kilo code", "herdr:kilo"] + +[[rules]] +id = "opencode_permission" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["△ Permission required"] }, + { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, +] + +[[rules]] +id = "esc_interrupt_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc interrupt"] diff --git a/src/detect/manifests/kimi.toml b/src/detect/manifests/kimi.toml new file mode 100644 index 00000000..b4d0100f --- /dev/null +++ b/src/detect/manifests/kimi.toml @@ -0,0 +1,77 @@ +id = "kimi" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["kimi-code", "kimi code"] + +[[rules]] +id = "current_approval_panel" +state = "blocked" +priority = 400 +region = "whole_recent" +visible_blocker = true +contains = ["↵ confirm"] +any = [ + { contains = ["run this command?"] }, + { contains = ["write this file?"] }, + { contains = ["apply these edits?"] }, + { contains = ["stop this task?"] }, + { contains = ["ready to build with this plan?"] }, + { line_regex = ['(?i)^\s*▶?\s*approve .*\?$'] }, +] +all = [ + { contains = [" choose"] }, + { any = [{ contains = ["approve"] }, { contains = ["reject"] }, { contains = ["revise"] }] }, +] + +[[rules]] +id = "question_panel" +state = "blocked" +priority = 390 +region = "whole_recent" +visible_blocker = true +contains = ["↑↓ select", "esc cancel"] +line_regex = ['^\s*question\s*$', '^\s*\? '] +any = [ + { contains = ["↵ choose"] }, + { contains = ["↵ toggle"] }, + { contains = ["↵ save"] }, +] + +[[rules]] +id = "legacy_approval_panel" +state = "blocked" +priority = 300 +region = "whole_recent" +contains = ["requesting approval", "reject"] +any = [ + { contains = ["approve once"] }, + { contains = ["approve for this session"] }, +] +all = [ + { any = [{ contains = ["1/2/3/4 choose"] }, { contains = ["↵ confirm"] }] }, +] + +[[rules]] +id = "background_agent_status_working" +state = "working" +priority = 120 +region = "bottom_non_empty_lines(3)" +visible_working = true +line_regex = ['(?i)\bkimi[-\w.]*\s+thinking\b.*\[[1-9][0-9]*\s+agents?\s+running\]'] + +[[rules]] +id = "moon_spinner_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*(🌕|🌖|🌗|🌘|🌑|🌒|🌓|🌔)\s*$'] + +[[rules]] +id = "braille_spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +line_regex = ['(?i)^\s*[\u2800-\u28FF]+\s*(thinking\.\.\.|working\.\.\.|using )'] diff --git a/src/detect/manifests/kiro.toml b/src/detect/manifests/kiro.toml new file mode 100644 index 00000000..9b0fb3d0 --- /dev/null +++ b/src/detect/manifests/kiro.toml @@ -0,0 +1,51 @@ +id = "kiro" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["kiro-cli"] + +[[rules]] +id = "tool_approval" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["requires approval"] +any = [ + { contains = ["yes, single permission"] }, + { contains = ["trust, always allow"] }, + { contains = ["no (tab to edit)"] }, + { contains = ["esc to close"] }, +] + +[[rules]] +id = "subagent_approval" +state = "blocked" +priority = 290 +region = "whole_recent" +visible_blocker = true +contains = ["pending from subagents"] +any = [ + { contains = ["tool approval"] }, + { contains = ["tool approvals"] }, +] +all = [ + { any = [{ contains = ["approve all pending"] }, { contains = ["configure individually"] }, { contains = ["exit (cancel subagents)"] }] }, +] + +[[rules]] +id = "kiro_working_marker" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["kiro is working"] + +[[rules]] +id = "tool_spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] +line_regex = ['^\s*(◔|◑|◕|●)\s+\p{Alphabetic}'] diff --git a/src/detect/manifests/opencode.toml b/src/detect/manifests/opencode.toml new file mode 100644 index 00000000..52452383 --- /dev/null +++ b/src/detect/manifests/opencode.toml @@ -0,0 +1,37 @@ +id = "opencode" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["open-code", "herdr:opencode"] + +[[rules]] +id = "permission_required" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["△ Permission required"] }, + { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, +] + +[[rules]] +id = "interrupt_hint_working" +state = "working" +priority = 110 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["esc to interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, + { contains = ["press esc to interrupt"] }, + { line_regex = ['(?i).*opencode.*esc (again to )?interrupt'] }, +] + +[[rules]] +id = "progress_bar_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +regex = ['(■|⬝){4,}'] diff --git a/src/detect/manifests/pi.toml b/src/detect/manifests/pi.toml new file mode 100644 index 00000000..a58d30e9 --- /dev/null +++ b/src/detect/manifests/pi.toml @@ -0,0 +1,13 @@ +id = "pi" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["herdr:pi"] + +[[rules]] +id = "working_literal" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["Working..."] diff --git a/src/detect/manifests/qodercli.toml b/src/detect/manifests/qodercli.toml new file mode 100644 index 00000000..51ca805e --- /dev/null +++ b/src/detect/manifests/qodercli.toml @@ -0,0 +1,38 @@ +id = "qodercli" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["qoderclicn", "qoder", "qodercn"] + +[[rules]] +id = "confirmation_or_input_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for user confirmation"], any = [{ contains = ["yes"] }, { contains = ["no"] }, { contains = ["allow"] }, { contains = ["reject"] }] }, + { contains = ["awaiting approval"], any = [{ contains = ["allow"] }, { contains = ["reject"] }] }, + { contains = ["permission required"] }, + { contains = ["allow once or always?"] }, + { contains = ["asking user"] }, + { contains = ["enter your response"] }, + { contains = ["review your answers:"] }, + { contains = ["shell awaiting input"] }, +] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["(esc to cancel,"] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\u2800-\u28FF]\s+.*\p{Alphabetic}'] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index f2def663..62dc3d05 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -3,7 +3,8 @@ //! Each pane's live bottom-of-buffer text is read periodically and matched //! against known agent output patterns to determine state. -mod agents; +pub mod manifest; +pub mod manifest_update; /// The detected state of a terminal pane. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -25,6 +26,8 @@ pub struct AgentDetection { /// True when the current screen is an agent-owned viewer that shows /// transcript/history instead of the live prompt state. pub skip_state_update: bool, + /// True when the current screen visibly shows live idle chrome. + pub visible_idle: bool, /// True when the current screen visibly shows live UI chrome that needs /// human input. This is stronger than arbitrary prompt-like text in the /// scrollback and may override a non-blocked integration state. @@ -35,12 +38,6 @@ pub struct AgentDetection { pub visible_working: bool, } -/// A narrow screen-derived exception that can veto PTY activity as semantic work. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AgentActivityVeto { - ClaudeIdleRecap, -} - /// Which agent we detected running in a pane. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Agent { @@ -63,6 +60,28 @@ pub enum Agent { Qodercli, } +impl Agent { + pub const ALL: [Self; 17] = [ + Self::Pi, + Self::Claude, + Self::Codex, + Self::Gemini, + Self::Cursor, + Self::Antigravity, + Self::Cline, + Self::OpenCode, + Self::GithubCopilot, + Self::Kimi, + Self::Kiro, + Self::Droid, + Self::Amp, + Self::Grok, + Self::Hermes, + Self::Kilo, + Self::Qodercli, + ]; +} + pub fn agent_label(agent: Agent) -> &'static str { match agent { Agent::Pi => "pi", @@ -179,162 +198,27 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio return AgentDetection { state: AgentState::Unknown, skip_state_update: false, + visible_idle: false, visible_blocker: false, visible_working: false, }; }; - agents::detect(agent, screen_content) -} - -pub(crate) fn agent_activity_veto( - agent: Option<Agent>, - screen_content: &str, -) -> Option<AgentActivityVeto> { - match agent { - Some(Agent::Claude) if agents::claude_code::has_idle_recap_notice(screen_content) => { - Some(AgentActivityVeto::ClaudeIdleRecap) - } - _ => None, - } + manifest::detect(agent, screen_content) } pub fn should_skip_state_update(agent: Option<Agent>, screen_content: &str) -> bool { - agent.is_some_and(|agent| agents::should_skip_state_update(agent, screen_content)) -} - -// --------------------------------------------------------------------------- -// Per-agent detectors -// --------------------------------------------------------------------------- - -#[cfg(test)] -fn detect_pi(content: &str) -> AgentState { - detect_state(Some(Agent::Pi), content) -} - -#[cfg(test)] -fn detect_claude(content: &str) -> AgentState { - detect_state(Some(Agent::Claude), content) -} - -#[cfg(test)] -fn detect_codex(content: &str) -> AgentState { - detect_state(Some(Agent::Codex), content) -} - -#[cfg(test)] -fn detect_gemini(content: &str) -> AgentState { - detect_state(Some(Agent::Gemini), content) -} - -#[cfg(test)] -fn detect_cursor(content: &str) -> AgentState { - detect_state(Some(Agent::Cursor), content) -} - -#[cfg(test)] -fn detect_antigravity(content: &str) -> AgentState { - detect_state(Some(Agent::Antigravity), content) -} - -#[cfg(test)] -fn detect_cline(content: &str) -> AgentState { - detect_state(Some(Agent::Cline), content) -} - -#[cfg(test)] -fn detect_opencode(content: &str) -> AgentState { - detect_state(Some(Agent::OpenCode), content) -} - -#[cfg(test)] -fn detect_github_copilot(content: &str) -> AgentState { - detect_state(Some(Agent::GithubCopilot), content) -} - -#[cfg(test)] -fn detect_kimi(content: &str) -> AgentState { - detect_state(Some(Agent::Kimi), content) -} - -#[cfg(test)] -fn detect_droid(content: &str) -> AgentState { - detect_state(Some(Agent::Droid), content) -} - -/// Check for braille spinner characters at the start of a line. -/// These are the Unicode braille pattern dots used by CLI spinners. -fn has_braille_spinner(content: &str) -> bool { - for line in content.lines() { - let trimmed = line.trim(); - if let Some(c) = trimmed.chars().next() { - if ('\u{2800}'..='\u{28FF}').contains(&c) { - return true; - } - } - } - false -} - -#[cfg(test)] -fn detect_qodercli(content: &str) -> AgentState { - detect_state(Some(Agent::Qodercli), content) -} - -#[cfg(test)] -fn detect_kilo(content: &str) -> AgentState { - detect_state(Some(Agent::Kilo), content) -} - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -/// Check for action confirmations followed by "yes" or "❯". -fn has_confirmation_prompt(lower_content: &str) -> bool { - if let Some(pos) = lower_content - .find("do you want to") - .or_else(|| lower_content.find("would you like to")) - { - let after = &lower_content[pos..]; - return after.contains("yes") || after.contains('❯'); - } - false -} - -/// Check for "❯" followed by numbered options like "1." -fn has_selection_prompt(content: &str) -> bool { - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with('❯') { - // Check if there's a digit followed by a dot nearby - if trimmed.chars().any(|c| c.is_ascii_digit()) && trimmed.contains('.') { - return true; - } - } - } - false -} - -/// Check for real interrupt hints, not unrelated "esc" and "interrupt" text. -fn has_interrupt_pattern(lower_content: &str) -> bool { - lower_content.contains("esc to interrupt") - || lower_content.contains("ctrl+c to interrupt") - || lower_content.contains("press esc to interrupt") -} - -#[cfg(test)] -fn has_cursor_spinner(content: &str) -> bool { - agents::cursor::has_cursor_spinner(content) -} - -#[cfg(test)] -fn content_above_prompt_box(content: &str) -> &str { - agents::claude_code::content_above_prompt_box(content) + agent.is_some_and(|agent| manifest::should_skip_state_update(agent, screen_content)) } -#[cfg(test)] -fn has_spinner_activity(content: &str) -> bool { - agents::claude_code::has_spinner_activity(content) +pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool { + matches!( + (source, agent_label), + ("herdr:pi", "pi") + | ("herdr:omp", "omp") + | ("herdr:hermes", "hermes") + | ("herdr:opencode", "opencode") + | ("herdr:kilo", "kilo") + ) } // --------------------------------------------------------------------------- @@ -662,57 +546,10 @@ mod tests { #[test] fn moved_agent_detection_routes_through_production_dispatch() { - let cases = [ - (Agent::Pi, "Working...", AgentState::Working), - ( - Agent::Claude, - "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────", - AgentState::Working, - ), - ( - Agent::Codex, - "generating code\nesc to interrupt", - AgentState::Working, - ), - (Agent::Gemini, "Esc to cancel", AgentState::Working), - (Agent::Cursor, "Ctrl+C to stop", AgentState::Working), - ( - Agent::Antigravity, - "⠋ Thinking through the request", - AgentState::Working, - ), - (Agent::Cline, "Reading project files", AgentState::Working), - ( - Agent::OpenCode, - "running tool\nesc to interrupt", - AgentState::Working, - ), - (Agent::GithubCopilot, "Esc cancel", AgentState::Working), - (Agent::Kimi, "🌕", AgentState::Working), - (Agent::Kiro, "Kiro is working", AgentState::Working), - (Agent::Droid, "Press ESC to stop", AgentState::Working), - (Agent::Amp, "Esc to cancel", AgentState::Working), - ( - Agent::Grok, - "⠋ Waiting… 1.8s\nCtrl+c:cancel Ctrl+Enter:interject", - AgentState::Working, - ), - (Agent::Hermes, "msg=interrupt", AgentState::Working), - ( - Agent::Kilo, - "Ask · DeepSeek V4 Pro\nesc interrupt", - AgentState::Working, - ), - (Agent::Qodercli, "\u{280B} Thinking...", AgentState::Working), - ]; - - for (agent, screen, expected) in cases { - assert_eq!( - detect_agent(Some(agent), screen).state, - expected, - "production dispatch for {agent:?}" - ); - } + let detection = detect_agent(Some(Agent::Pi), "Working..."); + + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); } // ---- Agent identification ---- @@ -1079,1852 +916,13 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - // ---- Workspace state rollup ---- - - // ---- No agent → Unknown ---- + // ---- Screen detection routing ---- #[test] fn no_agent_returns_unknown() { assert_eq!(detect_state(None, "anything"), AgentState::Unknown); } - // ---- Pi ---- - - #[test] - fn pi_working_when_working() { - assert_eq!(detect_pi("some output\nWorking..."), AgentState::Working); - } - - #[test] - fn pi_working_working_in_middle() { - assert_eq!(detect_pi("line1\nWorking...\nline3"), AgentState::Working); - } - - #[test] - fn pi_idle_at_prompt() { - assert_eq!(detect_pi("❯ "), AgentState::Idle); - } - - #[test] - fn pi_idle_no_working_text() { - assert_eq!(detect_pi("some output\n\n> ready"), AgentState::Idle); - } - - // ---- Claude Code ---- - - #[test] - fn claude_working_esc_to_interrupt() { - let screen = "Reading file src/main.rs\nesc to interrupt\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_working_ctrl_c_to_interrupt() { - let screen = "Editing code\nctrl+c to interrupt\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_working_spinner() { - let screen = "✽ Tempering…\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_working_middle_dot_spinner() { - let screen = "· Thinking…\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_working_spinner_with_detail() { - let screen = "✳ Simplifying recompute_tangents…\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_detailed_transcript_skips_state_update() { - let screen = "● I read the root and README.md.\n\n✻ Cogitated for 14s\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all verbose"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(detection.skip_state_update); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_detailed_transcript_collapse_skips_state_update() { - let screen = "● Running tool\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to collapse"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(detection.skip_state_update); - } - - #[test] - fn claude_wrapped_detailed_transcript_controls_skip_state_update() { - let screen = "● Running tool\n\n────────────────────────\n Showing detailed transcript · ctrl+o\n to toggle · ctrl+e to\n collapse"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(detection.skip_state_update); - } - - #[test] - fn claude_transcript_text_above_prompt_does_not_skip_state_update() { - let screen = "Docs mention: Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n❯ \n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(!detection.skip_state_update); - } - - #[test] - fn claude_waiting_do_you_want() { - let screen = "Do you want to run this command?\n\nYes No"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_waiting_would_you_like() { - let screen = "Would you like to apply these changes?\n\n❯ Yes"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_waiting_selection_prompt() { - let screen = "Do you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_waiting_esc_to_cancel() { - let screen = "Allow bash: rm -rf /tmp/test?\n\nDo you want to proceed?\n\nesc to cancel"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_bash_permission_modal_is_visible_blocker() { - let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && for i in 1 2 3; do dd if=/dev/urandom)\n ⎿ Waiting…\n\n────────────────────────\n Bash command\n\n mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test\n Create random files in temporary detector directory\n\n Contains expansion\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend · ctrl+e to explain"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_cropped_bash_permission_modal_is_visible_blocker() { - let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_waiting_ask_user_question_menu() { - let screen = - "Which approach should I take?\n❯ 1. Minimal change\n 2. Bigger refactor\n3. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_dynamic_workflow_prompt_is_visible_blocker() { - let screen = "● Workflow(Review the current worktree diff and verify the resulting findings)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n Review the current worktree diff and verify the resulting findings\n\n This dynamic workflow will spin up multiple subagents across the following phases:\n 1. Overview — map the diff and identify coupled subsystems\n 2. Review — review each changed file in parallel\n 3. Verify — adversarially verify each finding\n 4. Synthesize — summarize confirmed issues and residual risk\n\n Dynamic workflows can use a lot of tokens quickly by running many subagents in parallel — which counts against your usage limit. Stop a running workflow at any time with\n /workflows, or disable dynamic workflows in /config.\n\n ❯ 1. Yes, run it\n 2. View raw script\n 3. No\n\n Esc to cancel · Tab to amend\n ctrl+g to edit script in $EDITOR"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - assert!(!detection.visible_working); - } - - #[test] - fn claude_dynamic_workflow_prompt_uses_stable_modal_gate() { - let screen = "● Workflow(Evaluate changes)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n 1. Scope\n 2. Review\n 3. Synthesize\n\n Esc to cancel · Tab to amend"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_question_form_selected_top_is_visible_blocker() { - let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n❯ 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_question_form_with_arrow_glyph_footer_is_visible_blocker() { - let screen = "❯ this is a test can you use question otol to ask questiosns\n\n Thought for 16s (ctrl+o to expand)\n─────────────────────────────────────────────────────────────────────────────────────────\n ☐ Test type\n\nWhich kind of test question should I ask you next?\n\n❯ 1. Single choice\n Ask one multiple-choice question.\n 2. Multi choice\n Ask one question that allows several answers.\n 3. With preview\n Ask with side-by-side previews.\n 4. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to cancel"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_question_form_selected_bottom_is_visible_blocker() { - let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · ctrl+g to edit in Zed · Esc to cancel"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_idle_hooks_menu() { - let screen = "Hooks\n0 hooks configured\nℹ This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude. Learn more\n\n❯ 1. PreToolUse\n 2. PostToolUse\n 3. PostToolUseFailure\n\nEnter to confirm · Esc to cancel"; - assert_eq!(detect_claude(screen), AgentState::Idle); - } - - #[test] - fn claude_idle_theme_menu() { - let screen = "Theme\nChoose the text style that looks best with your terminal\n\n❯ 1. Dark mode ✔\n 2. Light mode\n 3. Dark mode (colorblind-friendly)\n\nSyntax theme: Monokai Extended (ctrl+t to disable)\n\nEnter to select · Esc to cancel"; - assert_eq!(detect_claude(screen), AgentState::Idle); - } - - #[test] - fn claude_idle_prompt_box() { - let screen = "Task complete.\n─────────────\n❯ \n─────────────"; - assert_eq!(detect_claude(screen), AgentState::Idle); - } - - #[test] - fn claude_idle_recap_notice_is_activity_veto() { - let screen = "✻ Churned for 9s\n\n※ recap: We’re debugging issue 494: kitty graphics render in shell panes but not inside Neovim. Next, I’m tracing the snacks.nvim inline-image path against Herdr’s placeholder\n handling to confirm the exact failure point. (disable recaps in /config)\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────"; - - assert_eq!( - agent_activity_veto(Some(Agent::Claude), screen), - Some(AgentActivityVeto::ClaudeIdleRecap) - ); - } - - #[test] - fn wrapped_claude_idle_recap_notice_is_activity_veto() { - let screen = "※ recap: We’re debugging issue 494: kitty graphics render in shell panes but\n not inside Neovim. Next, I’m tracing the snacks.nvim inline-image path\n against Herdr’s placeholder handling to confirm the exact failure point…\n and then I will inspect the redraw cache. (disable recaps in /config)\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────"; - - assert_eq!( - agent_activity_veto(Some(Agent::Claude), screen), - Some(AgentActivityVeto::ClaudeIdleRecap) - ); - } - - #[test] - fn stale_claude_recap_above_new_work_does_not_veto_activity() { - let screen = "※ recap: Old recap. (disable recaps in /config)\n\n● Reading src/pane.rs\n\n✻ Crunching…\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────"; - - assert_eq!(agent_activity_veto(Some(Agent::Claude), screen), None); - } - - #[test] - fn claude_prompt_box_does_not_set_screen_idle_signal() { - let screen = "Interrupted.\n─────────────\n❯ \n─────────────"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - } - - #[test] - fn claude_prompt_box_with_status_text_and_custom_status_does_not_set_screen_idle_signal() { - let screen = "──────────────────────────────────────────── ◐ medium · /effort\n❯ \n────────────────────────────────────────────\n thommie-backend | refactor/cleanup-codebase | Opus 4.8 (1M context)\nIppy Tippy\n/coach-dive to chat about this\n▸▸ auto mode on (shift+tab to cycle) · ← for agents"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_interrupted_permission_prompt_box_does_not_set_screen_idle_signal() { - let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_declined_questions_in_scrollback_with_prompt_box_are_idle() { - let screen = "● User declined to answer questions\n ⎿ · What do you want help with? (Code task / PR review / Research / Claude setup)\n · How detailed should I be? (Short / Medium / Detailed)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~ ⊘ no git ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_old_permission_prompt_with_live_prompt_box_is_idle() { - let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_old_permission_prompt_above_wrapped_live_prompt_is_not_visible_blocker() { - let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ explain why this stale approval prompt should not be considered live anymore because\n my current prompt wrapped onto a second line\n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(!detection.visible_blocker); - assert_eq!( - crate::agent_detection_policy::apply_detection_policy( - crate::agent_detection_policy::DetectionPolicyInput { - agent: Some(Agent::Claude), - screen_detection: detection, - process_exited: false, - startup_grace_active: false, - pty_signal: Some(crate::agent_detection_policy::PtySignal { - active: false, - tainted: false, - }), - }, - ), - crate::agent_detection_policy::DetectionPolicyDecision::Publish(AgentDetection { - state: AgentState::Idle, - skip_state_update: false, - visible_blocker: false, - visible_working: false, - }) - ); - } - - #[test] - fn claude_spinner_after_interrupted_permission_is_visible_working() { - let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n❯ test\n\n✢ Garnishing… (1s · thinking with high effort)\n ⎿ Tip: Run claude --continue or claude --resume to resume a conversation\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_separators_without_prompt_do_not_set_screen_idle_signal() { - let screen = "Task complete.\n─────────────\nplain text\n─────────────"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - } - - #[test] - fn claude_spinner_above_prompt_box_is_working() { - let screen = "✢ Imagining… (3s · thinking with high effort)\n ⎿ Tip: Run /terminal-setup\n\n─────────────\n❯ \n─────────────\n~/project"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn claude_waiting_for_background_agent_is_working() { - let screen = "● Done. I’ve delegated a read-only repo investigation to a subagent, and it will come back with a detailed report.\n\n✻ Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/llm-proxy ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ Explore Investigate repo and report 33s · ↓ 225 tokens"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_waiting_for_multiple_background_agents_is_working() { - let screen = "● Done. I’ve delegated two investigations.\n\n✻ Waiting for 2 background agents to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn claude_completed_background_agent_wait_in_scrollback_is_idle() { - let screen = "❯ please create a background agent that sleeps 15 sec then say hi\n\n Thought for 8s (ctrl+o to expand)\n\n● claude(Sleep then say hi)\n ⎿ Backgrounded agent (↓ to manage · ctrl+o to expand)\n\n● Done. I launched a background agent that will wait 15 seconds and then reply with hi.\n\n✻ Waiting for 1 background agent to finish\n\n● Agent \"Sleep then say hi\" completed · 17s\n\n● hi\n\n✻ Brewed for 28s\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_completed_background_agent_with_prompt_box_is_idle() { - let screen = "● Done. I’ve started a background agent.\n\n✻ Waiting for 1 background agent to finish\n\n⏺ Agent \"Sleep then say hi\" completed · 31s\n\n⏺ hi\n\n✻ Baked for 42s\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ general-purpose Sleep then say hi 31s"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_zero_background_agent_wait_with_prompt_box_is_idle() { - let screen = "✻ Waiting for 0 background agents to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - } - - #[test] - fn claude_background_agent_wait_phrase_in_prose_is_idle() { - let screen = "Claude mentioned: Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - } - - #[test] - fn claude_working_not_confused_by_old_prompt() { - // The "esc to interrupt" is ABOVE the prompt box — should be working - let screen = "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - // ---- Codex ---- - - #[test] - fn codex_waiting_confirm() { - assert_eq!( - detect_codex("press enter to confirm or esc to cancel"), - AgentState::Blocked - ); - } - - #[test] - fn codex_waiting_allow_command() { - assert_eq!(detect_codex("allow command?\n[y/n]"), AgentState::Blocked); - } - - #[test] - fn codex_waiting_submit_answer() { - assert_eq!( - detect_codex("Question about approach\n| enter to submit answer"), - AgentState::Blocked - ); - } - - #[test] - fn codex_question_ui_is_visible_blocker() { - let screen = "Question 1/1 (1 unanswered)\nWhat kind of code improvement do you want?\n› 1. Reduce complexity\n 2. Improve reliability\n\ntab to add notes | enter to submit answer | esc to interrupt"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn codex_bare_yes_no_hint_is_not_visible_blocker() { - let detection = detect_agent(Some(Agent::Codex), "The docs mention [y/n] prompts."); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_replayed_transcript_weak_blocked_text_above_prompt_is_not_visible_blocker() { - let screen = "Codex\nBlocked signals in src/detect/agents/codex.rs:6: confirm footer, submit answer/all, allow command, [y/n], yes (y), or generic confirmation.\n\nLikely false positives: [y/n] and generic confirmation prose still mark Blocked.\n\n• Agent thread 019e7670-ba31-7641-b6e0-545c101de8c3 is closed. Replaying saved transcript.\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master · gpt-5.5 default · Context 37% used"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_replayed_allow_command_text_above_prompt_is_idle() { - let screen = "• Ran grep for blockers\n └ The phrase allow command? appears in our discussion above.\n\n■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.\n\n\n› Write tests for @filename\n\n gpt-5.5 high · ~/.herdr/worktrees/herdr/plugin-v1 · plugin-v1 · Context 18% used · 5h 93% left · weekly 71% left"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_generic_confirmation_prompt_is_not_visible_blocker() { - let detection = detect_agent( - Some(Agent::Codex), - "Earlier output asked: do you want to continue? The answer was yes.", - ); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(!detection.visible_blocker); - } - - #[test] - fn gemini_confirmation_prompt_is_visible_blocker() { - let detection = detect_agent(Some(Agent::Gemini), "Do you want to proceed?\n\nYes No"); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn codex_waiting_submit_answer_wrapped_footer() { - assert_eq!( - detect_codex( - "Question 1/2\nChoose an option.\nenter to submit answer\nesc to interrupt" - ), - AgentState::Blocked - ); - } - - #[test] - fn codex_waiting_submit_all_multi_question_footer() { - let screen = "Question 2/2 (1 unanswered)\nAt a high level, what is issue 249 supposed to fix?\n› 1. State arbitration (Recommended)\n 2. UI behavior\n 3. Test reliability\n 4. None of the above\n\ntab to add notes | enter to submit all | ←/→ to navigate questions | esc to interrupt"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn codex_interrupted_prompt_does_not_set_screen_idle_signal() { - let screen = "■ Conversation interrupted - tell the model what to do differently. Something went\nwrong? Hit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n gpt-5.5 high · ~/Projects/herdr-worktrees/issue-249-state-arbitration"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Idle); - } - - #[test] - fn codex_working_interrupt() { - assert_eq!( - detect_codex("generating code\nesc to interrupt"), - AgentState::Working - ); - } - - #[test] - fn codex_working_truncated_status_header() { - assert_eq!(detect_codex("• Working (0s • esc…"), AgentState::Working); - } - - #[test] - fn codex_status_line_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Ran git status --short\n └ M src/detect.rs\n\n• Working (17s • esc to interrupt)\n\n\n› Implement {feature}", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_live_working_status_above_current_prompt_stays_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Ran git diff --name-status v0.6.3..HEAD\n └ A website/src/pages/releases/index.astro\n\n• Working (28s • esc to interrupt)\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 96% left", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_generic_interrupt_status_above_current_prompt_stays_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• I need to inspect more code first.\n\n• Investigating code output (44s • esc to interrupt)\n\n\n› Summarize recent commits\n\n gpt-5.5 high · ~/Codes/herdr", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_truncated_status_with_arbitrary_label_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Ran git diff -- src/detect/mod.rs\n └ diff output...\n\n• Considering patch updates (2m 26s • esc …\n\n\n› Fix Codex detection\n\n ~/Projects/herdr · master", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_truncated_status_without_prompt_is_working() { - assert_eq!( - detect_codex("• Considering patch updates (2m 26s • esc …"), - AgentState::Working - ); - } - - #[test] - fn codex_bullet_with_esc_text_without_timer_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Notes mention esc to interrupt as a phrase\n\n› Fix Codex detection\n\n ~/Projects/herdr · master", - ); - - assert!(!detection.visible_blocker); - assert!(!detection.visible_working); - } - - #[test] - fn codex_background_terminal_status_above_current_prompt_stays_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Ran just check\n └ test output...\n\n• Working (1m 36s) · 1 background terminal running · /ps to view · /stop to close\n\n\n› Run /review on my current changes", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_reviewing_approval_request_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Reviewing approval request (22s • esc to interrupt)\n └ /bin/zsh -lc 'rm -rf /tmp/codex-rm-test'\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_reviewing_approval_request_without_prompt_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Running git push --force-with-lease origin codex/ci-flake-diagnostics\n\n• Reviewing approval request (2m 53s • esc to interrupt)\n └ /bin/zsh -lc 'git push --force-with-lease origin codex/ci-flake-diagnostics'", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_reviewing_multiple_approval_requests_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Reviewing 2 approval requests (0s • esc to interrupt)\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_booting_mcp_server_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Booting MCP server: codex_apps (7s • esc to interrupt)\n\n\n› ok spawn one again please\n\n ~/Projects/herdr · master", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_stale_working_status_above_current_prompt_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› `/feedback` to report the issue.\n\n\n › Working\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used\n\n\n■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 95% left", - ); - - assert!(!detection.visible_working); - } - - #[test] - fn codex_pasted_transcript_in_current_prompt_does_not_trigger_working() { - let detection = detect_agent( - Some(Agent::Codex), - "› so there is a problem\n\n\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly\n 36% …\n\n wdyt\n\n\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly 36% …", - ); - - assert!(!detection.visible_working); - } - - #[test] - fn codex_background_terminal_wait_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Waiting for background terminal (0s • esc to …\n └ cargo test -p codex-core -- --exact…\n\n\n› Ask Codex to do anything", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_working_header_without_interrupt_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Working (17s)\n\n› Implement {feature}", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(!detection.visible_working); - } - - #[test] - fn codex_old_working_line_before_later_block_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Working (17s • esc to interrupt)\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}", - ); - - assert!(!detection.visible_working); - } - - #[test] - fn codex_old_background_terminal_wait_before_later_block_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Waiting for background terminal (0s • esc to …\n └ cargo test -p codex-core\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}", - ); - - assert!(!detection.visible_working); - } - - #[test] - fn codex_queued_follow_up_keeps_active_working_status() { - let cases = [ - "• Working (9m 24s • esc to interrupt)\n\n • Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - "• Working (9m 24s • esc to interrupt)\n\n• Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - "• Working (4s • esc to interrupt)\n\n • Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - "• Working (4s • esc to interrupt)\n\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - ]; - - for screen in cases { - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - } - - #[test] - fn codex_idle() { - assert_eq!(detect_codex("❯ "), AgentState::Idle); - } - - #[test] - fn codex_transcript_viewer_skips_state_update() { - let screen = "/ T R A N S C R I P T / / / / / / / / / / / / / / / /\n\n› i did thats why our latest commit is also a claude fix D\n\n• Yes, then I would release.\n──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(detection.skip_state_update); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_wrapped_transcript_controls_skip_state_update() { - let screen = "/ T R A N S C R I P T /\n\n› yeah go ahead\n──────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to\n page home/end to jump\n q to quit esc to edit prev"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(detection.skip_state_update); - } - - #[test] - fn codex_esc_transcript_controls_skip_state_update() { - let screen = "/ T R A N S C R I P T /\n\n› yeah go ahead\n──────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc/← to edit prev → to edit next enter to edit message"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(detection.skip_state_update); - } - - #[test] - fn codex_transcript_controls_above_prompt_do_not_skip_state_update() { - let screen = "Old output:\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev\n\n› "; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(!detection.skip_state_update); - } - - #[test] - fn codex_exit_control_without_scroll_control_does_not_skip_state_update() { - let screen = "Some output\n\n q to quit esc to edit prev"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(!detection.skip_state_update); - } - - // ---- Gemini ---- - - #[test] - fn gemini_waiting_confirmation() { - assert_eq!( - detect_gemini("waiting for user confirmation\nYes No"), - AgentState::Blocked - ); - } - - #[test] - fn gemini_waiting_text_without_choices_is_idle() { - assert_eq!( - detect_gemini("waiting for user confirmation"), - AgentState::Idle - ); - } - - #[test] - fn gemini_waiting_apply() { - assert_eq!( - detect_gemini("│ Apply this change\n│ Yes │ No"), - AgentState::Blocked - ); - } - - #[test] - fn gemini_waiting_allow_execution() { - assert_eq!( - detect_gemini("│ Allow execution of: rm test.txt"), - AgentState::Blocked - ); - } - - #[test] - fn gemini_working() { - assert_eq!( - detect_gemini("thinking...\nesc to cancel"), - AgentState::Working - ); - } - - #[test] - fn gemini_idle() { - assert_eq!(detect_gemini("❯ "), AgentState::Idle); - } - - // ---- Cursor ---- - - #[test] - fn cursor_waiting_accept() { - assert_eq!( - detect_cursor("Apply changes? (y) (enter) or keep (n)"), - AgentState::Blocked - ); - } - - #[test] - fn cursor_waiting_allow() { - assert_eq!(detect_cursor("allow file edit (y)"), AgentState::Blocked); - } - - #[test] - fn cursor_working_spinner() { - assert_eq!(detect_cursor("⬡ Grepping.."), AgentState::Working); - } - - #[test] - fn cursor_working_braille_status() { - assert_eq!( - detect_cursor("⠠⠜ Running 5.52k tokens"), - AgentState::Working - ); - assert_eq!( - detect_cursor("⠞ Working 5.62k tokens"), - AgentState::Working - ); - assert_eq!( - detect_cursor("⠛ Grepping 1.2k tokens"), - AgentState::Working - ); - } - - #[test] - fn cursor_blocked_command_approval() { - let screen = - "Waiting for approval...\nRun this command?\n→ Run (once) (y)\nSkip (esc or n)"; - assert_eq!(detect_cursor(screen), AgentState::Blocked); - } - - #[test] - fn cursor_approval_words_without_visible_controls_are_idle() { - assert_eq!( - detect_cursor("previous output: waiting for approval"), - AgentState::Idle - ); - } - - #[test] - fn cursor_running_text_with_unrelated_yes_is_not_blocked() { - let screen = "previous answer mentioned (y)\n⠠⠜ Running 5.52k tokens"; - assert_eq!(detect_cursor(screen), AgentState::Working); - } - - #[test] - fn cursor_working_ctrl_c() { - assert_eq!( - detect_cursor("processing\nctrl+c to stop"), - AgentState::Working - ); - } - - #[test] - fn cursor_idle() { - assert_eq!(detect_cursor("> "), AgentState::Idle); - } - - // ---- Antigravity ---- - - #[test] - fn antigravity_blocked_permission_prompt() { - let screen = "Requesting permission for: git log -n 50\nDo you want to proceed?\n> 1. Yes\n↑/↓ Navigate · tab Amend · e edit command"; - assert_eq!(detect_antigravity(screen), AgentState::Blocked); - } - - #[test] - fn antigravity_question_without_permission_request_stays_idle() { - assert_eq!( - detect_antigravity("Do you want to proceed?\n>"), - AgentState::Idle - ); - } - - #[test] - fn antigravity_working_spinner() { - assert_eq!(detect_antigravity("⡿ Working..."), AgentState::Working); - assert_eq!(detect_antigravity("⣯ Loading..."), AgentState::Working); - assert_eq!(detect_antigravity("⢿ Generating..."), AgentState::Working); - assert_eq!(detect_antigravity("⣷ Running..."), AgentState::Working); - } - - #[test] - fn antigravity_background_task_footer_is_working() { - let screen = "? for shortcuts Gemini 3.5 Flash (High) · 1 task(s) · /tasks"; - assert_eq!(detect_antigravity(screen), AgentState::Working); - } - - #[test] - fn antigravity_background_task_footer_parses_plural_variants() { - assert_eq!( - detect_antigravity("Gemini 3.5 Flash (High) · 10 task(s) · /tasks"), - AgentState::Working - ); - assert_eq!( - detect_antigravity("model · 2 tasks · /tasks"), - AgentState::Working - ); - assert_eq!( - detect_antigravity("model · 1 task · /tasks"), - AgentState::Working - ); - } - - #[test] - fn antigravity_zero_background_tasks_is_idle() { - let screen = "Gemini 3.5 Flash (High) · 0 task(s) · /tasks"; - assert_eq!(detect_antigravity(screen), AgentState::Idle); - } - - #[test] - fn antigravity_task_text_outside_bottom_footer_is_idle() { - let screen = - "User said the footer had /tasks and 1 task(s)\nline 1\nline 2\nline 3\nline 4\nline 5"; - assert_eq!(detect_antigravity(screen), AgentState::Idle); - } - - #[test] - fn antigravity_idle_prompt() { - let screen = "Antigravity CLI\n────────────────\n>\n────────────────\n? for shortcuts"; - assert_eq!(detect_antigravity(screen), AgentState::Idle); - } - - // ---- Cline ---- - - #[test] - fn cline_waiting_tool_use() { - assert_eq!(detect_cline("let cline use this tool"), AgentState::Blocked); - } - - #[test] - fn cline_waiting_act_mode() { - assert_eq!( - detect_cline("[act mode] execute command?\nyes"), - AgentState::Blocked - ); - } - - #[test] - fn cline_mode_text_with_unrelated_yes_is_not_blocked() { - assert_eq!( - detect_cline("[act mode]\nPrevious answer was yes."), - AgentState::Working - ); - } - - #[test] - fn cline_ready_text_is_not_visible_blocker() { - let detection = detect_agent(Some(Agent::Cline), "cline is ready for your message"); - - assert!(!detection.visible_blocker); - } - - #[test] - fn cline_defaults_to_working() { - // Cline's default is working (unlike other agents) - assert_eq!(detect_cline("some random output"), AgentState::Working); - } - - // ---- OpenCode ---- - - #[test] - fn opencode_waiting_permission() { - assert_eq!( - detect_opencode("△ Permission required"), - AgentState::Blocked - ); - } - - #[test] - fn opencode_working() { - assert_eq!( - detect_opencode("running tool\nesc to interrupt"), - AgentState::Working - ); - } - - #[test] - fn opencode_working_on_footer_interrupt() { - let screen = "\ - ▣ Build · MiniMax M3 Free\n\ -\n\ - ┃\n\ - ┃ Build · MiniMax M3 Free OpenCode Zen ~/Projects/llm-proxy:master\n\ - ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n\ - ⬝⬝⬝■■■■■ esc interrupt 24.4K (12%) ctrl+p commands • OpenCode 1.15.13"; - assert_eq!(detect_opencode(screen), AgentState::Working); - } - - #[test] - fn opencode_working_on_progress_footer_without_product_text() { - assert_eq!( - detect_opencode("■■■■■■⬝⬝ esc interrupt"), - AgentState::Working - ); - } - - #[test] - fn opencode_working_on_escape_again_footer() { - assert_eq!( - detect_opencode( - "⬝⬝■■■■■■ esc again to interrupt 14.3K (7%) ctrl+p commands • OpenCode 1.15.13" - ), - AgentState::Working - ); - } - - #[test] - fn opencode_progress_row_alone_is_working() { - assert_eq!(detect_opencode("■■■■■■⬝⬝"), AgentState::Working); - } - - #[test] - fn opencode_ctrl_p_commands_alone_is_not_working() { - assert_eq!( - detect_opencode("esc interrupt 24.4K (12%) ctrl+p commands"), - AgentState::Idle - ); - } - - #[test] - fn opencode_waiting_question_prompt() { - assert_eq!( - detect_opencode( - "Goal Detail Confirm\n\ - What do you want help with right now?\n\ - 1. Code change\n\ - 5. Type your own answer\n\ - ⇆ tab ↑↓ select enter confirm esc dismiss", - ), - AgentState::Blocked - ); - } - - #[test] - fn opencode_waiting_question_confirm_tab() { - assert_eq!( - detect_opencode("Goal Detail Confirm\nReview\n⇆ tab enter submit esc dismiss"), - AgentState::Blocked - ); - } - - #[test] - fn opencode_idle() { - assert_eq!(detect_opencode("> "), AgentState::Idle); - } - - // ---- Kilo ---- - - #[test] - fn kilo_waiting_question_prompt() { - assert_eq!( - detect_kilo( - "Write the following joke to ~/joke.md?\n\ - 1. Yes, write it\n\ - 2. No, thanks\n\ - 3. Type your own answer\n\ - ↑↓ select enter submit esc dismiss", - ), - AgentState::Blocked - ); - } - - #[test] - fn kilo_working() { - assert_eq!( - detect_kilo("Ask · DeepSeek V4 Pro\nesc interrupt\n12.8K (1%) · $0.01"), - AgentState::Working - ); - } - - #[test] - fn kilo_idle() { - assert_eq!( - detect_kilo("Ask anything... \"Fix broken tests\"\nCode · DeepSeek V4 Pro"), - AgentState::Idle - ); - } - - // ---- GitHub Copilot ---- - - #[test] - fn copilot_waiting_fetch_approval() { - let content = "\ -╭───────────────────────────────────────────────────────────────────╮ -│ Fetch web content │ -│ ───────────────────────────────────────────────────────────────── │ -│ Copilot is attempting to access the following URL: │ -│ │ -│ ╭───────────────────────────────────────────────────────────────╮ │ -│ │ https://www.google.com/ │ │ -│ ╰───────────────────────────────────────────────────────────────╯ │ -│ │ -│ Do you want to allow this access? │ -│ │ -│ ❯ 1. Yes │ -│ 2. No │ -│ │ -│ ↑/↓ to navigate · enter to select · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_allow_directory_access() { - let content = "\ -╭──────────────────────────────────────────────────────────────────╮ -│ Allow directory access │ -│ ──────────────────────────────────────────────────────────────── │ -│ This action may read or write the following path outside your │ -│ allowed directory list. │ -│ │ -│ ╭──────────────────────────────────────────────────────────────╮ │ -│ │ /Users/user/Dev/workspace │ │ -│ ╰──────────────────────────────────────────────────────────────╯ │ -│ │ -│ Do you want to allow this? │ -│ │ -│ 1. Yes │ -│ ❯ 2. Yes, and add these directories to the allowed list │ -│ 3. No (Esc) │ -│ │ -│ ↑/↓ to navigate · enter to select · esc to cancel │ -╰──────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_directory_permission() { - let content = "\ -○ Asking user Requesting permission to access directory 'src/' for r… - -╭───────────────────────────────────────────────────────────────────╮ -│ Question │ -│ ───────────────────────────────────────────────────────────────── │ -│ Requesting permission to access directory 'src/' for reading │ -│ files. Allow? │ -│ │ -│ ❯ 1. Yes (Allow) │ -│ 2. No (Deny) │ -│ │ -│ ↑/↓ to select · enter to confirm · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_action_confirmation() { - let content = "\ -○ Asking user Confirm action: 'Reset local changes in working tree' … - -╭───────────────────────────────────────────────────────────────────╮ -│ Question │ -│ ───────────────────────────────────────────────────────────────── │ -│ Confirm action: 'Reset local changes in working tree' — proceed? │ -│ │ -│ ❯ 1. Yes, reset changes │ -│ 2. No, cancel │ -│ │ -│ ↑/↓ to select · enter to confirm · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_db_choice() { - let content = "\ -○ Asking user Choose a database for the project: - -╭───────────────────────────────────────────────────────────────────╮ -│ Question │ -│ ───────────────────────────────────────────────────────────────── │ -│ Choose a database for the project: │ -│ │ -│ ❯ 1. PostgreSQL (Recommended) │ -│ 2. SQLite │ -│ 3. MySQL │ -│ 4. MongoDB │ -│ │ -│ ↑/↓ to select · enter to confirm · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_freeform_input() { - let content = "\ -○ Asking user Enter the name for the new branch: - -╭───────────────────────────────────────────────────────────────────╮ -│ Question │ -│ ───────────────────────────────────────────────────────────────── │ -│ Enter the name for the new branch: │ -│ │ -│ ❯ Type your answer... │ -│ │ -│ enter to submit · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_plan_review() { - let content = "\ -○ Plan ready for review - Create a 200-word inspirational poem. - Fi… - -╭───────────────────────────────────────────────────────────────────╮ -│ Plan Ready for Review │ -│ ───────────────────────────────────────────────────────────────── │ -│ - Create a 200-word inspirational poem. │ -│ - Files/changes: none (deliver poem in chat). │ -│ - Steps: draft poem, verify ~200 words, offer up to 2 revisions. │ -│ - Decision: Inspirational tone, exact length target ~200 words. │ -│ │ -│ ❯ 1. Accept plan and build on default permissions (recommended) │ -│ 2. Exit plan mode and I will prompt myself │ -│ 3. Suggest changes │ -│ │ -│ ↑/↓ to navigate · enter to select · ctrl+e to show full plan · │ -│ esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_enable_autopilot() { - let content = "\ -╭───────────────────────────────────────────────────────────────────╮ -│ Enable autopilot mode │ -│ ───────────────────────────────────────────────────────────────── │ -│ Autopilot mode works best with all permissions enabled. Without │ -│ them, permission requests will be auto-denied and the agent may │ -│ not complete tasks requiring file edits or shell commands. │ -│ │ -│ You can also enable permissions later with /allow-all │ -│ │ -│ ❯ 1. Enable all permissions (recommended) │ -│ 2. Continue with limited permissions │ -│ 3. Cancel (Esc) │ -│ │ -│ ↑/↓ to navigate · enter to select · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_working_thinking_spinner() { - assert_eq!( - detect_github_copilot("○ Thinking esc cancel"), - AgentState::Working - ); - assert_eq!( - detect_github_copilot("◎ Thinking esc cancel"), - AgentState::Working - ); - assert_eq!( - detect_github_copilot("◉ Thinking esc cancel"), - AgentState::Working - ); - } - - // ---- Kimi ---- - - #[test] - fn kimi_blocked_approval_prompt_wins_over_spinner() { - let screen = "⠋ Using Shell (git log --oneline -10)\n╭─ approval ─╮\nShell is requesting approval to run command:\ngit log --oneline -10\n→ [1] Approve once\n[2] Approve for this session\n[3] Reject\n[4] Reject, tell the model what to do instead\n▲/▼ select 1/2/3/4 choose ↵ confirm"; - assert_eq!(detect_kimi(screen), AgentState::Blocked); - } - - #[test] - fn kimi_current_approval_panel_is_visible_blocker() { - let screen = "────────────────────────────────────────────────────\n ▶ Run this command?\n\n $ git status --short\n\n ▶ 1. Approve once\n 2. Approve for this session\n 3. Reject\n 4. Reject with feedback\n\n ↑/↓ select · 1/2/3/4 choose · ↵ confirm\n────────────────────────────────────────────────────"; - let detection = detect_agent(Some(Agent::Kimi), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn kimi_question_panel_is_visible_blocker() { - let screen = "────────────────────────────────────────────────────\n question\n\n (○) Destination Submit\n\n ? Which destination should I use?\n\n → [1] Local checkout\n [2] Remote branch\n [3] Other\n\n ↑↓ select 1-3 / ↵ choose ←/→/tab switch esc cancel\n────────────────────────────────────────────────────"; - let detection = detect_agent(Some(Agent::Kimi), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn kimi_approval_words_without_prompt_stay_idle() { - assert_eq!(detect_kimi("approve?"), AgentState::Idle); - assert_eq!(detect_kimi("continue? [y/n]"), AgentState::Idle); - } - - #[test] - fn kimi_working_braille_thinking() { - assert_eq!( - detect_kimi("⠦ Thinking... <1s · 19 tokens"), - AgentState::Working - ); - } - - #[test] - fn kimi_working_braille_using_tool() { - assert_eq!( - detect_kimi("⠹ Using Shell (git log -20 --name-status)"), - AgentState::Working - ); - } - - #[test] - fn kimi_working_braille_working_status_is_visible_working() { - let detection = detect_agent(Some(Agent::Kimi), "⠋ working..."); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn kimi_working_moon_spinner() { - assert_eq!(detect_kimi("🌕"), AgentState::Working); - assert_eq!(detect_kimi("🌗"), AgentState::Working); - assert_eq!(detect_kimi("🌘"), AgentState::Working); - } - - #[test] - fn kimi_working_moon_spinner_above_input_box() { - let screen = "✨ yo\n\n🌗\n\n── input ─────────────────────────"; - assert_eq!(detect_kimi(screen), AgentState::Working); - } - - #[test] - fn kimi_old_transcript_words_stay_idle() { - assert_eq!(detect_kimi("thinking"), AgentState::Idle); - assert_eq!(detect_kimi("generating code"), AgentState::Idle); - assert_eq!( - detect_kimi("Used Shell (git log --oneline -10)"), - AgentState::Idle - ); - assert_eq!(detect_kimi("some 🌕 in prose"), AgentState::Idle); - } - - #[test] - fn kimi_idle() { - let screen = "Welcome to Kimi Code CLI!\n── input ─\n────────────────\nagent (Kimi-k2.6 ●) ~/Projects/herdr"; - assert_eq!(detect_kimi(screen), AgentState::Idle); - } - - #[test] - fn kimi_current_editor_box_does_not_set_screen_idle_signal() { - let screen = "╭──────────────────────────────────────────────────╮\n│ > │\n╰──────────────────────────────────────────────────╯\nk2 ~/Projects/herdr /help: show commands\n context: 0.0%"; - let detection = detect_agent(Some(Agent::Kimi), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - // ---- Kiro ---- - - #[test] - fn kiro_working_on_status_bar() { - let screen = "◕ Shell\n esc to cancel\n● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n Kiro is working · type to queue a message"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working); - } - - #[test] - fn kiro_working_on_tool_spinner_and_cancel_hint() { - let screen = "◕ Shell\n esc to cancel\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working); - } - - #[test] - fn kiro_idle_at_prompt() { - let screen = "● 1 MCP failure — see /mcp\n──────────────────────────────────────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n ask a question or describe a task ↵\n /copy to clipboard"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle); - } - - #[test] - fn kiro_blocked_on_tool_approval_prompt() { - let screen = "↓ Shell mkdir -p /tmp/test-kiro-{a,b,c} && ls /tmp/test-kiro-*\n\n─────────────────────────────────────────────────────────────────────────────────────────\n shell requires approval\n ❯ Yes, single permission\n Trust, always allow in this session\n No (Tab to edit)\n─────────────────────────────────────────────────────────────────────────────────────────\n ESC to close | Tab to edit"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Blocked); - } - - #[test] - fn kiro_blocked_on_subagent_tool_approval_prompt() { - let screen = " Please delegate to a sub-agent to search the web\n\n● Orchestrating (1 agent)\n esc to cancel\n ctrl+g open agent monitor\n ● web-research kiro_default ⚠ tool approval needed\n\n ◐ Tasks · 1 done · 2 remaining ctrl+x to expand\n──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ⚠ 3 tool approvals pending from subagents\n ❯ (a) Approve all pending\n (f) Approve all pending and auto-approve all future requests\n (c) Configure individually (agent monitor)\n (x) Exit (cancel subagents) %"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Blocked); - } - - #[test] - fn kiro_does_not_treat_stale_failure_spinner_as_working() { - let screen = "● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%\n\n ask a question or describe a task ↵"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle); - } - - #[test] - fn kiro_identified_by_process_name() { - assert_eq!(identify_agent("kiro"), Some(Agent::Kiro)); - assert_eq!(identify_agent("kiro-cli"), Some(Agent::Kiro)); - } - - // ---- Droid ---- - - #[test] - fn droid_working_thinking_with_spinner() { - let screen = "> how u doin\n\n⠴ Thinking... (Press ESC to stop)\n\nAuto (Off)"; - assert_eq!(detect_droid(screen), AgentState::Working); - } - - #[test] - fn droid_working_esc_to_stop_alone() { - // ESC to stop without spinner is still working (UI chrome) - let screen = "Processing\n(Press ESC to stop)"; - assert_eq!(detect_droid(screen), AgentState::Working); - } - - #[test] - fn droid_waiting_execute_approval() { - let screen = concat!( - "⛬ I'll create some folders.\n\n", - " EXECUTE (mkdir -p /tmp/test, impact: medium)\n\n", - "╭────────────────────╮\n", - "│ > Yes, allow │\n", - "│ Yes, always allow │\n", - "│ No, cancel │\n", - "╰────────────────────╯\n", - " Use ↑↓ to navigate, Enter to select, Esc to cancel\n", - ); - assert_eq!(detect_droid(screen), AgentState::Blocked); - } - - #[test] - fn droid_waiting_selection_with_chrome() { - let screen = "│ > Yes, allow │\n│ No, cancel │\n Use ↑↓ to navigate, Enter to select, Esc to cancel"; - assert_eq!(detect_droid(screen), AgentState::Blocked); - } - - #[test] - fn droid_not_waiting_on_options_text_alone() { - // "Yes, allow" in normal conversation should NOT trigger blocked - let screen = "The user said > Yes, allow the changes"; - assert_eq!(detect_droid(screen), AgentState::Idle); - } - - #[test] - fn droid_idle_prompt() { - let screen = - "╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help"; - assert_eq!(detect_droid(screen), AgentState::Idle); - } - - #[test] - fn droid_idle_after_response() { - let screen = - "⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯"; - assert_eq!(detect_droid(screen), AgentState::Idle); - } - - #[test] - fn droid_braille_spinner_detected() { - assert!(has_braille_spinner("⠴ Thinking...")); - assert!(has_braille_spinner(" ⠧ Loading...")); - assert!(has_braille_spinner("text\n⠋ Working\nmore")); - } - - #[test] - fn droid_braille_spinner_no_false_positive() { - assert!(!has_braille_spinner("normal text")); - assert!(!has_braille_spinner("Thinking...")); - assert!(!has_braille_spinner("some ⠴ in middle of text")); - } - - #[test] - fn droid_identified_by_process_name() { - assert_eq!(identify_agent("droid"), Some(Agent::Droid)); - } - - // ---- Amp ---- - - #[test] - fn amp_blocked_waiting_for_approval() { - let screen = "Invoke tool shell_command?\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]\nWaiting for approval..."; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked); - } - - #[test] - fn amp_blocked_run_this_command() { - let screen = "Run this command?\nrg --files\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked); - } - - #[test] - fn amp_blocked_allow_editing_file() { - let screen = "Allow editing file:\nsrc/detect.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked); - } - - #[test] - fn amp_blocked_allow_creating_file() { - let screen = "Allow creating file:\nsrc/new_file.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked); - } - - #[test] - fn amp_working_running_tools() { - let screen = " ✓ Search Map the core runtime architecture\n ⋯ Oracle ▼\n ≈ Running tools... Esc to cancel"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Working); - } - - #[test] - fn amp_idle() { - let screen = " Response complete.\n\n╭─100% of 272k · $1.20─────────────────────────╮\n│ │\n╰───────────────────────~/Projects/herdr (master)╯"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Idle); - } - - #[test] - fn amp_identified_by_process_name() { - assert_eq!(identify_agent("amp"), Some(Agent::Amp)); - assert_eq!(identify_agent("amp-local"), Some(Agent::Amp)); - } - - // ---- Grok ---- - - #[test] - fn grok_blocked_on_permission_prompt() { - let screen = "Show recent commit history for analysis\n\ - git -C /home/can/Projects/herdr log --oneline --decorate -n 12\n\ - Use ← → to choose permission whitelist scope\n\n\ - 1 (○) Always allow: git -C\n\ - 2 (●) Yes, proceed\n\ - 3 (○) No, reject (type to add feedback)\n\n\ - 1/3:select │ ←/→:scope │ Ctrl+o:yolo │ Ctrl+c:cancel"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); - } - - #[test] - fn grok_blocked_wins_over_spinner() { - let screen = "⠹ Run git 30s\nUse ← → to choose permission whitelist scope\nYes, proceed\nNo, reject (type to add feedback)"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); - } - - #[test] - fn grok_choice_words_without_scope_selector_are_not_blocked() { - let screen = "Yes, proceed\nNo, reject (type to add feedback)"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle); - } - - #[test] - fn grok_working_on_waiting_spinner() { - let screen = "⠋ Waiting… 1.8s\nCtrl+c:cancel │ Ctrl+Enter:interject"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); - } - - #[test] - fn grok_working_on_tool_spinner() { - let screen = "⠼ Run git -C /home/can/Projects/herdr log --oneline 1.0s"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); - } - - #[test] - fn grok_idle_after_turn_completed() { - let screen = "yo\n\nTurn completed in 1.7s.\n\n╭────╮\n│ ❯ │\n╰─ gpt-5.4 ─╯"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle); - } - - // ---- Hermes ---- - - #[test] - fn hermes_identified_by_process_name() { - assert_eq!(identify_agent("hermes"), Some(Agent::Hermes)); - assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes)); - } - - #[test] - fn hermes_working_on_interrupt_footer() { - let screen = " (⌐■_■) computing...\n\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏱ 3s\n─────────────────────────────────────────────────────────────────────────────────────────\n⚕ ❯ msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel"; - assert_eq!( - detect_state(Some(Agent::Hermes), screen), - AgentState::Working - ); - } - - #[test] - fn hermes_idle_ignores_stale_initializing_agent_text() { - let screen = "● say exactly READY and stop\nInitializing agent...\n\n╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯"; - assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); - } - - #[test] - fn hermes_blocked_on_dangerous_command_prompt() { - let screen = "╭────────────────────────────────────────────────────────────╮\n│ ⚠️ Dangerous Command │\n│ mkdir -p /tmp/herdr-hermes-block-test/subdir && touch │\n│ ❯ 1. Allow once │\n│ 2. Allow for this session │\n│ 3. Add to permanent allowlist │\n│ 4. Deny │\n│ 5. Show full command │\n╰────────────────────────────────────────────────────────────╯\n ↑/↓ to select, Enter to confirm\n⚠ ❯"; - assert_eq!( - detect_state(Some(Agent::Hermes), screen), - AgentState::Blocked - ); - } - - #[test] - fn hermes_idle_at_prompt_after_response() { - let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯\n─────────────────────────────────────────────────────────────────────────────────────────"; - assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); - } - - #[test] - fn hermes_denied_message_is_idle() { - let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n Command was blocked/denied by the safety layer. I did not retry.\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.4K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏲ 11s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯"; - assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); - } - - // ---- Qodercli ---- - - #[test] - fn qodercli_identified_by_process_name() { - assert_eq!(identify_agent("qodercli"), Some(Agent::Qodercli)); - assert_eq!(identify_agent("qoderclicn"), Some(Agent::Qodercli)); - assert_eq!(identify_agent("qoder"), Some(Agent::Qodercli)); - assert_eq!(identify_agent("qodercn"), Some(Agent::Qodercli)); - } - - #[test] - fn qodercli_blocked_on_confirmation() { - assert_eq!( - detect_qodercli("Waiting for user confirmation...\nAllow\nReject"), - AgentState::Blocked, - ); - } - - #[test] - fn qodercli_waiting_confirmation_without_choices_is_idle() { - assert_eq!( - detect_qodercli("Waiting for user confirmation..."), - AgentState::Idle, - ); - } - - #[test] - fn qodercli_working_on_spinner() { - assert_eq!(detect_qodercli("\u{280B} Thinking..."), AgentState::Working); - } - - #[test] - fn qodercli_idle_on_prompt() { - assert_eq!(detect_qodercli("> "), AgentState::Idle); - } - - #[test] - fn qodercli_idle_when_only_stale_braille_glyph_in_scrollback() { - // A single stray braille character in a previous output line must not - // flip the pane to Working — only an actual spinner *row* should. - let screen = "\ -agent finished a previous task.\n\ -\u{280B}\n\ -> \n"; - assert_eq!(detect_qodercli(screen), AgentState::Idle); - } - - #[test] - fn qodercli_working_on_full_spinner_row() { - // Real spinner row: braille glyph + space + alphabetic phrase. - let screen = "\u{280B} Thinking...\n"; - assert_eq!(detect_qodercli(screen), AgentState::Working); - } - - #[test] - fn qodercli_working_on_esc_to_cancel_hint() { - // The "(esc to cancel, …)" suffix is qodercli's explicit working - // marker. It must trigger Working even if a hook icon replaced the - // spinner glyph in this frame. - let screen = "Thinking... (esc to cancel, 5s)\n"; - assert_eq!(detect_qodercli(screen), AgentState::Working); - } - - #[test] - fn qodercli_idle_when_text_mentions_working_in_prose() { - // The previous heuristic treated the bare word "working" as Working, - // which produced false positives for narrative output (commits, logs, - // Markdown). The pane should remain Idle until a real working signal - // appears. - let screen = "\ -fix: keep working set warm across reloads\n\ -\n\ -> \n"; - assert_eq!(detect_qodercli(screen), AgentState::Idle); - } - - #[test] - fn qodercli_exit_hint_is_not_visible_blocker() { - let screen = "\ -\u{280B} Thinking...\n\ -Press Ctrl+C again to exit.\n"; - let detection = detect_agent(Some(Agent::Qodercli), screen); - - assert!(!detection.visible_blocker); - } - - #[test] - fn qodercli_esc_rewind_is_not_visible_blocker() { - let screen = "Press Esc again to rewind.\n"; - let detection = detect_agent(Some(Agent::Qodercli), screen); - - assert!(!detection.visible_blocker); - } - - #[test] - fn qodercli_blocked_on_permission_required_dialog() { - // qodercli renders this dialog when a tool call needs user approval. - let screen = "\ -Permission Required\n\ -Caller: test\n\ -Command: mkdir -p /root/example\n\ -Allow once or always?\n\ - \u{276F} 1. Allow Once - allow `mkdir` for one\n\ - 2. Always allow `mkdir` for future sessions\n\ - 3. Reject and tell qodercli something\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - #[test] - fn qodercli_blocked_on_permission_required_alone() { - // Even when the prompt copy gets truncated by the viewport, the title - // alone should be enough to flip the pane to blocked. - assert_eq!(detect_qodercli("Permission Required"), AgentState::Blocked,); - } - - #[test] - fn qodercli_blocked_on_askuser_enter_response_placeholder() { - // qodercli's ask-user tool renders an input box with this placeholder - // when waiting for the user to type a response. - let screen = "\ -What kind of project are you working on?\n\ -> \n\ - Enter your response\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - #[test] - fn qodercli_blocked_on_askuser_review_tab() { - // The multi-question/multi-select review tab heading is unique to the - // ask-user dialog and means the agent is parked waiting on user input. - let screen = "\ -Review your answers:\n\ -\n\ -Project type \u{2192} Web app\n\ -Stack \u{2192} (not answered)\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - #[test] - fn qodercli_blocked_on_interactive_shell_waiting() { - // When qodercli spawns an interactive shell, the loading row turns - // into a "Shell awaiting input" hint until the user takes focus. - let screen = "! Shell awaiting input (Tab to focus)\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - #[test] - fn qodercli_blocked_on_askuser_single_choice_dialog() { - // Single-select ask-user has no "Enter your response" placeholder and - // no "Review your answers:" heading. The BaseTabDialog title - // "Asking User" is the only stable signal across every ask-user form. - let screen = "\ -Asking User\n\ -\n\ -Which framework should we use?\n\ - React\n\ - Vue\n\ - Svelte\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - // ---- Helpers ---- - - #[test] - fn content_above_prompt_box_extracts_correctly() { - let screen = "line1\nline2\n──────\n❯ \n──────"; - let above = content_above_prompt_box(screen); - assert!(above.contains("line1")); - assert!(above.contains("line2")); - assert!(!above.contains('❯')); - } - - #[test] - fn content_above_prompt_box_no_box() { - let screen = "just some text\nno borders here"; - let above = content_above_prompt_box(screen); - assert_eq!(above, screen); - } - - #[test] - fn spinner_activity_detected() { - assert!(has_spinner_activity("· Thinking…")); - assert!(has_spinner_activity("✽ Tempering…")); - assert!(has_spinner_activity("✳ Simplifying recompute_tangents…")); - assert!(has_spinner_activity(" ✶ Reading…")); // with leading whitespace - assert!(has_spinner_activity("✻ Pouncing…")); - assert!(has_spinner_activity("✽ Processing…")); - } - - #[test] - fn spinner_activity_not_false_positive() { - assert!(!has_spinner_activity("normal text")); - assert!(!has_spinner_activity("✽ no ellipsis here")); - assert!(!has_spinner_activity("✽ …")); - assert!(!has_spinner_activity("some ✽ in the middle")); - } - - #[test] - fn cursor_spinner_detected() { - assert!(has_cursor_spinner("⬡ Grepping..")); - assert!(has_cursor_spinner("⬢ Reading…")); - assert!(has_cursor_spinner("⠠⠜ Running 5.52k tokens")); - assert!(has_cursor_spinner("⠞ Working 5.62k tokens")); - assert!(has_cursor_spinner("⠛ Grepping 1.2k tokens")); - assert!(has_cursor_spinner("⠛ Analyzing 1.2k tokens")); - } - - #[test] - fn cursor_spinner_not_false_positive() { - assert!(!has_cursor_spinner("normal text")); - assert!(!has_cursor_spinner("some ⬡ in middle")); - assert!(!has_cursor_spinner("⠛ Read notes")); - } - // ---- Process identification (real PTY) ---- #[cfg(target_os = "linux")] @@ -3043,20 +1041,4 @@ Which framework should we use?\n\ // In CI/test environments without a terminal, tpgid is typically -1 let _ = tpgid; } - - #[test] - fn terminal_tail_content_works_with_detection() { - assert_eq!(detect_pi("Working..."), AgentState::Working); - } - - #[test] - fn ansi_colored_content_still_detects_working() { - assert_eq!(detect_pi("\x1b[31mWorking...\x1b[0m"), AgentState::Working); - } - - #[test] - fn visible_claude_prompt_box_is_idle() { - let screen = "Task complete.\n─────────────\n❯ \n─────────────"; - assert_eq!(detect_claude(screen), AgentState::Idle); - } } |
