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"; /// 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, pub state: AgentState, pub source: Option, pub matched_rule: Option, 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, pub fallback_reason: Option, pub evaluated_rules: Vec, pub warning: Option, pub manifest_version: Option, pub cached_remote_version: Option, pub local_override_shadowing_remote: bool, pub remote_update_status: Option, pub remote_update_error: Option, } #[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, pub(crate) cached_remote_version: Option, pub(crate) local_override_shadowing_remote: bool, pub(crate) warning: Option, } pub(crate) fn manifest_summaries() -> Vec { 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, pub regex: Vec, pub line_regex: Vec, 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, source: ManifestSource, warning: Option, cached_remote_version: Option, local_override_shadowing_remote: bool, } #[derive(Debug, Clone)] struct ManifestCache { manifests: Vec<(Agent, Option)>, } #[derive(Debug, Deserialize, Clone)] #[serde(deny_unknown_fields)] pub(crate) struct AgentManifest { id: String, version: Option, min_engine_version: Option, #[serde(rename = "updated_at")] _updated_at: Option, #[serde(default)] aliases: Vec, #[serde(default)] rules: Vec, } #[derive(Debug, Deserialize, Clone)] #[serde(deny_unknown_fields)] struct ManifestRule { id: String, state: Option, #[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, #[serde(default)] any: Vec, #[serde(default, rename = "not")] not_gate: Vec, #[serde(default)] contains: Vec, #[serde(default)] regex: Vec, #[serde(default)] line_regex: Vec, } #[derive(Debug, Deserialize, Clone)] #[serde(deny_unknown_fields)] struct ManifestGate { #[serde(default)] all: Vec, #[serde(default)] any: Vec, #[serde(default, rename = "not")] not_gate: Vec, #[serde(default)] contains: Vec, #[serde(default)] regex: Vec, #[serde(default)] line_regex: Vec, } #[derive(Debug, Clone)] struct CompiledRule { gate: CompiledGate, } #[derive(Debug, Clone)] struct CompiledGate { all: Vec, any: Vec, not_gate: Vec, contains: Vec, regex: Vec, line_regex: Vec, } #[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum ManifestState { Idle, Working, Blocked, Unknown, } impl From 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")), ("devin", include_str!("manifests/devin.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")), ("maki", include_str!("manifests/maki.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> = OnceLock::new(); static MANIFEST_RELOAD_LOCK: OnceLock> = 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 { 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 { MANIFEST_CACHE.get_or_init(|| RwLock::new(build_manifest_cache())) } fn build_manifest_cache() -> ManifestCache { ManifestCache { manifests: Agent::SCREEN_MANIFEST_AGENTS .into_iter() .map(|agent| (agent, load_manifest_uncached(agent))) .collect(), } } fn manifest_summaries_from_cache(cache: &ManifestCache) -> Vec { 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, } } #[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, 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, input, 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, DetectionInput { screen: screen_content, osc_title: "", osc_progress: "", }, 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, input: DetectionInput<'_>, 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(input, &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, context: Option<(LoadedManifest, Vec)>, 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 { 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 { 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, cached_remote_version: Option, local_override_shadowing_remote: bool, ) -> Result { 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, cached_remote_version: Option, 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 { 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 { 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 { 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 { let manifest = toml::from_str::(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 { 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))?; if rule.region.trim().starts_with("top_non_empty_lines(") && manifest .min_engine_version .is_some_and(|version| version < TOP_NON_EMPTY_LINES_ENGINE_VERSION) { return Err(format!( "rule {} uses top_non_empty_lines but min_engine_version is below {}", rule.id, TOP_NON_EMPTY_LINES_ENGINE_VERSION )); } 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" | "osc_title" | "osc_progress" => Ok(()), _ if region_count(trimmed, "bottom_lines").is_some() || region_count(trimmed, "bottom_non_empty_lines").is_some() || top_region_count(trimmed).is_some() => { Ok(()) } _ => Err(trimmed.to_string()), } } fn override_path(agent: Agent) -> Option { Some( crate::config::config_dir() .join("agent-detection") .join(format!("{}.toml", agent_label(agent))), ) } fn remote_update_status(agent: Agent) -> Option { 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, 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 { Ok(CompiledGate { all: gate .all .iter() .map(compile_gate) .collect::>()?, any: gate .any .iter() .map(compile_gate) .collect::>()?, not_gate: gate .not_gate .iter() .map(compile_gate) .collect::>()?, 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::>()?, line_regex: gate .line_regex .iter() .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) .collect::>()?, }) } 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>(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), "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); } if let Some(count) = top_region_count(trimmed) { return top_non_empty_lines(content, count); } "" } } } fn region_count(spec: &str, name: &str) -> Option { spec.strip_prefix(name) .and_then(|rest| rest.strip_prefix('(')) .and_then(|rest| rest.strip_suffix(')')) .and_then(|count| count.parse::().ok()) } const TOP_NON_EMPTY_LINES_ENGINE_VERSION: u32 = 3; const MAX_TOP_REGION_LINE_COUNT: usize = u16::MAX as usize; fn top_region_count(spec: &str) -> Option { let count = spec .strip_prefix("top_non_empty_lines")? .strip_prefix('(')? .strip_suffix(')')?; if count.starts_with('0') || !count.bytes().all(|byte| byte.is_ascii_digit()) { return None; } count .parse::() .ok() .filter(|count| *count <= MAX_TOP_REGION_LINE_COUNT) } 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 top_non_empty_lines(content: &str, count: usize) -> &str { let lines: Vec<&str> = content.lines().collect(); let Some(end_index) = lines .iter() .enumerate() .filter(|(_, line)| !line.trim().is_empty()) .take(count) .last() .map(|(index, _)| index) else { return ""; }; let byte_offset = line_start_offset(content, &lines, end_index + 1); &content[..byte_offset] } 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::(); &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 { 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 { 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::() .min(content.len()) } #[cfg(test)] mod tests;