aboutsummaryrefslogtreecommitdiff
path: root/src/detect
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 19:23:13 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 19:23:13 +0300
commit82468a0e03360219898ebf3735bdf559182009f8 (patch)
tree0ee5213fe041a539f23e846f9148493528249512 /src/detect
parent36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (diff)
feat: add manifest-based detection engine
Diffstat (limited to 'src/detect')
-rw-r--r--src/detect/manifest.rs68
-rw-r--r--src/detect/manifest/tests.rs204
-rw-r--r--src/detect/manifest_update.rs24
-rw-r--r--src/detect/manifests/claude.toml152
-rw-r--r--src/detect/manifests/codex.toml85
-rw-r--r--src/detect/mod.rs21
6 files changed, 377 insertions, 177 deletions
diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs
index 6f660367..06ed4df6 100644
--- a/src/detect/manifest.rs
+++ b/src/detect/manifest.rs
@@ -13,6 +13,17 @@ use super::{
pub const DEFAULT_KNOWN_AGENT_IDLE_FALLBACK: &str = "default_known_agent_idle_fallback";
+/// Input to the detection engine, carrying the screen snapshot plus any
+/// OSC-derived strings captured from the terminal title / progress sequences.
+/// Pass empty strings for `osc_title` and `osc_progress` when the data is not
+/// available — behavior is identical to the pre-OSC engine in that case.
+#[derive(Debug, Clone, Copy)]
+pub struct DetectionInput<'a> {
+ pub screen: &'a str,
+ pub osc_title: &'a str,
+ pub osc_progress: &'a str,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectionExplain {
pub agent: Option<String>,
@@ -306,18 +317,41 @@ fn manifest_summary_from_loaded(agent: Agent, loaded: LoadedManifest) -> AgentMa
}
}
+#[allow(dead_code)] // shim for existing callers; detect_with_osc is the real path
pub fn detect(agent: Agent, screen_content: &str) -> AgentDetection {
+ detect_with_osc(
+ agent,
+ DetectionInput {
+ screen: screen_content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ )
+}
+
+pub fn detect_with_osc(agent: Agent, input: DetectionInput<'_>) -> 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()
+ evaluate_loaded_manifest(agent, input, loaded, false).into_detection()
}
pub fn explain(agent: Agent, screen_content: &str) -> DetectionExplain {
+ explain_with_input(
+ agent,
+ DetectionInput {
+ screen: screen_content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ )
+}
+
+pub fn explain_with_input(agent: Agent, input: DetectionInput<'_>) -> DetectionExplain {
let Some(loaded) = load_manifest(agent) else {
return fallback_explain(Some(agent), None, true);
};
- evaluate_loaded_manifest(agent, screen_content, loaded, true)
+ evaluate_loaded_manifest(agent, input, loaded, true)
}
pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain {
@@ -350,7 +384,17 @@ 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
+ evaluate_loaded_manifest(
+ agent,
+ DetectionInput {
+ screen: screen_content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ loaded,
+ false,
+ )
+ .skip_state_update
}
impl DetectionExplain {
@@ -367,7 +411,7 @@ impl DetectionExplain {
fn evaluate_loaded_manifest(
agent: Agent,
- screen_content: &str,
+ input: DetectionInput<'_>,
loaded: LoadedManifest,
include_update_status: bool,
) -> DetectionExplain {
@@ -375,7 +419,7 @@ fn evaluate_loaded_manifest(
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 region_text = region(input, &rule.region);
let matched_rule = compiled_rule_matches(compiled_rule, region_text);
evaluated_rules.push(EvaluatedRule {
id: rule.id.clone(),
@@ -1025,7 +1069,9 @@ fn validate_region_name(spec: &str) -> Result<(), String> {
| "prompt_box_body"
| "above_prompt_box"
| "last_non_empty_above_prompt_box"
- | "after_last_horizontal_rule" => Ok(()),
+ | "after_last_horizontal_rule"
+ | "osc_title"
+ | "osc_progress" => Ok(()),
_ if region_count(trimmed, "bottom_lines").is_some()
|| region_count(trimmed, "bottom_non_empty_lines").is_some() =>
{
@@ -1192,8 +1238,16 @@ fn compiled_gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> b
true
}
-fn region<'a>(content: &'a str, spec: &str) -> &'a str {
+fn region<'a>(input: DetectionInput<'a>, spec: &str) -> &'a str {
let trimmed = spec.trim();
+ // OSC regions source from their dedicated fields, not the screen.
+ match trimmed {
+ "osc_title" => return input.osc_title,
+ "osc_progress" => return input.osc_progress,
+ _ => {}
+ }
+ // All other regions operate on the screen content as before.
+ let content = input.screen;
match trimmed {
"whole_recent" => content,
"after_last_prompt_marker" => after_last_prompt_marker(content),
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs
index e5b03c0b..0f425384 100644
--- a/src/detect/manifest/tests.rs
+++ b/src/detect/manifest/tests.rs
@@ -156,7 +156,7 @@ line_regex = ["^exact line$"]
#[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"));
+ write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
let explain = explain(Agent::Codex, "remote-ready");
@@ -165,10 +165,10 @@ fn remote_manifest_loads_between_local_override_and_bundled() {
explain.source,
Some(ManifestSource::Remote { .. })
));
- assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3"));
assert_eq!(
explain.cached_remote_version.as_deref(),
- Some("2026.06.10.1")
+ Some("2026.06.10.3")
);
});
}
@@ -176,7 +176,7 @@ fn remote_manifest_loads_between_local_override_and_bundled() {
#[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"));
+ write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
let explain = explain(Agent::Codex, "ordinary prompt text");
@@ -185,7 +185,7 @@ fn fallback_explain_preserves_active_manifest_version() {
explain.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
);
- assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3"));
assert!(matches!(
explain.source,
Some(ManifestSource::Remote { .. })
@@ -216,7 +216,7 @@ fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() {
#[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_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
write_local_codex(&local_manifest("idle", "local-ready"));
let explain = explain(Agent::Codex, "local-ready");
@@ -226,7 +226,7 @@ fn local_override_shadows_cached_remote_manifest() {
assert!(explain.local_override_shadowing_remote);
assert_eq!(
explain.cached_remote_version.as_deref(),
- Some("2026.06.10.1")
+ Some("2026.06.10.3")
);
});
}
@@ -234,7 +234,7 @@ fn local_override_shadows_cached_remote_manifest() {
#[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_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
write_local_codex("id = ");
let explain = explain(Agent::Codex, "remote-ready");
@@ -251,7 +251,7 @@ fn invalid_local_override_falls_back_to_cached_remote_manifest() {
#[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"));
+ write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "cached-ready"));
let cached = explain(Agent::Codex, "cached-ready");
assert_eq!(cached.state, AgentState::Blocked);
@@ -261,7 +261,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
Some("test")
);
- write_remote_codex_without_reload(&remote_manifest("2026.06.10.2", "working", "new-ready"));
+ write_remote_codex_without_reload(&remote_manifest("2026.06.10.4", "working", "new-ready"));
let unchanged = explain(Agent::Codex, "new-ready");
assert_eq!(unchanged.state, AgentState::Idle);
@@ -271,7 +271,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
);
assert_eq!(
unchanged.cached_remote_version.as_deref(),
- Some("2026.06.10.1")
+ Some("2026.06.10.3")
);
reload_manifests();
@@ -280,7 +280,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
assert_eq!(reloaded.state, AgentState::Working);
assert_eq!(
reloaded.cached_remote_version.as_deref(),
- Some("2026.06.10.2")
+ Some("2026.06.10.4")
);
assert_eq!(
reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()),
@@ -492,7 +492,185 @@ 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)"),
+ region(
+ DetectionInput {
+ screen: content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ "bottom_non_empty_lines(2)"
+ ),
"marker\nnew\n"
);
}
+
+// ---------------------------------------------------------------------------
+// OSC rule tests — exercise the new osc_title / osc_progress regions against
+// the bundled Claude and Codex manifests.
+// ---------------------------------------------------------------------------
+
+fn osc_explain(
+ agent: Agent,
+ screen: &str,
+ osc_title: &str,
+ osc_progress: &str,
+) -> DetectionExplain {
+ explain_with_input(
+ agent,
+ DetectionInput {
+ screen,
+ osc_title,
+ osc_progress,
+ },
+ )
+}
+
+// --- Claude OSC rules ---
+
+#[test]
+fn claude_osc_title_braille_prefix_is_working() {
+ // "⠂" is U+2802, in the braille block U+2800-U+28FF
+ let result = osc_explain(Agent::Claude, "", "⠂ project", "");
+ assert_eq!(result.state, AgentState::Working);
+ assert_eq!(
+ result.matched_rule.as_ref().map(|r| r.id.as_str()),
+ Some("osc_title_working")
+ );
+ assert!(result.visible_working);
+}
+
+#[test]
+fn claude_osc_title_static_prefix_is_idle() {
+ // "✳" is U+2733, static prefix when Claude is not working
+ let result = osc_explain(Agent::Claude, "", "✳ Claude Code", "");
+ assert_eq!(result.state, AgentState::Idle);
+ assert_eq!(
+ result.matched_rule.as_ref().map(|r| r.id.as_str()),
+ Some("osc_title_idle")
+ );
+ assert!(result.visible_idle);
+}
+
+#[test]
+fn claude_osc_progress_4_3_alone_does_not_force_working() {
+ // Claude leaves progress stuck at 4;3 while waiting for permission, so
+ // 4;3 must not be a working signal on its own. With no other evidence it
+ // falls back to idle; blocked screen rules can win when present.
+ let result = osc_explain(Agent::Claude, "", "", "4;3;");
+ assert_eq!(result.state, AgentState::Idle);
+ assert_eq!(
+ result.fallback_reason.as_deref(),
+ Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ );
+ assert!(!result.visible_working);
+}
+
+#[test]
+fn claude_blocker_screen_outranks_stale_osc_progress() {
+ // Regression: progress 4;3 persists during permission prompts. The
+ // blocked form on screen must win because no rule treats 4;3 as working.
+ let blocker_screen =
+ "──────────\n 1. Yes\n 2. No\n\nEnter to select · ↑/↓ to navigate · Esc to cancel\n";
+ let result = osc_explain(Agent::Claude, blocker_screen, "✳ Task title", "4;3;");
+ assert_eq!(result.state, AgentState::Blocked);
+ assert!(result.visible_blocker);
+}
+
+#[test]
+fn claude_osc_progress_4_0_is_idle() {
+ let result = osc_explain(Agent::Claude, "", "", "4;0;");
+ assert_eq!(result.state, AgentState::Idle);
+ assert_eq!(
+ result.matched_rule.as_ref().map(|r| r.id.as_str()),
+ Some("osc_progress_idle")
+ );
+}
+
+#[test]
+fn claude_blocker_screen_outranks_osc_idle_title() {
+ // When the OSC title shows ✳ (idle) but the screen has a bash permission
+ // prompt, the blocked rule at priority 850 beats osc_title_idle at 250.
+ let blocker_screen = "do you want to proceed?\n\
+ bash command: rm -rf /tmp/test\n\
+ ❯ 1. Yes\n 2. No\n\n\
+ Esc to cancel · Tab to amend · ctrl+e to explain\n";
+ let result = osc_explain(Agent::Claude, blocker_screen, "✳ Claude Code", "");
+ assert_eq!(result.state, AgentState::Blocked);
+ assert!(result.visible_blocker);
+}
+
+#[test]
+fn claude_empty_osc_empty_screen_is_idle_fallback() {
+ // No OSC data, no matching screen rule → fallback idle (unchanged V3 behavior)
+ let result = osc_explain(Agent::Claude, "", "", "");
+ assert_eq!(result.state, AgentState::Idle);
+ assert_eq!(
+ result.fallback_reason.as_deref(),
+ Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ );
+ assert!(!result.visible_idle);
+}
+
+// --- Codex OSC rules ---
+
+#[test]
+fn codex_osc_title_braille_spinner_is_working() {
+ // "⠋" is U+280B, in the braille block
+ let result = osc_explain(Agent::Codex, "", "⠋ llm-proxy", "");
+ assert_eq!(result.state, AgentState::Working);
+ assert_eq!(
+ result.matched_rule.as_ref().map(|r| r.id.as_str()),
+ Some("osc_title_working")
+ );
+ assert!(result.visible_working);
+}
+
+#[test]
+fn codex_osc_title_action_required_is_blocked() {
+ let result = osc_explain(Agent::Codex, "", "[ . ] Action Required | llm-proxy", "");
+ assert_eq!(result.state, AgentState::Blocked);
+ assert_eq!(
+ result.matched_rule.as_ref().map(|r| r.id.as_str()),
+ Some("osc_title_blocked")
+ );
+ assert!(result.visible_blocker);
+}
+
+#[test]
+fn codex_osc_title_plain_is_idle() {
+ let result = osc_explain(Agent::Codex, "", "llm-proxy", "");
+ assert_eq!(result.state, AgentState::Idle);
+ assert_eq!(
+ result.matched_rule.as_ref().map(|r| r.id.as_str()),
+ Some("osc_title_idle")
+ );
+ assert!(result.visible_idle);
+}
+
+#[test]
+fn codex_background_terminal_screen_beats_osc_idle() {
+ // Codex doesn't set is_task_running() for background terminals, so title is
+ // plain (idle). The screen rule background_terminal_working at priority 800
+ // beats osc_title_idle at priority 100.
+ let screen = "background terminal running · /ps to view · /stop to close\n";
+ let result = osc_explain(Agent::Codex, screen, "llm-proxy", "");
+ assert_eq!(result.state, AgentState::Working);
+ assert_eq!(
+ result.matched_rule.as_ref().map(|r| r.id.as_str()),
+ Some("background_terminal_working")
+ );
+ assert!(result.visible_working);
+}
+
+#[test]
+fn codex_osc_working_beats_weak_blocker_screen() {
+ // A stale [y/n] on screen triggers weak_blocker at priority 600, but an
+ // active braille spinner in the OSC title is priority 1050 — OSC wins.
+ let screen = "do you want to continue? [y/n]\n";
+ let result = osc_explain(Agent::Codex, screen, "⠋ llm-proxy", "");
+ assert_eq!(result.state, AgentState::Working);
+ assert_eq!(
+ result.matched_rule.as_ref().map(|r| r.id.as_str()),
+ Some("osc_title_working")
+ );
+}
diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs
index 6534b885..f82b4864 100644
--- a/src/detect/manifest_update.rs
+++ b/src/detect/manifest_update.rs
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use super::{agent_label, parse_agent_label, Agent};
-pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 1;
+pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 2;
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;
@@ -446,9 +446,13 @@ fn sync_parent_dir(parent: &Path) -> Result<(), String> {
}
fn directory_sync_unsupported(err: &std::io::Error) -> bool {
+ // PermissionDenied: Windows cannot open directories via `File::open`
+ // (os error 5), so directory sync is effectively unsupported there.
matches!(
err.kind(),
- std::io::ErrorKind::Unsupported | std::io::ErrorKind::InvalidInput
+ std::io::ErrorKind::Unsupported
+ | std::io::ErrorKind::InvalidInput
+ | std::io::ErrorKind::PermissionDenied
)
}
@@ -610,7 +614,7 @@ contains = ["{contains}"]
#[test]
fn process_agent_manifest_commits_newer_manifest_atomically() {
with_state_dir("commit-newer", || {
- let content = remote_manifest("2026.06.10.1", "ready");
+ let content = remote_manifest("2026.06.10.3", "ready");
let commit = process_agent_manifest(Agent::Codex, &content, 1)
.unwrap()
.unwrap();
@@ -618,7 +622,7 @@ contains = ["{contains}"]
assert_eq!(commit.agent, Agent::Codex);
assert_eq!(
commit.version,
- ManifestVersion::parse("2026.06.10.1").unwrap()
+ ManifestVersion::parse("2026.06.10.3").unwrap()
);
assert_eq!(
fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
@@ -648,7 +652,7 @@ path = "codex.toml"
.unwrap();
fs::write(
web_dir.join("codex.toml"),
- remote_manifest("2026.06.10.1", "auto-update-ready"),
+ remote_manifest("2026.06.10.3", "auto-update-ready"),
)
.unwrap();
std::env::set_var(
@@ -689,10 +693,10 @@ path = "codex.toml"
#[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");
+ let current = remote_manifest("2026.06.10.4", "current");
process_agent_manifest(Agent::Codex, &current, 1).unwrap();
- let older = remote_manifest("2026.06.10.1", "older");
+ let older = remote_manifest("2026.06.10.3", "older");
assert!(process_agent_manifest(Agent::Codex, &older, 2).is_err());
assert_eq!(
fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
@@ -704,10 +708,10 @@ path = "codex.toml"
#[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");
+ let current = remote_manifest("2026.06.10.3", "current");
process_agent_manifest(Agent::Codex, &current, 1).unwrap();
- let changed = remote_manifest("2026.06.10.1", "changed");
+ let changed = remote_manifest("2026.06.10.3", "changed");
assert!(process_agent_manifest(Agent::Codex, &changed, 2).is_err());
assert_eq!(
fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
@@ -719,7 +723,7 @@ path = "codex.toml"
#[test]
fn process_agent_manifest_skips_same_version_same_content() {
with_state_dir("skip-same", || {
- let current = remote_manifest("2026.06.10.1", "current");
+ let current = remote_manifest("2026.06.10.3", "current");
process_agent_manifest(Agent::Codex, &current, 1).unwrap();
let result = process_agent_manifest(Agent::Codex, &current, 2).unwrap();
diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml
index 84177af6..7bcecf6f 100644
--- a/src/detect/manifests/claude.toml
+++ b/src/detect/manifests/claude.toml
@@ -1,10 +1,18 @@
id = "claude"
-version = "2026.06.10.1"
-min_engine_version = 1
+version = "2026.06.10.3"
+min_engine_version = 2
updated_at = "2026-06-10T00:00:00Z"
aliases = ["claude-code"]
[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1100
+region = "osc_title"
+visible_working = true
+regex = ['^[\x{2800}-\x{28FF}] ']
+
+[[rules]]
id = "transcript_viewer"
state = "unknown"
priority = 1000
@@ -20,6 +28,29 @@ any = [
]
[[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 = "live_prompt_box"
state = "idle"
priority = 950
@@ -47,27 +78,22 @@ not = [
]
[[rules]]
-id = "live_blocked_form"
+id = "bash_permission_prompt"
state = "blocked"
-priority = 980
-region = "after_last_horizontal_rule"
+priority = 850
+region = "whole_recent"
visible_blocker = true
-contains = ["enter to select", "esc to cancel"]
+contains = ["do you want to proceed?"]
any = [
- { contains = ["tab/arrow keys to navigate"] },
- { contains = ["arrow keys to navigate"] },
- { contains = ["arrows to navigate"] },
- { contains = ["↑/↓ to navigate"] },
- { contains = ["↑↓ to navigate"] },
+ { 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 = "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"
@@ -86,79 +112,6 @@ all = [
]
[[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
@@ -177,3 +130,18 @@ any = [
not = [
{ regex = ['(?m)^\s*❯\s*$'] },
]
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 250
+region = "osc_title"
+visible_idle = true
+regex = ['^\x{2733} ']
+
+[[rules]]
+id = "osc_progress_idle"
+state = "idle"
+priority = 250
+region = "osc_progress"
+regex = ['^4;0']
diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml
index cd09e4b0..8cd42fa3 100644
--- a/src/detect/manifests/codex.toml
+++ b/src/detect/manifests/codex.toml
@@ -1,9 +1,25 @@
id = "codex"
-version = "2026.06.10.1"
-min_engine_version = 1
+version = "2026.06.10.2"
+min_engine_version = 2
updated_at = "2026-06-10T00:00:00Z"
[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1100
+region = "osc_title"
+visible_blocker = true
+contains = ["Action Required"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1050
+region = "osc_title"
+visible_working = true
+regex = ['^[\x{2800}-\x{28FF}] ']
+
+[[rules]]
id = "transcript_viewer"
state = "unknown"
priority = 1000
@@ -29,45 +45,16 @@ any = [
]
[[rules]]
-id = "current_live_working"
+id = "background_terminal_working"
state = "working"
-priority = 710
-region = "current_prompt_block_marker"
+priority = 800
+region = "bottom_non_empty_lines(6)"
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 = ["Waiting for background terminal"] },
{ 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]]
@@ -83,23 +70,13 @@ any = [
]
[[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 \()'] },
+id = "osc_title_idle"
+state = "idle"
+priority = 100
+region = "osc_title"
+visible_idle = true
+regex = ['\S']
+not = [
+ { regex = ['^[\x{2800}-\x{28FF}]'] },
+ { contains = ["Action Required"] },
]
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index 62dc3d05..6a8a25c5 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -193,7 +193,18 @@ pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState {
}
/// Detect state and whether a visible blocker is present on the current screen.
+#[allow(dead_code)] // shim for existing callers; detect_agent_with_osc is the real path
pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetection {
+ detect_agent_with_osc(agent, screen_content, "", "")
+}
+
+/// Detect state using screen content plus OSC title/progress strings.
+pub fn detect_agent_with_osc(
+ agent: Option<Agent>,
+ screen_content: &str,
+ osc_title: &str,
+ osc_progress: &str,
+) -> AgentDetection {
let Some(agent) = agent else {
return AgentDetection {
state: AgentState::Unknown,
@@ -203,7 +214,14 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio
visible_working: false,
};
};
- manifest::detect(agent, screen_content)
+ manifest::detect_with_osc(
+ agent,
+ manifest::DetectionInput {
+ screen: screen_content,
+ osc_title,
+ osc_progress,
+ },
+ )
}
pub fn should_skip_state_update(agent: Option<Agent>, screen_content: &str) -> bool {
@@ -218,6 +236,7 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) ->
| ("herdr:hermes", "hermes")
| ("herdr:opencode", "opencode")
| ("herdr:kilo", "kilo")
+ | ("herdr:kimi", "kimi")
)
}