diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 19:23:13 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 19:23:13 +0300 |
| commit | 82468a0e03360219898ebf3735bdf559182009f8 (patch) | |
| tree | 0ee5213fe041a539f23e846f9148493528249512 /src/detect | |
| parent | 36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (diff) | |
feat: add manifest-based detection engine
Diffstat (limited to 'src/detect')
| -rw-r--r-- | src/detect/manifest.rs | 68 | ||||
| -rw-r--r-- | src/detect/manifest/tests.rs | 204 | ||||
| -rw-r--r-- | src/detect/manifest_update.rs | 24 | ||||
| -rw-r--r-- | src/detect/manifests/claude.toml | 152 | ||||
| -rw-r--r-- | src/detect/manifests/codex.toml | 85 | ||||
| -rw-r--r-- | src/detect/mod.rs | 21 |
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, ¤t, 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, ¤t, 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, ¤t, 1).unwrap(); let result = process_agent_manifest(Agent::Codex, ¤t, 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") ) } |
