aboutsummaryrefslogtreecommitdiff
path: root/src/detect
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 15:24:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 15:33:45 +0300
commit36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (patch)
tree4a2d100eef7f26af7feaa93ce661423c3994c54e /src/detect
parent8d13492926829c5cf0ea42c8613344cd65ff59fa (diff)
feat: distribute agent detection manifests
Diffstat (limited to 'src/detect')
-rw-r--r--src/detect/agents/amp.rs46
-rw-r--r--src/detect/agents/antigravity.rs76
-rw-r--r--src/detect/agents/claude_code.rs434
-rw-r--r--src/detect/agents/cline.rs19
-rw-r--r--src/detect/agents/codex.rs258
-rw-r--r--src/detect/agents/cursor.rs79
-rw-r--r--src/detect/agents/droid.rs39
-rw-r--r--src/detect/agents/gemini.rs31
-rw-r--r--src/detect/agents/github_copilot.rs28
-rw-r--r--src/detect/agents/grok.rs37
-rw-r--r--src/detect/agents/hermes.rs30
-rw-r--r--src/detect/agents/kilo.rs13
-rw-r--r--src/detect/agents/kimi.rs97
-rw-r--r--src/detect/agents/kiro.rs60
-rw-r--r--src/detect/agents/mod.rs105
-rw-r--r--src/detect/agents/opencode.rs58
-rw-r--r--src/detect/agents/pi.rs9
-rw-r--r--src/detect/agents/qodercli.rs86
-rw-r--r--src/detect/manifest.rs1411
-rw-r--r--src/detect/manifest/tests.rs498
-rw-r--r--src/detect/manifest_update.rs774
-rw-r--r--src/detect/manifests/amp.toml29
-rw-r--r--src/detect/manifests/antigravity.toml33
-rw-r--r--src/detect/manifests/claude.toml179
-rw-r--r--src/detect/manifests/cline.toml26
-rw-r--r--src/detect/manifests/codex.toml105
-rw-r--r--src/detect/manifests/cursor.toml57
-rw-r--r--src/detect/manifests/droid.toml48
-rw-r--r--src/detect/manifests/gemini.toml25
-rw-r--r--src/detect/manifests/github-copilot.toml30
-rw-r--r--src/detect/manifests/grok.toml28
-rw-r--r--src/detect/manifests/hermes.toml30
-rw-r--r--src/detect/manifests/kilo.toml24
-rw-r--r--src/detect/manifests/kimi.toml77
-rw-r--r--src/detect/manifests/kiro.toml51
-rw-r--r--src/detect/manifests/opencode.toml37
-rw-r--r--src/detect/manifests/pi.toml13
-rw-r--r--src/detect/manifests/qodercli.toml38
-rw-r--r--src/detect/mod.rs2104
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(&current) {
+ 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, &current, 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, &current, 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, &current, 1).unwrap();
+
+ let result = process_agent_manifest(Agent::Codex, &current, 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);
- }
}