diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-19 16:02:57 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-19 16:02:57 +0300 |
| commit | ab33633ceb9e4bea3e292b2371241797d1587d02 (patch) | |
| tree | 7b03faf43e5714782e43d582a00626a6f0d852f1 /crates | |
initial dingger: tmux agent watcher with herdr's detection engine
extract detect-core from herdr's src/detect (engine + 22 manifests,
tests unchanged) and add the dingger binary: tmux capture-pane watcher,
process-tree agent identification, herdr's notification policy, embedded
notification sounds, @agent_state write-back, and catalog-based manifest
refresh. viewer screens are non-observations per the engine contract;
reviewed by a three-model panel (opus 5.5, gpt 6.1 sol, kimi k3) with
all confirmed findings applied as deletions.
Diffstat (limited to 'crates')
35 files changed, 5212 insertions, 0 deletions
diff --git a/crates/detect-core/Cargo.toml b/crates/detect-core/Cargo.toml new file mode 100644 index 0000000..0e2bcc8 --- /dev/null +++ b/crates/detect-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "detect-core" +version = "0.1.0" +edition = "2021" +description = "Agent state detection engine extracted from herdr (Apache-2.0, Copyright herdr contributors)" + +[dependencies] +regex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } diff --git a/crates/detect-core/src/lib.rs b/crates/detect-core/src/lib.rs new file mode 100644 index 0000000..ef6e964 --- /dev/null +++ b/crates/detect-core/src/lib.rs @@ -0,0 +1,277 @@ +//! Agent state detection engine, extracted from herdr's `src/detect/` +//! (Apache-2.0, Copyright herdr contributors). +//! +//! The engine is a pure function over a terminal screen snapshot plus OSC +//! title/progress strings: `detect_agent_with_osc(agent, screen, title, +//! progress) -> AgentDetection`. Which agent a pane is running is decided by +//! the caller; `identify_agent` maps a process name to an [`Agent`]. +//! +//! Detection rules live in declarative TOML manifests (`manifests/`) compiled +//! into the binary. Locally refreshed manifests (stored by `dingger manifests +//! update`) and user overrides under `DINGGER_CONFIG_DIR` shadow the bundled +//! set. + +pub mod manifest; +pub mod manifest_update; + +/// The engine's explain/debug surface lives in `manifest` (DetectionExplain, +/// explain_with_input, agent_state_label); dinggerd itself consumes only +/// `detect_agent_with_osc`, labels, and the manifest paths. +/// The detected state of a terminal pane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum AgentState { + /// Agent finished, prompt visible, nothing happening. + Idle, + /// Agent is actively working/processing. + Working, + /// Agent needs human input and is blocked on a response. + Blocked, + /// Plain shell or unrecognized program. + Unknown, +} + +/// Screen-derived agent state plus confidence metadata used for source arbitration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentDetection { + pub state: AgentState, + /// True when the current screen is an agent-owned viewer that shows + /// transcript/history instead of the live prompt state. Callers must treat + /// such a screen as a non-observation: leave the previously published + /// state standing instead of applying [`Self::state`]. + pub skip_state_update: bool, + /// True when the current screen visibly shows live idle chrome. + pub visible_idle: bool, + /// True when the current screen visibly shows live UI chrome that needs + /// human input. This is stronger than arbitrary prompt-like text in the + /// scrollback and may override a non-blocked integration state. + pub visible_blocker: bool, + /// True when the current screen visibly shows live working chrome. In + /// herdr PTY activity is the normal working authority; dingger is + /// screen-only and consumes [`Self::state`] and [`Self::skip_state_update`] + /// only. + pub visible_working: bool, +} + +/// Which agent we detected running in a pane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Agent { + Pi, + Claude, + Codex, + Gemini, + Cursor, + Devin, + Antigravity, + Cline, + Omp, + Mastracode, + OpenCode, + GithubCopilot, + Kimi, + Kiro, + Droid, + Amp, + Grok, + Hermes, + Kilo, + Qodercli, + Qwen, + Letta, + Maki, + Muse, +} + +impl Agent { + pub const ALL: [Self; 24] = [ + Self::Pi, + Self::Claude, + Self::Codex, + Self::Gemini, + Self::Cursor, + Self::Devin, + Self::Antigravity, + Self::Cline, + Self::Omp, + Self::Mastracode, + Self::OpenCode, + Self::GithubCopilot, + Self::Kimi, + Self::Kiro, + Self::Droid, + Self::Amp, + Self::Grok, + Self::Hermes, + Self::Kilo, + Self::Qodercli, + Self::Qwen, + Self::Letta, + Self::Maki, + Self::Muse, + ]; + + pub const SCREEN_MANIFEST_AGENTS: [Self; 22] = [ + Self::Pi, + Self::Claude, + Self::Codex, + Self::Gemini, + Self::Cursor, + Self::Devin, + Self::Antigravity, + Self::Cline, + Self::OpenCode, + Self::GithubCopilot, + Self::Kimi, + Self::Kiro, + Self::Droid, + Self::Amp, + Self::Grok, + Self::Hermes, + Self::Kilo, + Self::Qodercli, + Self::Qwen, + Self::Letta, + Self::Maki, + Self::Muse, + ]; +} + +pub fn agent_label(agent: Agent) -> &'static str { + match agent { + Agent::Pi => "pi", + Agent::Claude => "claude", + Agent::Codex => "codex", + Agent::Gemini => "gemini", + Agent::Cursor => "cursor", + Agent::Devin => "devin", + Agent::Antigravity => "agy", + Agent::Cline => "cline", + Agent::Omp => "omp", + Agent::Mastracode => "mastracode", + Agent::OpenCode => "opencode", + Agent::GithubCopilot => "copilot", + Agent::Kimi => "kimi", + Agent::Kiro => "kiro", + Agent::Droid => "droid", + Agent::Amp => "amp", + Agent::Grok => "grok", + Agent::Hermes => "hermes", + Agent::Kilo => "kilo", + Agent::Qodercli => "qodercli", + Agent::Qwen => "qwen", + Agent::Letta => "letta", + Agent::Maki => "maki", + Agent::Muse => "muse", + } +} + +pub fn parse_agent_label(agent: &str) -> Option<Agent> { + let name = normalized_agent_lookup_name(agent); + parse_canonical_agent_label(&name).or_else(|| lookup_agent(&name)) +} + +pub(crate) fn parse_canonical_agent_label(label: &str) -> Option<Agent> { + let agent = lookup_agent(label)?; + (agent_label(agent) == label).then_some(agent) +} + +fn lookup_agent(name: &str) -> Option<Agent> { + let name = path_basename(name); + match name { + "pi" => Some(Agent::Pi), + "claude" | "claude-code" => Some(Agent::Claude), + "codex" => Some(Agent::Codex), + "gemini" => Some(Agent::Gemini), + "cursor" | "cursor-agent" => Some(Agent::Cursor), + "devin" | "devin-cli" | "devin cli" => Some(Agent::Devin), + "agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity), + "cline" | ".cline" => Some(Agent::Cline), + "omp" => Some(Agent::Omp), + "mastracode" | "mastra-code" | "mastra code" => Some(Agent::Mastracode), + "opencode" | "opencode2" | "open-code" => Some(Agent::OpenCode), + "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot), + "kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi), + "kiro" | "kiro-cli" => Some(Agent::Kiro), + "droid" => Some(Agent::Droid), + "amp" | "amp-local" => Some(Agent::Amp), + "grok" | "grok-build" => Some(Agent::Grok), + "hermes" | "hermes-agent" => Some(Agent::Hermes), + "kilo" | "kilo-code" | "kilo code" => Some(Agent::Kilo), + "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), + "qwen" | "qwen-code" | "qwen code" => Some(Agent::Qwen), + "letta" | "letta-code" | "letta code" => Some(Agent::Letta), + "maki" => Some(Agent::Maki), + "muse" | "muse-code" | "muse-cli" => Some(Agent::Muse), + _ if is_muse_versioned_binary(name) => Some(Agent::Muse), + _ => None, + } +} + +/// Muse's install-dir launcher script resolves the active release and execs +/// `muse-bin-<version>` (e.g. `muse-bin-0.1.0-R708.1`), so the running +/// process never carries a bare `muse`/`muse-bin` alias. Require a digit +/// immediately after the `muse-bin-` prefix so unrelated binaries such as +/// `muse-binary` or a bare `muse-bin` stay unmatched. +/// Accepts path-qualified `argv0` values by checking only the basename, since +/// the launcher may `exec` with an absolute install-dir path. +fn is_muse_versioned_binary(name: &str) -> bool { + path_basename(name) + .strip_prefix("muse-bin-") + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) +} + +/// Identify which agent is running from the process name. +/// Returns `None` for plain shells or unrecognized programs. +pub fn identify_agent(process_name: &str) -> Option<Agent> { + parse_agent_label(process_name) +} + +pub(crate) fn normalized_agent_lookup_name(name: &str) -> String { + let mut name = name.trim().to_lowercase(); + for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js"] { + if name.ends_with(suffix) { + name.truncate(name.len() - suffix.len()); + break; + } + } + name +} + +pub(crate) fn path_basename(path: &str) -> &str { + path.rsplit(['/', '\\']) + .find(|component| !component.is_empty()) + .unwrap_or(path) +} + +/// Serialize tests that mutate the config/state env vars. +#[cfg(test)] +pub fn test_env_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) +} + +/// Detect state using screen content plus OSC title/progress strings. +/// If `agent` is `None`, returns `Unknown`. +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, + skip_state_update: false, + visible_idle: false, + visible_blocker: false, + visible_working: false, + }; + }; + manifest::detect_with_osc( + agent, + manifest::DetectionInput { + screen: screen_content, + osc_title, + osc_progress, + }, + ) +} diff --git a/crates/detect-core/src/manifest.rs b/crates/detect-core/src/manifest.rs new file mode 100644 index 0000000..dba7520 --- /dev/null +++ b/crates/detect-core/src/manifest.rs @@ -0,0 +1,1546 @@ +use std::{ + path::{Path, PathBuf}, + sync::{Arc, 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<String>, + pub state: AgentState, + pub source: Option<ManifestSource>, + pub matched_rule: Option<MatchedRule>, + pub screen_detection_skipped: bool, + pub visible_idle: bool, + pub visible_blocker: bool, + pub visible_working: bool, + pub skip_state_update: bool, + pub skipped_update_reason: Option<String>, + pub fallback_reason: Option<String>, + pub evaluated_rules: Vec<EvaluatedRule>, + pub warning: Option<String>, + pub manifest_version: Option<String>, + pub cached_remote_version: Option<String>, + pub local_override_shadowing_remote: bool, + pub remote_update_status: Option<String>, + pub remote_update_error: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManifestSource { + Bundled, + Remote { path: PathBuf, version: String }, + Override(PathBuf), +} + +impl ManifestSource { + pub fn label(&self) -> String { + match self { + Self::Bundled => "bundled".to_string(), + Self::Remote { path, .. } => format!("remote:{}", path.display()), + Self::Override(path) => path.display().to_string(), + } + } + + pub fn kind(&self) -> &'static str { + match self { + Self::Bundled => "bundled", + Self::Remote { .. } => "remote", + Self::Override(_) => "local override", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentManifestSummary { + pub agent: Agent, + pub active_source: ManifestSource, + pub active_version: Option<String>, + pub cached_remote_version: Option<String>, + pub local_override_shadowing_remote: bool, + pub warning: Option<String>, +} + +pub fn manifest_summaries() -> Vec<AgentManifestSummary> { + let lock = manifest_cache(); + let guard = match lock.read() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + manifest_summaries_from_cache(&guard) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchedRule { + pub id: String, + pub priority: i32, + pub region: String, + pub state: AgentState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvaluatedRule { + pub id: String, + pub priority: i32, + pub region: String, + pub evidence: RuleEvidence, + pub state: AgentState, + pub matched: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleEvidence { + pub contains: Vec<String>, + pub regex: Vec<String>, + pub line_regex: Vec<String>, + pub all_count: usize, + pub any_count: usize, + pub not_count: usize, + pub region_bytes: usize, + pub region_preview: String, +} + +#[derive(Debug, Clone)] +struct LoadedManifest { + manifest: AgentManifest, + // Keep Regex search caches warm across manifest loads and pane polling. + compiled_rules: Arc<[CompiledRule]>, + source: ManifestSource, + warning: Option<String>, + cached_remote_version: Option<String>, + local_override_shadowing_remote: bool, +} + +#[derive(Debug, Clone)] +struct ManifestCache { + manifests: Vec<(Agent, Option<LoadedManifest>)>, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub(crate) struct AgentManifest { + id: String, + version: Option<ManifestVersion>, + min_engine_version: Option<u32>, + #[serde(rename = "updated_at")] + _updated_at: Option<String>, + #[serde(default)] + aliases: Vec<String>, + #[serde(default)] + rules: Vec<ManifestRule>, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestRule { + id: String, + state: Option<ManifestState>, + #[serde(default)] + priority: i32, + #[serde(default = "default_region")] + region: String, + #[serde(default)] + visible_idle: bool, + #[serde(default)] + visible_blocker: bool, + #[serde(default)] + visible_working: bool, + #[serde(default)] + skip_state_update: bool, + #[serde(default)] + all: Vec<ManifestGate>, + #[serde(default)] + any: Vec<ManifestGate>, + #[serde(default, rename = "not")] + not_gate: Vec<ManifestGate>, + #[serde(default)] + contains: Vec<String>, + #[serde(default)] + regex: Vec<String>, + #[serde(default)] + line_regex: Vec<String>, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestGate { + #[serde(default)] + all: Vec<ManifestGate>, + #[serde(default)] + any: Vec<ManifestGate>, + #[serde(default, rename = "not")] + not_gate: Vec<ManifestGate>, + #[serde(default)] + contains: Vec<String>, + #[serde(default)] + regex: Vec<String>, + #[serde(default)] + line_regex: Vec<String>, +} + +#[derive(Debug, Clone)] +struct CompiledRule { + gate: CompiledGate, +} + +#[derive(Debug, Clone)] +struct CompiledGate { + all: Vec<CompiledGate>, + any: Vec<CompiledGate>, + not_gate: Vec<CompiledGate>, + contains: Vec<String>, + regex: Vec<Regex>, + line_regex: Vec<Regex>, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ManifestState { + Idle, + Working, + Blocked, + Unknown, +} + +impl From<ManifestState> for AgentState { + fn from(value: ManifestState) -> Self { + match value { + ManifestState::Idle => AgentState::Idle, + ManifestState::Working => AgentState::Working, + ManifestState::Blocked => AgentState::Blocked, + ManifestState::Unknown => AgentState::Unknown, + } + } +} + +fn default_region() -> String { + "whole_recent".to_string() +} + +const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ + ("amp", include_str!("manifests/amp.toml")), + ("agy", include_str!("manifests/antigravity.toml")), + ("claude", include_str!("manifests/claude.toml")), + ("cline", include_str!("manifests/cline.toml")), + ("codex", include_str!("manifests/codex.toml")), + ("cursor", include_str!("manifests/cursor.toml")), + ("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")), + ("letta", include_str!("manifests/letta.toml")), + ("maki", include_str!("manifests/maki.toml")), + ("muse", include_str!("manifests/muse.toml")), + ("opencode", include_str!("manifests/opencode.toml")), + ("pi", include_str!("manifests/pi.toml")), + ("qodercli", include_str!("manifests/qodercli.toml")), + ("qwen", include_str!("manifests/qwen.toml")), + ("copilot", include_str!("manifests/github-copilot.toml")), +]; + +static MANIFEST_CACHE: OnceLock<RwLock<ManifestCache>> = OnceLock::new(); +static MANIFEST_RELOAD_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + +const MAX_RULES_PER_MANIFEST: usize = 128; +const MAX_GATE_DEPTH: usize = 8; +const MAX_TOTAL_GATES: usize = 512; +const MAX_MATCHERS_PER_GATE: usize = 32; +const MAX_TOTAL_MATCHERS: usize = 1024; +const MAX_MATCHER_CHARS: usize = 512; + +pub fn reload_manifests() -> Vec<AgentManifestSummary> { + let _reload_guard = MANIFEST_RELOAD_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let cache = build_manifest_cache(); + let summaries = manifest_summaries_from_cache(&cache); + let lock = MANIFEST_CACHE.get_or_init(|| RwLock::new(cache.clone())); + match lock.write() { + Ok(mut guard) => *guard = cache, + Err(poisoned) => *poisoned.into_inner() = cache, + } + summaries +} + +#[allow(dead_code)] // parity with herdr: selective reload after remote manifest refresh +pub(crate) fn reload_manifests_for_agents(agents: &[Agent]) { + if agents.is_empty() { + return; + } + + let _reload_guard = MANIFEST_RELOAD_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let lock = manifest_cache(); + let replacements = Agent::SCREEN_MANIFEST_AGENTS + .into_iter() + .filter(|agent| agents.contains(agent)) + .map(|agent| (agent, load_manifest_uncached(agent))) + .collect::<Vec<_>>(); + let mut cache = match lock.write() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + for (agent, replacement) in replacements { + if let Some((_, loaded)) = cache + .manifests + .iter_mut() + .find(|(cached_agent, _)| *cached_agent == agent) + { + *loaded = replacement; + } + } +} + +fn manifest_cache() -> &'static RwLock<ManifestCache> { + 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<AgentManifestSummary> { + cache + .manifests + .iter() + .filter_map(|(agent, loaded)| { + loaded + .clone() + .map(|loaded| manifest_summary_from_loaded(*agent, loaded)) + }) + .collect() +} + +fn manifest_summary_from_loaded(agent: Agent, loaded: LoadedManifest) -> AgentManifestSummary { + AgentManifestSummary { + agent, + active_version: loaded.manifest.version.as_ref().map(ToString::to_string), + active_source: loaded.source, + cached_remote_version: loaded.cached_remote_version, + local_override_shadowing_remote: loaded.local_override_shadowing_remote, + warning: loaded.warning, + } +} + +#[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) +} + +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.iter()) + { + 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<Agent>, + context: Option<(LoadedManifest, Vec<EvaluatedRule>)>, + include_update_status: bool, +) -> DetectionExplain { + let ( + source, + evaluated_rules, + warning, + manifest_version, + cached_remote_version, + local_override_shadowing_remote, + ) = context + .map(|(loaded, evaluated)| { + ( + Some(loaded.source), + evaluated, + loaded.warning, + loaded.manifest.version.as_ref().map(ToString::to_string), + loaded.cached_remote_version, + loaded.local_override_shadowing_remote, + ) + }) + .unwrap_or((None, Vec::new(), None, None, None, false)); + let known_agent = agent.is_some(); + let remote_update_status = include_update_status + .then(|| agent.and_then(remote_update_status)) + .flatten(); + + DetectionExplain { + agent: agent.map(|agent| agent_label(agent).to_string()), + state: if known_agent { + AgentState::Idle + } else { + AgentState::Unknown + }, + source, + matched_rule: None, + screen_detection_skipped: false, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + skipped_update_reason: None, + fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()), + evaluated_rules, + warning, + manifest_version, + cached_remote_version, + local_override_shadowing_remote, + remote_update_status: remote_update_status + .as_ref() + .map(|status| status.last_result.clone()), + remote_update_error: remote_update_status.and_then(|status| status.last_error), + } +} + +fn load_manifest(agent: Agent) -> Option<LoadedManifest> { + let lock = manifest_cache(); + let guard = match lock.read() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + guard + .manifests + .iter() + .find(|(cached_agent, _)| *cached_agent == agent) + .and_then(|(_, loaded)| loaded.clone()) +} + +fn load_manifest_uncached(agent: Agent) -> Option<LoadedManifest> { + let bundled = bundled_manifest(agent)?; + let mut remote = read_remote_manifest(agent, &bundled); + let cached_remote_version = remote.as_ref().and_then(|loaded| match &loaded.source { + _ if loaded.cached_remote_version.is_some() => loaded.cached_remote_version.clone(), + ManifestSource::Remote { version, .. } => Some(version.clone()), + _ => None, + }); + let Some(path) = override_path(agent) else { + if let Some(loaded) = remote.as_mut() { + loaded.cached_remote_version = cached_remote_version.clone(); + } + return Some(remote.unwrap_or_else(|| { + bundled_loaded_manifest(agent, bundled, None, cached_remote_version, false) + })); + }; + let local_override_shadowing_remote = path.exists() && cached_remote_version.is_some(); + if let Some(loaded) = remote.as_mut() { + loaded.cached_remote_version = cached_remote_version.clone(); + loaded.local_override_shadowing_remote = local_override_shadowing_remote; + } + + if !path.exists() { + return Some(remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + })); + } + + match read_override_manifest(&path) { + Ok(manifest) if manifest_matches_agent(&manifest, agent) => { + match loaded_manifest( + manifest, + ManifestSource::Override(path.clone()), + None, + cached_remote_version.clone(), + local_override_shadowing_remote, + ) { + Ok(loaded) => Some(loaded), + Err(err) => { + let mut loaded = remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + }); + loaded.warning = Some(format!( + "ignored override {} because it could not be compiled: {err}", + path.display() + )); + Some(loaded) + } + } + } + Ok(manifest) => { + let mut loaded = remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + }); + loaded.warning = Some(format!( + "ignored override {} because manifest id {} does not match {}", + path.display(), + manifest.id, + agent_label(agent) + )); + Some(loaded) + } + Err(err) => { + let mut loaded = remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + }); + loaded.warning = Some(format!( + "ignored override {} because it could not be loaded: {err}", + path.display() + )); + Some(loaded) + } + } +} + +fn loaded_manifest( + manifest: AgentManifest, + source: ManifestSource, + warning: Option<String>, + cached_remote_version: Option<String>, + local_override_shadowing_remote: bool, +) -> Result<LoadedManifest, String> { + let compiled_rules = compile_manifest(&manifest)?.into(); + Ok(LoadedManifest { + manifest, + compiled_rules, + source, + warning, + cached_remote_version, + local_override_shadowing_remote, + }) +} + +fn bundled_loaded_manifest( + agent: Agent, + manifest: AgentManifest, + warning: Option<String>, + cached_remote_version: Option<String>, + local_override_shadowing_remote: bool, +) -> LoadedManifest { + loaded_manifest( + manifest, + ManifestSource::Bundled, + warning, + cached_remote_version, + local_override_shadowing_remote, + ) + .unwrap_or_else(|err| { + panic!( + "bundled {} manifest could not be compiled: {err}", + agent_label(agent) + ) + }) +} + +fn bundled_manifest(agent: Agent) -> Option<AgentManifest> { + let id = agent_label(agent); + BUNDLED_MANIFESTS + .iter() + .find(|(manifest_id, _)| *manifest_id == id) + .map(|(_, content)| { + parse_manifest(content) + .unwrap_or_else(|err| panic!("bundled {id} manifest is invalid: {err}")) + }) +} + +fn read_override_manifest(path: &Path) -> Result<AgentManifest, String> { + let content = std::fs::read_to_string(path).map_err(|err| err.to_string())?; + parse_manifest(&content) +} + +fn read_remote_manifest(agent: Agent, bundled: &AgentManifest) -> Option<LoadedManifest> { + let path = super::manifest_update::remote_manifest_path(agent); + if !path.exists() { + return None; + } + match std::fs::read_to_string(&path) + .map_err(|err| err.to_string()) + .and_then(|content| { + parse_remote_manifest_for_agent(agent, &content).map(|parsed| parsed.manifest) + }) { + Ok(manifest) => { + let version = manifest + .version + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "unknown".to_string()); + if let (Some(remote_version), Some(bundled_version)) = + (manifest.version.as_ref(), bundled.version.as_ref()) + { + if remote_version < bundled_version { + return Some(bundled_loaded_manifest( + agent, + bundled.clone(), + Some(format!( + "ignored remote manifest {} because cached version {remote_version} is older than bundled {bundled_version}", + path.display() + )), + Some(remote_version.to_string()), + false, + )); + } + } + match loaded_manifest( + manifest, + ManifestSource::Remote { + path: path.clone(), + version, + }, + None, + None, + false, + ) { + Ok(loaded) => Some(loaded), + Err(err) => Some(bundled_loaded_manifest( + agent, + bundled.clone(), + Some(format!( + "ignored remote manifest {} because it could not be compiled: {err}", + path.display() + )), + None, + false, + )), + } + } + Err(err) => Some(bundled_loaded_manifest( + agent, + bundled.clone(), + Some(format!( + "ignored remote manifest {} because it could not be loaded: {err}", + path.display() + )), + None, + false, + )), + } +} + +pub fn agent_state_label(state: AgentState) -> &'static str { + match state { + AgentState::Idle => "idle", + AgentState::Working => "working", + AgentState::Blocked => "blocked", + AgentState::Unknown => "unknown", + } +} + +pub fn explain_to_json_value(explain: &DetectionExplain) -> serde_json::Value { + let matched_rule = explain.matched_rule.as_ref().map(|rule| { + serde_json::json!({ + "id": rule.id, + "priority": rule.priority, + "region": rule.region, + "state": agent_state_label(rule.state), + }) + }); + let evaluated_rules: Vec<_> = explain + .evaluated_rules + .iter() + .map(|rule| { + serde_json::json!({ + "id": rule.id, + "priority": rule.priority, + "region": rule.region, + "state": agent_state_label(rule.state), + "matched": rule.matched, + "evidence": { + "contains": &rule.evidence.contains, + "regex": &rule.evidence.regex, + "line_regex": &rule.evidence.line_regex, + "all_count": rule.evidence.all_count, + "any_count": rule.evidence.any_count, + "not_count": rule.evidence.not_count, + "region_bytes": rule.evidence.region_bytes, + "region_preview": &rule.evidence.region_preview, + }, + }) + }) + .collect(); + + serde_json::json!({ + "agent": explain.agent, + "state": agent_state_label(explain.state), + "manifest_source": explain.source.as_ref().map(|source| source.label()), + "manifest_version": &explain.manifest_version, + "cached_remote_version": &explain.cached_remote_version, + "local_override_shadowing_remote": explain.local_override_shadowing_remote, + "remote_update_status": &explain.remote_update_status, + "remote_update_error": &explain.remote_update_error, + "matched_rule": matched_rule, + "visible_idle": explain.visible_idle, + "visible_blocker": explain.visible_blocker, + "visible_working": explain.visible_working, + "screen_detection_skipped": explain.screen_detection_skipped, + "skip_state_update": explain.skip_state_update, + "skipped_update_reason": explain.skipped_update_reason, + "fallback_reason": explain.fallback_reason, + "warning": explain.warning, + "evaluated_rules": evaluated_rules, + }) +} + +pub(crate) struct ParsedRemoteManifest { + pub(crate) manifest: AgentManifest, + #[allow(dead_code)] // carried for parity with herdr manifest bookkeeping + pub(crate) version: ManifestVersion, +} + +pub(crate) fn parse_manifest(content: &str) -> Result<AgentManifest, String> { + let manifest = toml::from_str::<AgentManifest>(content).map_err(|err| err.to_string())?; + validate_manifest(&manifest)?; + Ok(manifest) +} + +pub(crate) fn parse_remote_manifest_for_agent( + agent: Agent, + content: &str, +) -> Result<ParsedRemoteManifest, String> { + let manifest = parse_manifest(content)?; + if !manifest_matches_agent(&manifest, agent) { + return Err(format!( + "manifest id {} does not match {}", + manifest.id, + agent_label(agent) + )); + } + let version = manifest + .version + .clone() + .ok_or("remote manifest must include version")?; + let min_engine_version = manifest + .min_engine_version + .ok_or("remote manifest must include min_engine_version")?; + if min_engine_version > super::manifest_update::MANIFEST_ENGINE_VERSION { + return Err(format!( + "manifest requires engine {min_engine_version}, current engine is {}", + super::manifest_update::MANIFEST_ENGINE_VERSION + )); + } + Ok(ParsedRemoteManifest { manifest, version }) +} + +fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> { + if manifest.rules.is_empty() { + return Err("manifest must contain at least one rule".to_string()); + } + if manifest.rules.len() > MAX_RULES_PER_MANIFEST { + return Err(format!( + "manifest contains {} rules, max is {MAX_RULES_PER_MANIFEST}", + manifest.rules.len() + )); + } + + let mut complexity = ManifestComplexity::default(); + for rule in &manifest.rules { + if rule.id.trim().is_empty() { + return Err("manifest rule id must not be empty".to_string()); + } + if rule.skip_state_update { + if rule.state != Some(ManifestState::Unknown) { + return Err(format!( + "rule {} uses skip_state_update without state = \"unknown\"", + rule.id + )); + } + if rule.visible_idle || rule.visible_blocker || rule.visible_working { + return Err(format!( + "rule {} uses skip_state_update with visible state evidence", + rule.id + )); + } + } + validate_region_name(&rule.region) + .map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?; + 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<PathBuf> { + Some( + crate::manifest_update::override_dir() + .join(format!("{}.toml", agent_label(agent))), + ) +} + +struct RemoteUpdateStatus { + last_result: String, + last_error: Option<String>, +} + +/// dingger has no background remote-update machinery; the `dingger manifests +/// update` CLI command stores refreshed manifests in the remote store +/// directory, and `read_remote_manifest` picks them up on the next cache +/// reload. There is no fetch status to report. +fn remote_update_status(_agent: Agent) -> Option<RemoteUpdateStatus> { + None +} + +fn manifest_matches_agent(manifest: &AgentManifest, agent: Agent) -> bool { + let id = agent_label(agent); + manifest.id == id + || manifest.aliases.iter().any(|alias| alias == id) + || parse_agent_label(&manifest.id) == Some(agent) + || manifest + .aliases + .iter() + .any(|alias| parse_agent_label(alias) == Some(agent)) +} + +fn manifest_gate_from_rule(rule: &ManifestRule) -> ManifestGate { + ManifestGate { + all: rule.all.clone(), + any: rule.any.clone(), + not_gate: rule.not_gate.clone(), + contains: rule.contains.clone(), + regex: rule.regex.clone(), + line_regex: rule.line_regex.clone(), + } +} + +fn compile_manifest(manifest: &AgentManifest) -> Result<Vec<CompiledRule>, String> { + manifest + .rules + .iter() + .map(|rule| { + compile_gate(&manifest_gate_from_rule(rule)) + .map(|gate| CompiledRule { gate }) + .map_err(|err| format!("rule {} could not be compiled: {err}", rule.id)) + }) + .collect() +} + +fn compile_gate(gate: &ManifestGate) -> Result<CompiledGate, String> { + Ok(CompiledGate { + all: gate + .all + .iter() + .map(compile_gate) + .collect::<Result<_, _>>()?, + any: gate + .any + .iter() + .map(compile_gate) + .collect::<Result<_, _>>()?, + not_gate: gate + .not_gate + .iter() + .map(compile_gate) + .collect::<Result<_, _>>()?, + contains: gate + .contains + .iter() + .map(|needle| needle.to_lowercase()) + .collect(), + regex: gate + .regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::<Result<_, _>>()?, + line_regex: gate + .line_regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::<Result<_, _>>()?, + }) +} + +fn compiled_rule_matches(rule: &CompiledRule, text: &str) -> bool { + let lower_text = text.to_lowercase(); + compiled_gate_matches(&rule.gate, text, &lower_text) +} + +fn rule_evidence(rule: &ManifestRule, region_text: &str) -> RuleEvidence { + RuleEvidence { + contains: rule.contains.clone(), + regex: rule.regex.clone(), + line_regex: rule.line_regex.clone(), + all_count: rule.all.len(), + any_count: rule.any.len(), + not_count: rule.not_gate.len(), + region_bytes: region_text.len(), + region_preview: bounded_preview(region_text), + } +} + +fn bounded_preview(text: &str) -> String { + const MAX_CHARS: usize = 240; + let mut preview: String = text.chars().take(MAX_CHARS).collect(); + if text.chars().count() > MAX_CHARS { + preview.push_str("..."); + } + preview +} + +fn compiled_gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> bool { + if !gate + .contains + .iter() + .all(|needle| lower_text.contains(needle)) + { + return false; + } + + if !gate.regex.iter().all(|regex| regex.is_match(text)) { + return false; + } + + if !gate + .line_regex + .iter() + .all(|regex| text.lines().any(|line| regex.is_match(line))) + { + return false; + } + + if !gate + .all + .iter() + .all(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + + if !gate.any.is_empty() + && !gate + .any + .iter() + .any(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + + if gate + .not_gate + .iter() + .any(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + + true +} + +fn region<'a>(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<usize> { + spec.strip_prefix(name) + .and_then(|rest| rest.strip_prefix('(')) + .and_then(|rest| rest.strip_suffix(')')) + .and_then(|count| count.parse::<usize>().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<usize> { + 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::<usize>() + .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::<usize>(); + &content[..byte_offset.min(content.len())] +} + +fn whole_recent_without_current_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + if current_codex_prompt_index(&lines).is_some() { + "" + } else { + content + } +} + +fn current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + lines[..prompt_index] + .iter() + .rev() + .find(|line| codex_block_marker_line(line)) + .copied() +} + +fn after_current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + let block_index = lines[..prompt_index] + .iter() + .rposition(|line| codex_block_marker_line(line))?; + Some(slice_from_line_index(content, &lines, block_index)) +} + +fn current_codex_prompt_index(lines: &[&str]) -> Option<usize> { + let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?; + if lines[prompt_index + 1..] + .iter() + .any(|line| codex_block_marker_line(line)) + { + return None; + } + Some(prompt_index) +} + +fn codex_prompt_line(line: &str) -> bool { + line == "›" || line.starts_with("› ") +} + +fn codex_block_marker_line(line: &str) -> bool { + line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓') +} + +fn prompt_box_body(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let top = prompt_box_top_border_index(&lines)?; + let start = line_start_offset(content, &lines, top + 1); + let end_index = lines[top + 1..] + .iter() + .position(|line| is_horizontal_rule(line)) + .map(|relative| top + 1 + relative) + .unwrap_or(lines.len()); + let end = line_start_offset(content, &lines, end_index); + Some(&content[start.min(content.len())..end.min(content.len())]) +} + +fn above_prompt_box(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(top) = prompt_box_top_border_index(&lines) else { + return content; + }; + let end = line_start_offset(content, &lines, top); + &content[..end.min(content.len())] +} + +fn after_last_horizontal_rule(content: &str) -> &str { + let mut last_rule_end = 0usize; + let mut offset = 0usize; + for line in content.lines() { + let next_offset = offset + line.len() + 1; + if is_horizontal_rule(line) { + last_rule_end = next_offset.min(content.len()); + } + offset = next_offset; + } + &content[last_rule_end..] +} + +fn last_non_empty_line(content: &str) -> &str { + content + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("") +} + +fn prompt_box_top_border_index(lines: &[&str]) -> Option<usize> { + let mut border_count = 0; + for index in (0..lines.len()).rev() { + if is_horizontal_rule(lines[index]) { + border_count += 1; + if border_count == 2 { + return Some(index); + } + } + } + None +} + +fn is_horizontal_rule(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + + let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count(); + if rule_chars == 0 { + return false; + } + + let rule_bytes = trimmed + .char_indices() + .nth(rule_chars) + .map(|(index, _)| index) + .unwrap_or(trimmed.len()); + let suffix = trimmed[rule_bytes..].trim_start(); + + suffix.is_empty() || rule_chars >= 3 +} + +fn slice_from_line_index<'a>(content: &'a str, lines: &[&str], index: usize) -> &'a str { + let byte_offset = line_start_offset(content, lines, index); + &content[byte_offset.min(content.len())..] +} + +fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize { + lines[..index.min(lines.len())] + .iter() + .map(|line| line.len() + 1) + .sum::<usize>() + .min(content.len()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/detect-core/src/manifest/tests.rs b/crates/detect-core/src/manifest/tests.rs new file mode 100644 index 0000000..3d9a5a2 --- /dev/null +++ b/crates/detect-core/src/manifest/tests.rs @@ -0,0 +1,830 @@ +use super::*; + +// Codex is only a registry key here; behavior tests supply synthetic rules. +fn remote_manifest(version: &str, state: &str, contains: &str) -> String { + format!( + r#" +id = "codex" +version = "{version}" +min_engine_version = 1 +updated_at = "2026-06-10T12:00:00Z" + +[[rules]] +id = "test" +state = "{state}" +contains = ["{contains}"] +"# + ) +} + +fn local_manifest(state: &str, contains: &str) -> String { + format!( + r#" +id = "codex" + +[[rules]] +id = "test" +state = "{state}" +contains = ["{contains}"] +"# + ) +} + +fn rules_manifest(rules: &str) -> String { + format!( + r#" +id = "codex" + +{rules} +"# + ) +} + +fn with_manifest_dirs<T>(name: &str, f: impl FnOnce() -> T) -> T { + let _guard = crate::test_env_lock().lock().unwrap(); + let old_config = std::env::var_os("XDG_CONFIG_HOME"); + let old_state = std::env::var_os("XDG_STATE_HOME"); + let base = std::env::temp_dir().join(format!( + "herdr-manifest-loader-{name}-{}", + std::process::id() + )); + let config_dir = base.join("config"); + let state_dir = base.join("state"); + let _ = std::fs::remove_dir_all(&base); + std::env::set_var("XDG_CONFIG_HOME", &config_dir); + std::env::set_var("XDG_STATE_HOME", &state_dir); + reload_manifests(); + let result = f(); + match old_config { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match old_state { + Some(value) => std::env::set_var("XDG_STATE_HOME", value), + None => std::env::remove_var("XDG_STATE_HOME"), + } + reload_manifests(); + let _ = std::fs::remove_dir_all(&base); + result +} + +fn write_remote_codex(content: &str) { + let path = crate::manifest_update::remote_manifest_path(Agent::Codex); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + reload_manifests(); +} + +fn write_remote_codex_without_reload(content: &str) { + let path = crate::manifest_update::remote_manifest_path(Agent::Codex); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); +} + +fn write_local_codex(content: &str) { + let path = override_path(Agent::Codex).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + reload_manifests(); +} + +#[test] +fn known_agent_no_match_defaults_to_idle_fallback() { + with_manifest_dirs("no-match", || { + write_local_codex(&local_manifest("working", "active-marker")); + let explain = explain(Agent::Codex, "unmatched-marker"); + + assert_eq!(explain.state, AgentState::Idle); + assert!(!explain.visible_idle); + assert_eq!( + explain.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); + }); +} + +#[test] +fn rule_semantics_apply_gates_priority_and_line_regex() { + with_manifest_dirs("rule-semantics", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "low_contains" +state = "idle" +priority = 1 +contains = ["match"] + +[[rules]] +id = "high_nested_gates" +state = "working" +priority = 10 +contains = ["match"] +all = [ + { any = [{ regex = ["w[io]n"] }, { contains = ["fallback"] }] }, +] +not = [ + { contains = ["blocked"] }, +] + +[[rules]] +id = "line_regex" +state = "blocked" +priority = 20 +line_regex = ["^exact line$"] +"#, + )); + + let high = explain(Agent::Codex, "match win"); + assert_eq!(high.state, AgentState::Working); + assert_eq!( + high.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("high_nested_gates") + ); + + let not_gate = explain(Agent::Codex, "match win blocked"); + assert_eq!(not_gate.state, AgentState::Idle); + assert_eq!( + not_gate.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("low_contains") + ); + + let line = explain(Agent::Codex, "before\nexact line\nafter"); + assert_eq!(line.state, AgentState::Blocked); + assert_eq!( + line.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("line_regex") + ); + }); +} + +#[test] +fn paired_alternatives_require_complete_gates_at_bottom_buffer() { + with_manifest_dirs("paired-alternatives", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "paired" +state = "blocked" +region = "bottom_non_empty_lines(3)" +visible_blocker = true +any = [ + { all = [ + { contains = ["alpha heading"] }, + { regex = ['alpha\s+control\s*\z'] }, + ] }, + { all = [ + { contains = ["beta heading"] }, + { regex = ['beta\s+control\s*\z'] }, + ] }, +] +"#, + )); + for text in [ + "alpha heading\nalpha control", + "beta heading\nbeta\ncontrol", + ] { + let result = explain(Agent::Codex, text); + assert_eq!(result.state, AgentState::Blocked); + assert!(result.visible_blocker); + } + for text in [ + "alpha heading\nbeta control", + "beta heading\nalpha control", + "beta control", + "beta heading", + "beta heading\nbeta control\nnewer content", + "beta heading\nfiller one\nfiller two\nbeta control", + ] { + let result = explain(Agent::Codex, text); + assert!(result.matched_rule.is_none(), "{text}"); + assert!(!result.visible_blocker, "{text}"); + } + }); +} + +#[test] +fn remote_manifest_loads_between_local_override_and_bundled() { + with_manifest_dirs("remote-source", || { + write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready")); + + let explain = explain(Agent::Codex, "remote-ready"); + + assert_eq!(explain.state, AgentState::Blocked); + assert!(matches!( + explain.source, + Some(ManifestSource::Remote { .. }) + )); + assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1")); + assert_eq!( + explain.cached_remote_version.as_deref(), + Some("9999.01.01.1") + ); + }); +} + +#[test] +fn fallback_explain_preserves_active_manifest_version() { + with_manifest_dirs("fallback-version", || { + write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready")); + + let explain = explain(Agent::Codex, "ordinary prompt text"); + + assert_eq!(explain.state, AgentState::Idle); + assert_eq!( + explain.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); + assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1")); + assert!(matches!( + explain.source, + Some(ManifestSource::Remote { .. }) + )); + }); +} + +#[test] +fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() { + with_manifest_dirs("older-remote-bundled-fallback", || { + write_remote_codex(&remote_manifest("2026.06.10.0", "blocked", "remote-ready")); + + let explain = explain(Agent::Codex, "remote-ready"); + + assert!(matches!(explain.source, Some(ManifestSource::Bundled))); + assert_eq!( + explain.cached_remote_version.as_deref(), + Some("2026.06.10.0") + ); + assert!(explain + .warning + .as_deref() + .is_some_and(|warning| warning.contains("older than bundled"))); + }); +} + +#[test] +fn local_override_shadows_cached_remote_manifest() { + with_manifest_dirs("local-shadows-remote", || { + write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready")); + write_local_codex(&local_manifest("idle", "local-ready")); + + let explain = explain(Agent::Codex, "local-ready"); + + assert_eq!(explain.state, AgentState::Idle); + assert!(matches!(explain.source, Some(ManifestSource::Override(_)))); + assert!(explain.local_override_shadowing_remote); + assert_eq!( + explain.cached_remote_version.as_deref(), + Some("9999.01.01.1") + ); + }); +} + +#[test] +fn invalid_local_override_falls_back_to_cached_remote_manifest() { + with_manifest_dirs("invalid-local-remote-fallback", || { + write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready")); + write_local_codex("id = "); + + let explain = explain(Agent::Codex, "remote-ready"); + + assert_eq!(explain.state, AgentState::Blocked); + assert!(matches!( + explain.source, + Some(ManifestSource::Remote { .. }) + )); + assert!(explain.warning.is_some()); + }); +} + +#[test] +fn detection_uses_cached_manifest_until_explicit_reload() { + with_manifest_dirs("cache-boundary", || { + write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "cached-ready")); + + let cached = explain(Agent::Codex, "cached-ready"); + assert_eq!(cached.state, AgentState::Blocked); + assert!(matches!(cached.source, Some(ManifestSource::Remote { .. }))); + assert_eq!( + cached.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("test") + ); + + write_remote_codex_without_reload(&remote_manifest("9999.01.01.2", "working", "new-ready")); + + let unchanged = explain(Agent::Codex, "new-ready"); + assert_eq!(unchanged.state, AgentState::Idle); + assert_eq!( + unchanged.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); + assert_eq!( + unchanged.cached_remote_version.as_deref(), + Some("9999.01.01.1") + ); + + reload_manifests(); + + let reloaded = explain(Agent::Codex, "new-ready"); + assert_eq!(reloaded.state, AgentState::Working); + assert_eq!( + reloaded.cached_remote_version.as_deref(), + Some("9999.01.01.2") + ); + assert_eq!( + reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("test") + ); + }); +} + +#[test] +fn compiled_rules_are_shared_until_manifest_reload() { + with_manifest_dirs("shared-compiled-rules", || { + write_remote_codex(&format!( + "{}\nregex = ['^cached-[a-z]+$']\n", + remote_manifest("9999.01.01.1", "blocked", "cached-ready") + )); + let first = load_manifest(Agent::Codex).unwrap(); + let second = load_manifest(Agent::Codex).unwrap(); + assert!(!first.compiled_rules.is_empty()); + assert_eq!( + first.compiled_rules.as_ptr(), + second.compiled_rules.as_ptr(), + "cached loads must retain the same compiled rules and regex search caches" + ); + + write_remote_codex_without_reload(&format!( + "{}\nregex = ['^new-[a-z]+$']\n", + remote_manifest("9999.01.01.2", "working", "new-ready") + )); + let unchanged = load_manifest(Agent::Codex).unwrap(); + assert_eq!( + first.compiled_rules.as_ptr(), + unchanged.compiled_rules.as_ptr() + ); + + reload_manifests_for_agents(&[Agent::Codex]); + let reloaded = load_manifest(Agent::Codex).unwrap(); + let shared_reload = load_manifest(Agent::Codex).unwrap(); + assert_ne!( + first.compiled_rules.as_ptr(), + reloaded.compiled_rules.as_ptr() + ); + assert_eq!( + reloaded.compiled_rules.as_ptr(), + shared_reload.compiled_rules.as_ptr() + ); + assert!(compiled_rule_matches( + &first.compiled_rules[0], + "cached-ready" + )); + assert!(!compiled_rule_matches( + &first.compiled_rules[0], + "new-ready" + )); + assert_eq!( + explain(Agent::Codex, "new-ready").state, + AgentState::Working + ); + + std::thread::scope(|scope| { + for _ in 0..4 { + let reloaded = &reloaded; + scope.spawn(move || { + let loaded = load_manifest(Agent::Codex).unwrap(); + assert_eq!( + loaded.compiled_rules.as_ptr(), + reloaded.compiled_rules.as_ptr() + ); + for _ in 0..8 { + assert_eq!(detect(Agent::Codex, "new-ready").state, AgentState::Working); + } + }); + } + }); + }); +} + +#[test] +fn osc_regions_use_separate_inputs_and_share_rule_priority() { + with_manifest_dirs("osc-regions", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "screen" +state = "idle" +priority = 10 +region = "whole_recent" +visible_idle = true +contains = ["screen-marker"] + +[[rules]] +id = "title" +state = "working" +priority = 20 +region = "osc_title" +visible_working = true +regex = ['^title-marker$'] + +[[rules]] +id = "progress" +state = "blocked" +priority = 30 +region = "osc_progress" +visible_blocker = true +regex = ['^progress-marker$'] +"#, + )); + for (screen, title, progress, state, rule) in [ + ("screen-marker", "", "", AgentState::Idle, "screen"), + ( + "screen-marker", + "title-marker", + "", + AgentState::Working, + "title", + ), + ( + "screen-marker", + "title-marker", + "progress-marker", + AgentState::Blocked, + "progress", + ), + ( + "screen-marker title-marker progress-marker", + "", + "", + AgentState::Idle, + "screen", + ), + ] { + let input = DetectionInput { + screen, + osc_title: title, + osc_progress: progress, + }; + let result = explain_with_input(Agent::Codex, input); + assert_eq!(result.state, state); + assert_eq!( + result + .matched_rule + .as_ref() + .map(|matched| matched.id.as_str()), + Some(rule) + ); + let detection = + crate::detect_agent_with_osc(Some(Agent::Codex), screen, title, progress); + assert_eq!(detection.state, state); + assert_eq!(detection.visible_idle, state == AgentState::Idle); + assert_eq!(detection.visible_working, state == AgentState::Working); + assert_eq!(detection.visible_blocker, state == AgentState::Blocked); + } + let swapped = explain_with_input( + Agent::Codex, + DetectionInput { + screen: "", + osc_title: "progress-marker", + osc_progress: "title-marker", + }, + ); + assert!(swapped.matched_rule.is_none()); + }); +} + +#[test] +fn skip_rule_suppresses_state_update_without_visible_state_evidence() { + with_manifest_dirs("skip-rule", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "activity" +state = "working" +priority = 10 +visible_working = true +contains = ["activity-marker"] + +[[rules]] +id = "overlay" +state = "unknown" +priority = 20 +skip_state_update = true +contains = ["overlay-marker"] +"#, + )); + let screen = "activity-marker overlay-marker"; + let result = explain(Agent::Codex, screen); + assert_eq!(result.state, AgentState::Unknown); + assert!(result.skip_state_update); + assert_eq!( + result.skipped_update_reason.as_deref(), + Some("matched_rule:overlay") + ); + assert!(!result.visible_idle); + assert!(!result.visible_working); + assert!(!result.visible_blocker); + assert!(detect(Agent::Codex, screen).skip_state_update); + }); +} + +#[test] +fn screen_regions_extract_structure_without_classifying_agent_state() { + for (screen, spec, expected) in [ + ("old\n\nnew\n", "bottom_lines(2)", "\nnew\n"), + ( + "before\n› input\nafter\n", + "after_last_prompt_marker", + "after\n", + ), + ( + "before\n› input\nafter\n", + "before_current_prompt_marker", + "before\n", + ), + ( + "before\n› input\nafter\n", + "whole_recent_without_current_prompt_marker", + "", + ), + ( + "no marker\n", + "whole_recent_without_current_prompt_marker", + "no marker\n", + ), + ( + "• old\n■ latest\n› input\n", + "current_prompt_block_marker", + "■ latest", + ), + ( + "• old\n■ latest\n› input\n", + "after_current_prompt_block_marker", + "■ latest\n› input\n", + ), + ("› old\n• new\n", "current_prompt_block_marker", ""), + ( + "above\n\n───\nbody\n───\nfooter\n", + "above_prompt_box", + "above\n\n", + ), + ( + "above\n\n───\nbody\n───\nfooter\n", + "last_non_empty_above_prompt_box", + "above", + ), + ( + "above\n───\nbody\n───\nfooter\n", + "prompt_box_body", + "body\n", + ), + ( + "above\n───\nbody\n───\nfooter\n", + "after_last_horizontal_rule", + "footer\n", + ), + ] { + assert_eq!( + region( + DetectionInput { + screen, + osc_title: "", + osc_progress: "" + }, + spec + ), + expected, + "region={spec}" + ); + } +} + +#[test] +fn all_bundled_manifests_parse_and_validate() { + for agent in Agent::SCREEN_MANIFEST_AGENTS { + assert!( + bundled_manifest(agent).is_some(), + "missing bundled manifest for {}", + agent_label(agent) + ); + } +} + +#[test] +fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() { + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "typo" +state = "working" +contain = ["Working"] +"# + ) + .is_err()); + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "empty" +state = "working" +"# + ) + .is_err()); + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_region" +state = "working" +region = "after_last_promt_marker" +contains = ["Working"] +"# + ) + .is_err()); + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_regex" +state = "working" +regex = ["["] +"# + ) + .is_err()); + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_nested_regex" +state = "working" +any = [{ line_regex = ["["] }] +"# + ) + .is_err()); +} + +#[test] +fn manifest_validation_keeps_skip_rules_neutral() { + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_skip_state" +state = "idle" +skip_state_update = true +contains = ["menu"] +"# + ) + .is_err()); + assert!(parse_manifest( + r#" +id = "codex" + +[[rules]] +id = "bad_skip_visible" +state = "unknown" +skip_state_update = true +visible_blocker = true +contains = ["menu"] +"# + ) + .is_err()); +} + +#[test] +fn manifest_validation_rejects_excessive_rule_count() { + let mut manifest = String::from( + r#" +id = "codex" +"#, + ); + for index in 0..129 { + manifest.push_str(&format!( + r#" +[[rules]] +id = "rule_{index}" +state = "idle" +contains = ["ready"] +"# + )); + } + assert!(parse_manifest(&manifest).is_err()); +} + +#[test] +fn manifest_validation_rejects_excessive_gate_depth() { + let manifest = r#" +id = "codex" + +[[rules]] +id = "deep" +state = "idle" +contains = ["ready"] +all = [ + { contains = ["1"], all = [ + { contains = ["2"], all = [ + { contains = ["3"], all = [ + { contains = ["4"], all = [ + { contains = ["5"], all = [ + { contains = ["6"], all = [ + { contains = ["7"], all = [ + { contains = ["8"], all = [ + { contains = ["9"] }, + ] }, + ] }, + ] }, + ] }, + ] }, + ] }, + ] }, + ] }, +] +"#; + assert!(parse_manifest(manifest).is_err()); +} + +#[test] +fn manifest_validation_rejects_excessive_matchers() { + let matchers = (0..33) + .map(|index| format!(r#""m{index}""#)) + .collect::<Vec<_>>() + .join(", "); + let manifest = format!( + r#" +id = "codex" + +[[rules]] +id = "many" +state = "idle" +contains = [{matchers}] +"# + ); + assert!(parse_manifest(&manifest).is_err()); +} + +#[test] +fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() { + let content = "marker\nold\n\nmiddle\nmarker\nnew\n"; + assert_eq!( + region( + DetectionInput { + screen: content, + osc_title: "", + osc_progress: "" + }, + "bottom_non_empty_lines(2)" + ), + "marker\nnew\n" + ); +} + +#[test] +fn top_non_empty_lines_uses_top_occurrence_for_repeated_text() { + let content = "\nmarker\nold\n\nmiddle\nmarker\nnew\n"; + assert_eq!( + region( + DetectionInput { + screen: content, + osc_title: "", + osc_progress: "" + }, + "top_non_empty_lines(2)" + ), + "\nmarker\nold\n" + ); +} + +#[test] +fn top_non_empty_lines_requires_a_canonical_positive_bounded_count() { + let name = "top_non_empty_lines"; + assert!(validate_region_name(&format!("{name}(1)")).is_ok()); + assert!(validate_region_name(&format!("{name}({})", u16::MAX)).is_ok()); + for count in ["0", "01", "+1", "65536", "999999999999999999999999"] { + assert!( + validate_region_name(&format!("{name}({count})")).is_err(), + "{name} accepted invalid count {count}" + ); + } +} + +#[test] +fn top_non_empty_lines_requires_engine_three_when_declared() { + let manifest = r#" +id = "codex" +version = "1" +min_engine_version = 2 + +[[rules]] +id = "background" +state = "working" +region = " top_non_empty_lines(1) " +contains = ["active"] +"#; + assert!(parse_manifest(manifest).is_err()); +} diff --git a/crates/detect-core/src/manifest_update.rs b/crates/detect-core/src/manifest_update.rs new file mode 100644 index 0000000..1bea8a1 --- /dev/null +++ b/crates/detect-core/src/manifest_update.rs @@ -0,0 +1,156 @@ +//! Compatibility shim for code extracted from herdr's `src/detect/`. +//! +//! herdr's `manifest_update.rs` handled remote catalog fetching, version +//! comparison, and on-disk manifest storage. dingger keeps the version type, +//! the engine version contract, and the local storage paths; remote fetching +//! is an explicit CLI action that populates the remote store directory. + +use std::path::PathBuf; + +use crate::{agent_label, Agent}; + +/// Engine contract version declared by the manifest schema in use. Remote +/// manifests with a higher `min_engine_version` are rejected. +pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 3; + +pub(crate) fn config_dir() -> PathBuf { + if let Some(dir) = std::env::var_os("DINGGER_CONFIG_DIR") { + return PathBuf::from(dir); + } + if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") { + return PathBuf::from(dir).join("dingger"); + } + dirs_home() + .map(|home| home.join(".config").join("dingger")) + .unwrap_or_else(|| PathBuf::from(".dingger")) +} + +pub(crate) fn state_dir() -> PathBuf { + if let Some(dir) = std::env::var_os("DINGGER_STATE_DIR") { + return PathBuf::from(dir); + } + if let Some(dir) = std::env::var_os("XDG_STATE_HOME") { + return PathBuf::from(dir).join("dingger"); + } + dirs_home() + .map(|home| home.join(".local").join("state").join("dingger")) + .unwrap_or_else(|| PathBuf::from(".dingger")) +} + +fn dirs_home() -> Option<PathBuf> { + std::env::var_os("HOME").map(PathBuf::from) +} + +/// Directory where remotely refreshed manifests are stored by +/// `dingger manifests update`. +pub fn remote_store_dir() -> PathBuf { + state_dir().join("agent-detection") +} + +pub fn remote_manifest_path(agent: Agent) -> PathBuf { + remote_store_dir().join(format!("{}.toml", agent_label(agent))) +} + +/// Directory for local override manifests that shadow bundled and remote ones. +pub(crate) fn override_dir() -> PathBuf { + config_dir().join("agent-detection") +} + +#[derive(Debug, Clone)] +pub(crate) struct ManifestVersion(String); + +impl ManifestVersion { + pub(crate) fn parse(value: &str) -> Result<Self, String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("version must not be empty".to_string()); + } + for segment in trimmed.split('.') { + if segment.is_empty() { + return Err(format!("version {trimmed:?} contains an empty segment")); + } + if !segment.chars().all(|ch| ch.is_ascii_digit()) { + return Err(format!("version {trimmed:?} must be dotted numeric")); + } + segment + .parse::<u64>() + .map_err(|_| format!("version {trimmed:?} contains an oversized segment"))?; + } + Ok(Self(trimmed.to_string())) + } +} + +impl std::fmt::Display for ManifestVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for ManifestVersion { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +impl serde::Serialize for ManifestVersion { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl Ord for ManifestVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + use std::cmp::Ordering; + let mut left = self.0.split('.'); + let mut right = other.0.split('.'); + + loop { + match (left.next(), right.next()) { + (Some(left), Some(right)) => { + let left = left.parse::<u64>().unwrap_or(0); + let right = right.parse::<u64>().unwrap_or(0); + match left.cmp(&right) { + Ordering::Equal => {} + ordering => return ordering, + } + } + (Some(left), None) => { + let left = left.parse::<u64>().unwrap_or(0); + if left == 0 { + continue; + } + return Ordering::Greater; + } + (None, Some(right)) => { + let right = right.parse::<u64>().unwrap_or(0); + if right == 0 { + continue; + } + return Ordering::Less; + } + (None, None) => return Ordering::Equal, + } + } + } +} + +impl PartialOrd for ManifestVersion { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + +impl PartialEq for ManifestVersion { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == std::cmp::Ordering::Equal + } +} + +impl Eq for ManifestVersion {} diff --git a/crates/detect-core/src/manifests/amp.toml b/crates/detect-core/src/manifests/amp.toml new file mode 100644 index 0000000..1fc5dfa --- /dev/null +++ b/crates/detect-core/src/manifests/amp.toml @@ -0,0 +1,65 @@ +id = "amp" +version = "2026.07.09.1" +min_engine_version = 2 +updated_at = "2026-07-09T00:00:00Z" +aliases = ["amp-local"] + +[[rules]] +id = "osc_title_plugin_confirmation_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +contains = ["Plugin confirmation needed"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['^[\x{2800}-\x{28FF}] '] + +[[rules]] +id = "approval_footer" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for approval"] }, + { contains = ["invoke tool"] }, + { contains = ["run this command?"] }, + { contains = ["allow editing file:"] }, + { contains = ["allow creating file:"] }, + { contains = ["confirm tool call"] }, + { contains = ["approve"], any = [{ contains = ["allow all for this session"] }, { contains = ["allow all for every session"] }, { contains = ["allow file for every session"] }, { contains = ["deny with feedback"] }] }, +] + +[[rules]] +id = "status_footer_working" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i)^\s*╰\s+\S+\s+(thinking|streaming|running tools|waiting)\s+─'] + +[[rules]] +id = "esc_cancel_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 50 +region = "osc_title" +visible_idle = true +contains = [" - amp - "] +not = [ + { regex = ['^[\x{2800}-\x{28FF}] '] }, + { contains = ["Plugin confirmation needed"] }, +] diff --git a/crates/detect-core/src/manifests/antigravity.toml b/crates/detect-core/src/manifests/antigravity.toml new file mode 100644 index 0000000..5daa8de --- /dev/null +++ b/crates/detect-core/src/manifests/antigravity.toml @@ -0,0 +1,80 @@ +id = "agy" +version = "2026.10.05.1" +min_engine_version = 1 +updated_at = "2026-10-05T00:00:00Z" +aliases = ["antigravity", "antigravity-cli"] + +# Evidence: Antigravity CLI 1.2.17 live pane reads, plus reporter captures +# from 1.1.28 through 1.2.16. +# +# Dialogs replace the prompt box and end with a key-hint row directly above +# the "esc to cancel" footer: +# permission: "↑/↓ Navigate · tab Amend · ctrl+g edit/expand command" +# "↑/↓ Navigate · tab Amend · f full diff" +# question: "↑/↓ Navigate · enter Select · esc Skip" +# trust: "↑/↓ Navigate · enter Confirm" +# slash menu: "↑/↓ Navigate · enter Select · tab Complete" (idle) +# Some captures hold only the two footer rows, so dialog rules must not +# require the question text. +# +# Working turns draw a braille spinner followed by either an -ing word or a +# thought summary above the prompt box, and the footer reads "esc to cancel" +# directly under the prompt box's bottom border. The idle footer reads +# "? for shortcuts". +# +# Background tasks and subagents show "· N task(s)" in the footer and a task +# panel under the prompt box. Once the agent is back at its prompt they do +# not count as working. + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = ["navigate", "tab amend"] + +[[rules]] +id = "question_prompt" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = ["navigate", "esc skip"] + +[[rules]] +id = "trust_prompt" +state = "blocked" +priority = 280 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["do you trust the contents of this project?", "yes, i trust this folder", "enter confirm"] + +# Pre-1.1.28 permission dialog. +[[rules]] +id = "legacy_permission_prompt" +state = "blocked" +priority = 270 +region = "whole_recent" +visible_blocker = true +contains = ["requesting permission for:"] +any = [ + { contains = ["do you want to proceed?"] }, + { contains = ["tab amend", "edit command"] }, +] + +[[rules]] +id = "esc_cancel_footer_working" +state = "working" +priority = 110 +region = "bottom_non_empty_lines(2)" +visible_working = true +regex = ['(?m)^\s*─{3,}\s*esc to cancel\b'] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 100 +region = "bottom_non_empty_lines(12)" +visible_working = true +line_regex = ['^\s*[\u2801-\u28FF]+\s+\S'] diff --git a/crates/detect-core/src/manifests/claude.toml b/crates/detect-core/src/manifests/claude.toml new file mode 100644 index 0000000..0c7bdcf --- /dev/null +++ b/crates/detect-core/src/manifests/claude.toml @@ -0,0 +1,230 @@ +id = "claude" +version = "2026.09.11.1" +min_engine_version = 2 +updated_at = "2026-09-11T00:00:00Z" +aliases = ["claude-code"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1100 +region = "osc_title" +visible_working = true +# Braille covers <= 2.1.227; half-circles are the 2.1.228 busy spinner. +regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D3}] '] + +[[rules]] +id = "live_turn_working" +state = "working" +priority = 970 +region = "bottom_non_empty_lines(12)" +visible_working = true +any = [ + { line_regex = ['^\s*[⏸⏵].*esc to interrupt(?:\s|·|$)'] }, + { line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2733}\x{2736}\x{273B}\x{273D}]\s+\S.*…(?:\s+\(\d+[smh](?:\s|·)|\s*$)'] }, +] + +[[rules]] +id = "background_agents_working" +state = "working" +priority = 965 +region = "last_non_empty_above_prompt_box" +visible_working = true +line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}]\s+Waiting for [1-9]\d* background agents? to finish\s*$'] + +[[rules]] +id = "background_mcp_task_working" +state = "working" +priority = 965 +region = "bottom_non_empty_lines(12)" +visible_working = true +# Claude renders activity summaries at column zero; wrapped continuations are indented. +# Keeping that shape prevents user prompt text from impersonating this signal. +regex = ['(?m)^[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}][ \t]+\S[^\n]*?(?:\n[ \t]+[^\n]*?){0,3}·(?:[ \t]+|\n[ \t]*)[1-9]\d*(?:[ \t]+|\n[ \t]*)MCP(?:[ \t]+|\n[ \t]*)tasks?(?:[ \t]+|\n[ \t]*)still(?:[ \t]+|\n[ \t]*)running[ \t]*$'] +not = [ + { contains = ["do you want to proceed?"] }, + { contains = ["esc to cancel"] }, + { contains = ["waiting for permission"] }, + { contains = ["do you want to allow this connection?"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, +] + +[[rules]] +id = "btw_overlay_working" +state = "working" +priority = 975 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = [ + '^\s*/btw(?:\s|$)', + '(?i)esc to close\s*$', +] + +[[rules]] +id = "transcript_viewer" +state = "unknown" +priority = 1000 +region = "bottom_non_empty_lines(3)" +skip_state_update = true +contains = ["showing detailed transcript"] +any = [ + { contains = ["ctrl+o", "to toggle"] }, + { contains = ["ctrl+e", "show all"] }, + { contains = ["ctrl+e", "collapse"] }, + { contains = ["↑↓ scroll"] }, + { contains = ["? for shortcuts"] }, +] + +[[rules]] +id = "live_blocked_form" +state = "blocked" +priority = 980 +region = "after_last_horizontal_rule" +visible_blocker = true +contains = ["esc to cancel"] +any = [ + { contains = ["enter to confirm"] }, + { contains = ["enter to select"], 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 = "mcp_elicitation_prompt" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true +# MCP elicitation dialogs (elicitation/create) show Accept/Decline controls +# with an "Esc to cancel" footer but no Enter hint, so live_blocked_form +# cannot see them (issue #3283). Gate on the invariant header line, the +# Accept/Decline control line, and the cancel footer. +contains = ["esc to cancel"] +line_regex = ['(?i)^\s*MCP server ["\x{201C}].+["\x{201D}] requests your input\s*$'] +all = [ + { any = [ + { line_regex = ['^\s*\x{276F}?\s*Accept\b'] }, + { line_regex = ['^\s*\x{276F}?\s*Decline\b'] }, + ] }, +] + +[[rules]] +id = "live_prompt_box" +state = "idle" +priority = 950 +region = "prompt_box_body" +visible_idle = true +line_regex = ['^\s*❯'] +not = [ + { contains = ["enter to select"] }, + { contains = ["esc to cancel"] }, + { contains = ["tab/arrow keys"] }, + { contains = ["arrow keys to navigate"] }, + { contains = ["↑/↓ to navigate"] }, +] + +[[rules]] +id = "model_picker_menu" +state = "unknown" +priority = 900 +region = "whole_recent" +skip_state_update = true +contains = ["select model", "enter to set as default", "esc to cancel"] +not = [ + { contains = ["do you want to proceed?"] }, + { contains = ["enter to select"] }, +] + +[[rules]] +id = "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"] }, +] +# Claude marks the selected option with "❯", so every option branch has to allow +# that prefix. Numbered branches that omit it only match options the cursor has +# moved away from, which left the resting layout below unmatched here (#2650): +# ❯ 1. Yes / 2. Yes, and don't ask again for: <cmd> / 3. No +# Cover both the two-option and "don't ask again" three-option shapes so this +# rule, not the narrower generic_permission_prompt, claims Bash approvals. +all = [ + { any = [ + { line_regex = ['(?i)^\s*❯?\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*2\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*2\.\s*no\b'] }, + { line_regex = ['(?i)^\s*❯?\s*3\.\s*no\b'] }, + ] }, +] + +[[rules]] +id = "generic_permission_prompt" +state = "blocked" +priority = 840 +region = "after_last_horizontal_rule" +visible_blocker = true +contains = ["do you want to proceed?", "esc to cancel"] +all = [ + { any = [ + { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*2\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*2\.\s*no\b'] }, + { line_regex = ['(?i)^\s*3\.\s*no\b'] }, + ] }, +] + +[[rules]] +id = "legacy_no_prompt_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +any = [ + { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["waiting for permission"] }, + { contains = ["do you want to allow this connection?"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, + { contains = ["do you want to proceed?", "esc to cancel"] }, + { contains = ["review your answers"] }, + { contains = ["skip interview and plan immediately"] }, +] +not = [ + { regex = ['(?m)^\s*❯\s*$'] }, +] + +[[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/crates/detect-core/src/manifests/cline.toml b/crates/detect-core/src/manifests/cline.toml new file mode 100644 index 0000000..2d31408 --- /dev/null +++ b/crates/detect-core/src/manifests/cline.toml @@ -0,0 +1,63 @@ +id = "cline" +version = "2026.09.11.1" +min_engine_version = 1 +updated_at = "2026-09-11T00:00:00Z" + +[[rules]] +id = "tool_permission" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["let cline use this tool"] }, + { contains = ["[act mode]", "execute command?", "yes"] }, + { contains = ["[act mode]", "use this tool?", "yes"] }, + { contains = ["[plan mode]", "execute command?", "yes"] }, + { contains = ["[plan mode]", "use this tool?", "yes"] }, +] + +[[rules]] +id = "inline_tool_permission" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(16)" +visible_blocker = true +contains = ["Cline needs permission", "Approve tool call?", "[y] Approve", "[n] Deny"] + +[[rules]] +id = "inline_question" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(24)" +visible_blocker = true +line_regex = ['^\s*Cline is asking a question\s*$', '^\s*>\s+\S'] +contains = ["(Tab)", "Shift+Tab"] + +[[rules]] +id = "active_turn" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(20)" +visible_working = true +any = [ + { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+\S'] }, + { contains = ["Thinking... (esc to cancel)"] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(12)" +visible_idle = true +line_regex = ['^\s*❯(?:\s.*)?$'] +contains = ["─", "(Tab)", "Shift+Tab"] + +[[rules]] +id = "default_cline_working" +state = "working" +priority = -10 +region = "whole_recent" +visible_working = true +regex = ['(?s).+'] diff --git a/crates/detect-core/src/manifests/codex.toml b/crates/detect-core/src/manifests/codex.toml new file mode 100644 index 0000000..d35820c --- /dev/null +++ b/crates/detect-core/src/manifests/codex.toml @@ -0,0 +1,129 @@ +id = "codex" +version = "2026.10.11.1" +min_engine_version = 3 +updated_at = "2026-10-11T00: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 = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "transcript_viewer" +state = "unknown" +priority = 1000 +region = "after_last_prompt_marker" +skip_state_update = true +contains = ["↑/↓ to scroll", "pgup/pgdn to", "home/end to jump", "q to quit"] +any = [ + { contains = ["esc to edit prev"] }, + { contains = ["esc/← to edit prev"] }, +] + +[[rules]] +id = "trust_directory" +state = "blocked" +priority = 950 +region = "top_non_empty_lines(20)" +visible_blocker = true +all = [ + { any = [ + { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, + { contains = ["Folder access"] }, + ] }, + { any = [ + { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, + { all = [ + { contains = ["Trust this folder?", "Codex can read, edit, and run files here"] }, + { any = [ + { contains = ["Trust and continue"] }, + { contains = ["enter continue"] }, + ] }, + ] }, + ] }, +] + +[[rules]] +id = "startup_update" +state = "blocked" +priority = 950 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +# Keep the legacy dialog; the shared picker (0.159+) has a different heading +# and footer. Choices can scroll out of a short viewport, but both gates must +# remain visible. The passive update history notice has neither picker footer. +any = [ + { all = [ + { contains = ["Update available!", "Update now"] }, + { regex = ['Skip\s+until\s+next\s+version', 'Press enter to continue\s*\z'] }, + ] }, + { all = [ + { regex = ['(?m)^\s*Update\s+available\s+·'] }, + { regex = ['enter\s+continue\s+·\s+esc\s+skip\s*\z'] }, + ] }, +] + +[[rules]] +id = "live_strong_blocker" +state = "blocked" +priority = 900 +region = "after_last_prompt_marker" +visible_blocker = true +any = [ + { contains = ["press enter to confirm or esc to cancel"] }, + { contains = ["enter to submit answer"] }, + { contains = ["enter to submit all"] }, + { contains = ["allow command?"] }, + { contains = ["All Results", "Filesystem Only", "Plugins"] }, +] + +[[rules]] +id = "weak_blocker" +state = "blocked" +priority = 600 +region = "whole_recent_without_current_prompt_marker" +# Sparkles can replace the space after ›. A later response marker makes that prompt stale. +not = [{ regex = ['(?m)^›[⠁⠂⠄⠈⠐⠠⡀⢀][^\n]*(?:\n(?:[^•■✗✓\n][^\n]*)?)*\z'] }] +any = [ + { contains = ["[y/n]"] }, + { contains = ["yes (y)"] }, + { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, + { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] }, +] + +[[rules]] +id = "screen_working_fallback" +state = "working" +priority = 500 +region = "before_current_prompt_marker" +visible_working = true +# Support animated and reduced-motion status, including dynamic activity labels. +# The interrupt hint can be remapped, unbound, or hidden, and queued inputs can +# sit below the status. Require the live timer suffix with no later response. +any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }] +regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z'] +# A failed reconnect keeps its final elapsed timer but is no longer working. +not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['\S'] +not = [ + { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, + { contains = ["Action Required"] }, +] diff --git a/crates/detect-core/src/manifests/cursor.toml b/crates/detect-core/src/manifests/cursor.toml new file mode 100644 index 0000000..ee03e6d --- /dev/null +++ b/crates/detect-core/src/manifests/cursor.toml @@ -0,0 +1,57 @@ +id = "cursor" +version = "2026.08.03.1" +min_engine_version = 1 +updated_at = "2026-08-03T01:08:04Z" +aliases = ["cursor-agent"] + +[[rules]] +id = "write_file_approval" +state = "blocked" +priority = 320 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["write to this file?", "proceed (y)"] +any = [ + { contains = ["reject & propose changes"] }, + { contains = ["esc or n or p"] }, + { contains = ["add write("] }, +] + +[[rules]] +id = "approval_prompt" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for approval", "run this command?"], any = [{ contains = ["run (once) (y)"] }, { contains = ["skip (esc or n)"] }] }, + { contains = ["(y) (enter)"] }, + { line_regex = ['(?i)^\s*allow .*\(y\)'] }, + { contains = ["keep (n)"] }, + { contains = ["skip (esc or n)"] }, + { line_regex = ['(?i)^\s*(?:→\s*)?run .*\(y\)'] }, +] + +[[rules]] +id = "stop_hint_working" +state = "working" +priority = 100 +region = "bottom_non_empty_lines(6)" +visible_working = true +contains = ["ctrl+c to stop"] + +[[rules]] +id = "background_task_status_working" +state = "working" +priority = 95 +region = "bottom_non_empty_lines(5)" +visible_working = true +line_regex = ['(?i)\b[1-9][0-9]*\s+background\s+tasks?\b'] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 90 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(⬡|⬢|[\u2800-\u28FF]+)\s+\p{Alphabetic}+\w*ing\b'] diff --git a/crates/detect-core/src/manifests/devin.toml b/crates/detect-core/src/manifests/devin.toml new file mode 100644 index 0000000..c9564f7 --- /dev/null +++ b/crates/detect-core/src/manifests/devin.toml @@ -0,0 +1,86 @@ +id = "devin" +version = "2026.06.15.1" +min_engine_version = 1 +updated_at = "2026-06-15T00:00:00Z" +aliases = ["devin-cli", "devin cli"] + +[[rules]] +id = "workspace_trust_prompt" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = [ + "do you trust the authors of this directory?", + "with untrusted content.", + "yes, trust ", +] + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["approve once", "select", "confirm", "esc cancel"] + +[[rules]] +id = "running_tools_footer" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["running tools", "esc to interrupt"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "guide_while_working" +state = "working" +priority = 190 +region = "bottom_non_empty_lines(6)" +visible_working = true +contains = ["guide devin while it works"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "tool_reading_timeout" +state = "working" +priority = 180 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["reading shell ", "timeout:"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "welcome_prompt_footer" +state = "idle" +priority = 120 +region = "bottom_non_empty_lines(8)" +visible_idle = true +contains = ["ask devin to build", "features, fix bugs", "your code"] +line_regex = ['^\s*❭ Ask Devin to build'] +not = [ + { contains = ["approve once", "esc cancel"] }, + { contains = ["running tools", "esc to interrupt"] }, + { contains = ["guide devin while it works"] }, +] + +[[rules]] +id = "live_prompt_footer" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(6)" +visible_idle = true +contains = ["context:"] +line_regex = ['^\s*❭'] +not = [ + { contains = ["approve once", "esc cancel"] }, + { contains = ["running tools", "esc to interrupt"] }, + { contains = ["guide devin while it works"] }, +] diff --git a/crates/detect-core/src/manifests/droid.toml b/crates/detect-core/src/manifests/droid.toml new file mode 100644 index 0000000..c41d71b --- /dev/null +++ b/crates/detect-core/src/manifests/droid.toml @@ -0,0 +1,48 @@ +id = "droid" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "execute_selection_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +contains = ["enter to select", "esc to cancel"] +any = [ + { contains = ["↑↓ to navigate"] }, + { contains = ["use ↑↓ to navigate"] }, +] +all = [ + { any = [{ contains = ["> yes, allow"] }, { contains = ["> no, cancel"] }] }, +] + +[[rules]] +id = "selection_menu_blocker" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["enter select", "esc cancel"] +any = [ + { contains = ["↑/↓ navigate"] }, + { contains = ["↑↓ navigate"] }, +] + +[[rules]] +id = "spinner_stop_working" +state = "working" +priority = 110 +region = "whole_recent" +visible_working = true +contains = ["esc to stop"] +line_regex = ['^\s*[\u2800-\u28FF]'] + +[[rules]] +id = "stop_hint_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to stop"] diff --git a/crates/detect-core/src/manifests/gemini.toml b/crates/detect-core/src/manifests/gemini.toml new file mode 100644 index 0000000..9d7a28e --- /dev/null +++ b/crates/detect-core/src/manifests/gemini.toml @@ -0,0 +1,25 @@ +id = "gemini" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" + +[[rules]] +id = "apply_or_allow_change" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["│ Apply this change"] }, + { contains = ["│ Allow execution"] }, + { all = [{ contains = ["yes"] }, { any = [{ contains = ["waiting for user confirmation"] }, { contains = ["│ Do you want to proceed"] }, { contains = ["do you want to proceed?"] }] }] }, + { line_regex = ['(?i)^\s*❯.*(yes|allow)'] }, +] + +[[rules]] +id = "esc_cancel_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc to cancel"] diff --git a/crates/detect-core/src/manifests/github-copilot.toml b/crates/detect-core/src/manifests/github-copilot.toml new file mode 100644 index 0000000..57fafdd --- /dev/null +++ b/crates/detect-core/src/manifests/github-copilot.toml @@ -0,0 +1,45 @@ +id = "copilot" +version = "2026.08.29.1" +min_engine_version = 1 +updated_at = "2026-08-29T00:00:00Z" +aliases = ["github-copilot", "ghcs"] + +[[rules]] +id = "selection_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +all = [ + { any = [ + { contains = ["esc to cancel"] }, + { contains = ["esc cancel"] }, + ] }, + { any = [ + { contains = ["enter to select"] }, + { contains = ["enter to confirm"] }, + { contains = ["enter to submit"] }, + { contains = ["enter accept"] }, + ] }, +] + +[[rules]] +id = "background_agents_working" +state = "working" +priority = 110 +region = "bottom_non_empty_lines(6)" +visible_working = true +line_regex = ['^\s*◎\s+Waiting for background agents(?:\s|·|$)'] + +[[rules]] +id = "working_cancel_hint" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["esc to cancel"] }, + { contains = ["esc cancel"] }, + { contains = ["esc again to cancel"] }, + { contains = ["esc interrupt"] } +] diff --git a/crates/detect-core/src/manifests/grok.toml b/crates/detect-core/src/manifests/grok.toml new file mode 100644 index 0000000..bc37bc4 --- /dev/null +++ b/crates/detect-core/src/manifests/grok.toml @@ -0,0 +1,161 @@ +id = "grok" +version = "2026.10.05.1" +min_engine_version = 3 +updated_at = "2026-10-05T00:00:00Z" +aliases = ["grok-build"] + +# Evidence: Grok Build 0.2.101 source and 1.0.34 live pane reads. +# +# Grok emits OSC 0 titles by default. Idle is "grok" or +# "<session> - grok". During a turn, the configured title gains a braille +# spinner and activity text. Permission prompts add "⚠ Action Required"; +# that prefix blinks while the terminal is unfocused, so visible blocker +# rules outrank the title spinner rule. +# +# Grok also emits OSC 9;4 progress on supported terminals. Herdr retains the +# payload after "9;": "4;1;-1" while busy and "4;0;0" when idle. +# +# Working turns render one live status line directly above the prompt box: +# "⠧ Waiting on subagent… 2.8s 13s ⇣29.7k [stop]" +# "⠴ Explore /tmp/… + 1 more… 5.6s 19s ⇣29.7k [stop]" +# with a braille spinner and a trailing [stop] chip, plus an Esc:cancel +# footer hint. Permission prompts and ask-user-question dialogs replace the +# spinner with "◆" and draw a "┃"-guttered option list: +# "┃ 2 (○) Yes, proceed" +# "┃ z (○) Type your answer here" +# with footer hints "1/3:select │ Ctrl+o:yolo │ Ctrl+c:cancel" (permission) +# or "Esc:unselect │ Tab:scrollback │ Shift+x:dismiss" (question dialog). +# Idle footers end with "Ctrl+.:shortcuts" and never contain "Esc:cancel". +# The startup splash draws its logo with braille characters, so working +# rules must anchor on the [stop] chip, not on a bare spinner glyph. + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +contains = ["Action Required"] + +[[rules]] +id = "option_dialog_blocked" +state = "blocked" +priority = 1200 +region = "whole_recent" +visible_blocker = true +line_regex = ['^\s*┃\s+[0-9a-z]+\s+\([●○]\)\s'] + +[[rules]] +id = "permission_hints_blocked" +state = "blocked" +priority = 1190 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = [":select", "ctrl+o:yolo", "ctrl+c:cancel"] + +[[rules]] +id = "question_dialog_hints_blocked" +state = "blocked" +priority = 1185 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = ["tab:scrollback", "shift+x:dismiss"] + +# Pre-0.2.x permission UI kept for older Grok Build releases. +[[rules]] +id = "permission_scope_selector" +state = "blocked" +priority = 1180 +region = "whole_recent" +visible_blocker = true +contains = ["yes, proceed", "no, reject"] +any = [ + { contains = ["use ← → to choose permission whitelist scope"] }, + { contains = ["←/→:scope"] }, +] + +# Background commands, monitors, and subagents left running after Grok returns +# to its prompt do not count as working, matching Claude and agy (#1217, #4735). + +[[rules]] +id = "osc_progress_working" +state = "working" +priority = 1150 +region = "osc_progress" +visible_working = true +regex = ['^4;1;-1$'] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 1100 +region = "osc_title" +visible_idle = true +regex = ['(?:^| - )grok$'] +not = [ + { regex = ['[\x{2800}-\x{28FF}]'] }, +] + +# Title items are configurable; omitting "grok" does not imply activity. +# Require a spinner, with OSC progress covering titles that omit it. +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1000 +region = "osc_title" +visible_working = true +regex = ['(?:^|\s)[\x{2801}-\x{28FF}](?:\s|$)'] + +[[rules]] +id = "osc_progress_idle" +state = "idle" +priority = 950 +region = "osc_progress" +visible_idle = true +regex = ['^4;0;0$'] + +# Visible activity outranks idle OSC signals: disabling title updates can +# leave the startup "grok" title unchanged throughout a turn. +[[rules]] +id = "spinner_status_working" +state = "working" +priority = 1160 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$'] + +[[rules]] +id = "esc_cancel_hints_working" +state = "working" +priority = 1155 +region = "bottom_non_empty_lines(2)" +visible_working = true +contains = ["ctrl+.:shortcuts"] +any = [ + { contains = ["esc:cancel"] }, + { contains = ["ctrl+c:cancel"] }, +] + +# Pre-0.2.x working chrome kept for older Grok Build releases. +[[rules]] +id = "waiting_tool_working" +state = "working" +priority = 1140 +region = "whole_recent" +visible_working = true +any = [ + { all = [{ contains = ["ctrl+c:cancel", "ctrl+enter:interject"] }, { contains = ["waiting"] }] }, + { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+(Run|Read|Search|List)\b'] }, +] + +[[rules]] +id = "prompt_hints_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(2)" +visible_idle = true +contains = ["ctrl+.:shortcuts"] +not = [ + { contains = ["esc:cancel"] }, + { contains = ["ctrl+c:cancel"] }, +] diff --git a/crates/detect-core/src/manifests/hermes.toml b/crates/detect-core/src/manifests/hermes.toml new file mode 100644 index 0000000..1754218 --- /dev/null +++ b/crates/detect-core/src/manifests/hermes.toml @@ -0,0 +1,102 @@ +id = "hermes" +version = "2026.07.24.1" +min_engine_version = 2 +updated_at = "2026-07-24T19:12:54Z" +aliases = ["hermes-agent"] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +regex = ['^⚠[\u{fe0e}\u{fe0f}]?(?:\s|$)'] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['^⏳[\u{fe0e}\u{fe0f}]?(?:\s|$)'] + +[[rules]] +id = "dangerous_command_approval" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["dangerous"] }, + { contains = ["approval"] }, + { contains = ["allow once", "deny"] }, + { line_regex = ['(?i)^\s*[▸>]?\s*1\.\s*allow'] }, +] +all = [ + { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] }, +] + +[[rules]] +id = "clarification_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["hermes needs your"] }, + { line_regex = ['^\s*ask\s+\S'] }, + { contains = ["type your answer"] }, +] +all = [ + { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["enter send"] }, { contains = ["press enter"] }, { contains = ["↑/↓ select"] }, { contains = ["↑/↓ to select"] }, { contains = ["other (type"] }] }, +] + +[[rules]] +id = "credential_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["sudo password"] }, + { contains = ["skill setup"] }, + { contains = ["🔑", "for "] }, +] + +[[rules]] +id = "confirmation_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +all = [ + { any = [{ contains = ["approve once", "cancel"] }, { contains = ["start a new session", "keep going"] }] }, + { any = [{ contains = ["enter to confirm"] }, { contains = ["enter confirm"] }, { contains = ["type 1/2/3"] }, { contains = ["y/n quick"] }] }, +] + +[[rules]] +id = "interrupt_status_working" +state = "working" +priority = 950 +region = "bottom_non_empty_lines(5)" +visible_working = true +any = [ + { contains = ["msg=interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, +] + +[[rules]] +id = "classic_cancel_working" +state = "working" +priority = 500 +region = "bottom_non_empty_lines(5)" +visible_working = true +contains = ["ctrl+c cancel"] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['^✓[\u{fe0e}\u{fe0f}]?(?:\s|$)'] diff --git a/crates/detect-core/src/manifests/kilo.toml b/crates/detect-core/src/manifests/kilo.toml new file mode 100644 index 0000000..4ef004e --- /dev/null +++ b/crates/detect-core/src/manifests/kilo.toml @@ -0,0 +1,24 @@ +id = "kilo" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["kilo-code", "kilo code", "herdr:kilo"] + +[[rules]] +id = "opencode_permission" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["△ Permission required"] }, + { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, +] + +[[rules]] +id = "esc_interrupt_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["esc interrupt"] diff --git a/crates/detect-core/src/manifests/kimi.toml b/crates/detect-core/src/manifests/kimi.toml new file mode 100644 index 0000000..b4d0100 --- /dev/null +++ b/crates/detect-core/src/manifests/kimi.toml @@ -0,0 +1,77 @@ +id = "kimi" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["kimi-code", "kimi code"] + +[[rules]] +id = "current_approval_panel" +state = "blocked" +priority = 400 +region = "whole_recent" +visible_blocker = true +contains = ["↵ confirm"] +any = [ + { contains = ["run this command?"] }, + { contains = ["write this file?"] }, + { contains = ["apply these edits?"] }, + { contains = ["stop this task?"] }, + { contains = ["ready to build with this plan?"] }, + { line_regex = ['(?i)^\s*▶?\s*approve .*\?$'] }, +] +all = [ + { contains = [" choose"] }, + { any = [{ contains = ["approve"] }, { contains = ["reject"] }, { contains = ["revise"] }] }, +] + +[[rules]] +id = "question_panel" +state = "blocked" +priority = 390 +region = "whole_recent" +visible_blocker = true +contains = ["↑↓ select", "esc cancel"] +line_regex = ['^\s*question\s*$', '^\s*\? '] +any = [ + { contains = ["↵ choose"] }, + { contains = ["↵ toggle"] }, + { contains = ["↵ save"] }, +] + +[[rules]] +id = "legacy_approval_panel" +state = "blocked" +priority = 300 +region = "whole_recent" +contains = ["requesting approval", "reject"] +any = [ + { contains = ["approve once"] }, + { contains = ["approve for this session"] }, +] +all = [ + { any = [{ contains = ["1/2/3/4 choose"] }, { contains = ["↵ confirm"] }] }, +] + +[[rules]] +id = "background_agent_status_working" +state = "working" +priority = 120 +region = "bottom_non_empty_lines(3)" +visible_working = true +line_regex = ['(?i)\bkimi[-\w.]*\s+thinking\b.*\[[1-9][0-9]*\s+agents?\s+running\]'] + +[[rules]] +id = "moon_spinner_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*(🌕|🌖|🌗|🌘|🌑|🌒|🌓|🌔)\s*$'] + +[[rules]] +id = "braille_spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +line_regex = ['(?i)^\s*[\u2800-\u28FF]+\s*(thinking\.\.\.|working\.\.\.|using )'] diff --git a/crates/detect-core/src/manifests/kiro.toml b/crates/detect-core/src/manifests/kiro.toml new file mode 100644 index 0000000..9d50f93 --- /dev/null +++ b/crates/detect-core/src/manifests/kiro.toml @@ -0,0 +1,97 @@ +id = "kiro" +version = "2026.09.19.1" +min_engine_version = 2 +updated_at = "2026-09-19T00:00:00Z" +aliases = ["kiro-cli"] + +[[rules]] +id = "live_prompt_idle" +state = "idle" +priority = 1100 +region = "bottom_non_empty_lines(4)" +visible_idle = true +line_regex = ['(?i)^\s*[>›]\s*ask a question or describe a task(?:\s+(?:enter|↵))?\s*$'] + +[[rules]] +id = "tool_approval" +state = "blocked" +priority = 1050 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +any = [ + { all = [ + { regex = ['(?is)(?:^|\n)\s*esc\s+to\s+close\b(?:[^\n]*\bto\s+navigate\b[^\n]*|.*\bto\s+navigate\b.*\bto\s+select\b.*\btab\s+to\s+edit|[^\n]*\benter\s+to\s+see\s+more\s+options)\s*\z'] }, + { any = [ + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:allow|always allow|deny|always deny)\s*$', + '(?i)^\s*(?:[>❯]\s*)?allow\s*$', + '(?i)^\s*(?:[>❯]\s*)?always allow\s*$', + '(?i)^\s*(?:[>❯]\s*)?deny\s*$', + '(?i)^\s*(?:[>❯]\s*)?always deny\s*$', + ] }, + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:yes, single permission|trust, always allow in this session|no \(tab to edit\))\s*$', + '(?i)^\s*(?:[>❯]\s*)?yes, single permission\s*$', + '(?i)^\s*(?:[>❯]\s*)?trust, always allow in this session\s*$', + '(?i)^\s*(?:[>❯]\s*)?no \(tab to edit\)\s*$', + ] }, + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:trust\b.*|entire tool)\s*$', + '(?i)^\s*(?:[>❯]\s*)?(?:trust )?entire tool(?:\s+\([^\n]*\))?(?:\s+(?:session|workspace|always))?\s*$', + ] }, + ] }, + ] }, +] + +[[rules]] +id = "tool_approval_edit" +state = "blocked" +priority = 1045 +region = "whole_recent" +visible_blocker = true +regex = ['(?im)(?:^|\n)[ \t]*[-─]+[ \t]*\n[^\n]*requires\s+approval\s*[·.]\s*modify\s+request[ \t]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*?[ \t]*[>›][ \t]*[^\n]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*[ \t]*[-─]+[ \t]*\n[ \t]*esc[ \t]+to[ \t]+close[ \t]*\n?\z'] + +[[rules]] +id = "crew_approval" +state = "blocked" +priority = 1040 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = [ + "tool approval", + "approve all pending", + "configure individually (agent monitor)", + "exit (cancel subagents)", +] + +[[rules]] +id = "question_panel" +state = "blocked" +priority = 1030 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["to navigate", "to submit", "esc to cancel"] + +[[rules]] +id = "live_working_footer" +state = "working" +priority = 950 +region = "bottom_non_empty_lines(4)" +visible_working = true +contains = ["kiro is working", "type to steer", "ctrl+s to queue"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?i)^[◐◓◑◒/|\\-]\s+kiro:'] + +[[rules]] +id = "osc_progress_working" +state = "working" +priority = 890 +region = "osc_progress" +visible_working = true +regex = ['^4;3;?$'] diff --git a/crates/detect-core/src/manifests/letta.toml b/crates/detect-core/src/manifests/letta.toml new file mode 100644 index 0000000..e27e2aa --- /dev/null +++ b/crates/detect-core/src/manifests/letta.toml @@ -0,0 +1,95 @@ +id = "letta" +version = "2026.08.24.1" +min_engine_version = 3 +updated_at = "2026-08-24T00:00:00Z" +aliases = ["letta-code", "letta code"] + +# Letta keeps completed tool rows and spinners in the transcript. A standalone +# screen spinner is not working evidence. Prefer its live OSC title/progress +# signals, with visible status chrome and running tool rows as fallbacks. +[[rules]] +id = "osc_progress_blocked" +state = "blocked" +priority = 1400 +region = "osc_progress" +visible_blocker = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +regex = ['^\[ [!.] \] Action Required(?: \| |$)'] + +[[rules]] +id = "command_approval" +state = "blocked" +priority = 1200 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Run this command?", "Enter to select · Esc to cancel"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "active_status" +state = "working" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] + +[[rules]] +id = "running_tool" +state = "working" +priority = 800 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] + +[[rules]] +id = "profile_selector" +state = "unknown" +priority = 700 +region = "bottom_non_empty_lines(12)" +contains = ["Create a new agent (--new)", "Enter select · Esc exit"] + +[[rules]] +id = "composer_input" +state = "unknown" +priority = 150 +region = "bottom_non_empty_lines(8)" +line_regex = ['^\s*›\s+\S.*$'] +not = [ + { line_regex = ['^\s*›\s+Try\s+"'] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(8)" +visible_idle = true +any = [ + { line_regex = ['^\s*›\s*$'] }, + { line_regex = ['^\s*›\s+Try\s+"'] }, +] +not = [ + { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] }, + { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] }, +] + +[[rules]] +id = "no_live_state_evidence" +state = "unknown" +priority = 0 +region = "whole_recent" +regex = ['(?s)^.*$'] diff --git a/crates/detect-core/src/manifests/maki.toml b/crates/detect-core/src/manifests/maki.toml new file mode 100644 index 0000000..5c58404 --- /dev/null +++ b/crates/detect-core/src/manifests/maki.toml @@ -0,0 +1,68 @@ +id = "maki" +version = "2026.07.09.2" +min_engine_version = 1 +updated_at = "2026-07-09T00:00:00Z" + +# Maki renders a persistent one-line status bar on the bottom row. It starts +# with the mode label "[BUILD]", "[PLAN]", or "[BASH]" when idle and gets a +# leading braille spinner cell while the agent is streaming. Permission +# requests and the plan-complete form replace the input box above the status +# bar. Maki does not set OSC title or OSC 9;4 progress. + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true +contains = ["permission required"] +any = [ + { contains = ["y allow", "n deny"] }, + { contains = ["confirm allow"] }, + { contains = ["confirm deny"] }, + { contains = ["enter deny", "esc cancel"] }, +] + +[[rules]] +id = "plan_complete_form" +state = "blocked" +priority = 970 +region = "whole_recent" +visible_blocker = true +contains = ["plan complete", "enter confirm"] +any = [ + { contains = ["space toggle parallel"] }, + { contains = ["edit plan"] }, +] + +[[rules]] +id = "status_bar_spinner_working" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(1)" +visible_working = true +line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} \[(BUILD|PLAN|BASH)\]'] + +[[rules]] +id = "status_bar_idle" +state = "idle" +priority = 850 +region = "bottom_non_empty_lines(1)" +visible_idle = true +line_regex = ['^ \[(BUILD|PLAN|BASH)\]'] + +# On narrow panes the right side of the status bar overwrites the mode label, +# so fall back to the prompt chevron above the input box border. The not-gates +# keep this from matching the streaming placeholder or a status bar that still +# shows the spinner. +[[rules]] +id = "prompt_box_idle" +state = "idle" +priority = 840 +region = "bottom_non_empty_lines(3)" +visible_idle = true +line_regex = ['^❯ '] +not = [ + { contains = ["queue another prompt"] }, + { line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} '] }, +] diff --git a/crates/detect-core/src/manifests/muse.toml b/crates/detect-core/src/manifests/muse.toml new file mode 100644 index 0000000..5081842 --- /dev/null +++ b/crates/detect-core/src/manifests/muse.toml @@ -0,0 +1,113 @@ +id = "muse" +version = "2026.08.26.1" +min_engine_version = 2 +updated_at = "2026-08-26T00:00:00Z" +aliases = ["muse-code", "muse-cli"] + +# Evidence: live bottom-buffer captures from Muse Code 0.2.1 in Herdr, using a local +# deterministic Responses provider so each UI state could be held and inspected. +# +# Idle has a `⟩` prompt and `model · effort · cwd` footer. Active turns show +# `◆ Working (... · esc to interrupt)` or another activity label with the same interrupt hint. +# +# Structured questions show two co-occurring footer controls: +# Enter to select · ↑/↓ to move · Tab for an optional note · Esc to interrupt +# Multi-select uses `Enter to toggle` instead. The paired controls distinguish a real picker +# from ordinary transcript text that happens to mention one action. +# +# First launch in an untrusted directory shows `Do you trust this workspace?` together with +# `Trust and continue`. This is a real blocker. User-opened `/theme` and `/skills` menus are +# not blockers; their paired footer controls identify overlays whose prior state must be kept. +# +# Muse 0.2.1 command approval shows `Allow this stage once` together with +# `Always allow in this workspace`. Muse 0.1 used `Allow once` with +# `Allow for this session`. Network approval shows `Yes, proceed` together with +# `Yes, don't ask again this session`. Each approval rule requires a pair because Muse can +# emit any one of these phrases as ordinary assistant text after a completed turn. + +[[rules]] +id = "workspace_trust_blocked" +state = "blocked" +priority = 970 +region = "bottom_non_empty_lines(12)" +visible_blocker = true +contains = ["Do you trust this workspace?"] +any = [ + { contains = ["Trust and continue"] }, + { contains = ["Use Up/Down"] }, +] + +[[rules]] +id = "pick_request_blocked" +state = "blocked" +priority = 950 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +any = [ + { contains = ["Enter to select", "Tab for an optional note"] }, + { contains = ["Enter to toggle", "Esc to interrupt"] }, +] + +[[rules]] +id = "menu_overlay" +state = "unknown" +priority = 940 +region = "bottom_non_empty_lines(8)" +skip_state_update = true +any = [ + { contains = ["enter confirm", "esc go back"] }, + { contains = ["enter save", "esc go back"] }, + { contains = ["space toggle", "esc close", "type filter"] }, +] + +[[rules]] +id = "working_esc_interrupt" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["esc to interrupt"] +not = [ + { contains = ["Enter to select", "Tab for an optional note"] }, + { contains = ["Enter to toggle", "Esc to interrupt"] }, +] + +[[rules]] +id = "blocked_approval" +state = "blocked" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +any = [ + { contains = ["Allow this stage once", "Always allow in this workspace"] }, + { contains = ["Allow once", "Allow for this session"] }, + { contains = ["Yes, proceed", "Yes, don't ask again this session"] }, +] + +[[rules]] +id = "idle_prompt" +state = "idle" +priority = 700 +region = "bottom_non_empty_lines(5)" +visible_idle = true +any = [ + { line_regex = ['^\s*⟩\s*$'] }, + { line_regex = ['^\s*⟩\s+\S'] }, +] +not = [ + { contains = ["esc to interrupt"] }, + { contains = ["Enter to select", "Tab for an optional note"] }, + { contains = ["Enter to toggle", "Esc to interrupt"] }, + { contains = ["enter confirm", "esc go back"] }, + { contains = ["enter save", "esc go back"] }, + { contains = ["space toggle", "esc close", "type filter"] }, +] + +[[rules]] +id = "idle_status_fallback" +state = "idle" +priority = 500 +region = "bottom_non_empty_lines(3)" +visible_idle = true +line_regex = ['^\s*\S+ · (none|minimal|low|medium|high|xhigh|ultra) · '] +not = [{ contains = ["esc to interrupt"] }] diff --git a/crates/detect-core/src/manifests/opencode.toml b/crates/detect-core/src/manifests/opencode.toml new file mode 100644 index 0000000..5245238 --- /dev/null +++ b/crates/detect-core/src/manifests/opencode.toml @@ -0,0 +1,37 @@ +id = "opencode" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["open-code", "herdr:opencode"] + +[[rules]] +id = "permission_required" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["△ Permission required"] }, + { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, +] + +[[rules]] +id = "interrupt_hint_working" +state = "working" +priority = 110 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["esc to interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, + { contains = ["press esc to interrupt"] }, + { line_regex = ['(?i).*opencode.*esc (again to )?interrupt'] }, +] + +[[rules]] +id = "progress_bar_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +regex = ['(■|⬝){4,}'] diff --git a/crates/detect-core/src/manifests/pi.toml b/crates/detect-core/src/manifests/pi.toml new file mode 100644 index 0000000..5ae51a8 --- /dev/null +++ b/crates/detect-core/src/manifests/pi.toml @@ -0,0 +1,27 @@ +id = "pi" +version = "2026.10.01.1" +min_engine_version = 1 +updated_at = "2026-10-01T00:00:00Z" +aliases = ["herdr:pi"] + +[[rules]] +id = "working_literal" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +any = [ + { contains = ["Working..."] }, + { line_regex = ['^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working$'] }, +] + +[[rules]] +id = "working_border" +state = "working" +priority = 100 +region = "bottom_non_empty_lines(12)" +visible_working = true +any = [ + { line_regex = ['^── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working ─+$'] }, + { line_regex = ['^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working$'] }, +] diff --git a/crates/detect-core/src/manifests/qodercli.toml b/crates/detect-core/src/manifests/qodercli.toml new file mode 100644 index 0000000..51ca805 --- /dev/null +++ b/crates/detect-core/src/manifests/qodercli.toml @@ -0,0 +1,38 @@ +id = "qodercli" +version = "2026.06.10.1" +min_engine_version = 1 +updated_at = "2026-06-10T00:00:00Z" +aliases = ["qoderclicn", "qoder", "qodercn"] + +[[rules]] +id = "confirmation_or_input_blocker" +state = "blocked" +priority = 300 +region = "whole_recent" +visible_blocker = true +any = [ + { contains = ["waiting for user confirmation"], any = [{ contains = ["yes"] }, { contains = ["no"] }, { contains = ["allow"] }, { contains = ["reject"] }] }, + { contains = ["awaiting approval"], any = [{ contains = ["allow"] }, { contains = ["reject"] }] }, + { contains = ["permission required"] }, + { contains = ["allow once or always?"] }, + { contains = ["asking user"] }, + { contains = ["enter your response"] }, + { contains = ["review your answers:"] }, + { contains = ["shell awaiting input"] }, +] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 100 +region = "whole_recent" +visible_working = true +contains = ["(esc to cancel,"] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 90 +region = "whole_recent" +visible_working = true +line_regex = ['^\s*[\u2800-\u28FF]\s+.*\p{Alphabetic}'] diff --git a/crates/detect-core/src/manifests/qwen.toml b/crates/detect-core/src/manifests/qwen.toml new file mode 100644 index 0000000..bbed237 --- /dev/null +++ b/crates/detect-core/src/manifests/qwen.toml @@ -0,0 +1,119 @@ +id = "qwen" +version = "2026.08.14.1" +min_engine_version = 2 +updated_at = "2026-08-14T00:00:00Z" +aliases = ["qwen-code", "qwen code"] + +# Qwen Code keeps its composer visible while responding, so the prompt box is +# not idle evidence by itself. Its status-prefixed terminal titles are the +# primary locale-independent signals when ui.showStatusInTitle is enabled. +# Exact screen fallbacks cover built-in locales and narrow terminals. OSC 9;4 +# is supplemental because Qwen emits it only while a tool is executing and +# only in terminals that advertise progress support. + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1200 +region = "osc_title" +visible_blocker = true +regex = ['^\x{2733}\x{FE0E}? '] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1100 +region = "osc_title" +visible_working = true +regex = ['^\x{25D0}\x{FE0E}? '] + +[[rules]] +id = "waiting_for_confirmation" +state = "blocked" +priority = 1000 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +line_regex = ['^\s*⠏\s+.*\.\.\.\s*$'] +any = [ + { contains = ["Waiting for user confirmation..."] }, + { contains = ["等待用户确认..."] }, + { contains = ["等待用戶確認..."] }, + { contains = ["Warten auf Benutzerbestätigung..."] }, + { contains = ["En attente de la confirmation de l'utilisateur..."] }, + { contains = ["ユーザーの確認を待っています..."] }, + { contains = ["Aguardando confirmação do usuário..."] }, + { contains = ["Ожидание подтверждения от пользователя..."] }, + { contains = ["Esperant la confirmació de l'usuari..."] }, +] + +[[rules]] +id = "tool_confirmation" +state = "blocked" +priority = 990 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["yes, allow once"] +any = [ + { contains = ["apply this change?"] }, + { contains = ["allow execution of:"] }, + { contains = ["allow execution of mcp tool"] }, + { contains = ["do you want to proceed?"] }, + { contains = ["shell command execution"] }, +] + +[[rules]] +id = "question_dialog" +state = "blocked" +priority = 980 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +line_regex = [ + '^\s*[❯›]\s*(?:\[(?: |✓)\]\s*)?\d+\.\s+', + '^\s*↑/↓\s*:.*(?:Enter|Return)\s*:', +] + +[[rules]] +id = "folder_trust_dialog" +state = "blocked" +priority = 970 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["do you trust this folder?", "trust folder (", "don't trust (esc)"] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:[⠁-⣿]|\.{1,2})\s+.*\(\d+(?:m(?:\s+\d+s)?|s).*\s·\sesc to cancel\)\s*$'] + +[[rules]] +id = "narrow_cancel_hint_working" +state = "working" +priority = 890 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*\(\d+(?:m(?:\s+\d+s)?|s)\s·\sesc to cancel\)\s*$'] + +[[rules]] +id = "osc_tool_progress_working" +state = "working" +priority = 850 +region = "osc_progress" +visible_working = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(30)" +visible_idle = true +line_regex = ['^\s*>\s*(?:type\s*)?.*$'] +any = [ + { contains = ["type your message"] }, + { contains = ["your message or @path/to/file"] }, + { contains = ["@path/to/file"] }, + { contains = ["type", "mes", "sage", "@pat", "h/to", "/fil"] }, +] diff --git a/crates/dinggerd/Cargo.toml b/crates/dinggerd/Cargo.toml new file mode 100644 index 0000000..073b65e --- /dev/null +++ b/crates/dinggerd/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "dinggerd" +version = "0.1.0" +edition = "2021" +description = "macOS agent watcher for tmux panes: notifications and status without owning a terminal" + +[dependencies] +detect-core = { path = "../detect-core" } +clap = { version = "4.5", default-features = false, features = ["std", "help", "usage", "error-context", "string", "derive"] } +mac-notification-sys = "0.6" +serde = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[[bin]] +name = "dingger" +path = "src/main.rs" diff --git a/crates/dinggerd/assets/done.mp3 b/crates/dinggerd/assets/done.mp3 Binary files differnew file mode 100644 index 0000000..753e734 --- /dev/null +++ b/crates/dinggerd/assets/done.mp3 diff --git a/crates/dinggerd/assets/request.mp3 b/crates/dinggerd/assets/request.mp3 Binary files differnew file mode 100644 index 0000000..fe4859e --- /dev/null +++ b/crates/dinggerd/assets/request.mp3 diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs new file mode 100644 index 0000000..f046732 --- /dev/null +++ b/crates/dinggerd/src/main.rs @@ -0,0 +1,100 @@ +//! dingger: agent state watching for tmux panes. + +mod manifest_update; +mod notify; +mod tmux; +mod watcher; + +use clap::{Parser, Subcommand}; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command(name = "dingger", about = "Agent state watching for tmux panes")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Watch all tmux panes and notify on agent state transitions. + Watch { + /// Poll interval in seconds. + #[arg(long, default_value_t = 1)] + interval_secs: u64, + /// Disable notification sounds. + #[arg(long)] + no_sounds: bool, + }, + /// Print the current agent state of every tmux pane. + Status, + /// Refresh detection manifests from herdr's published catalog. + Manifests { + #[command(subcommand)] + command: ManifestCommand, + }, +} + +#[derive(Subcommand)] +enum ManifestCommand { + /// Download newer manifests from herdr's published catalog into the + /// local remote store; bundled manifests are the offline fallback. + /// Restart `dingger watch` to pick them up. + Update, +} + +fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + match Cli::parse().command { + Command::Watch { + interval_secs, + no_sounds, + } => { + let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds); + if let Err(err) = watcher.run() { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } + Command::Status => { + if let Err(err) = status() { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } + Command::Manifests { command } => match command { + ManifestCommand::Update => { + if let Err(err) = manifest_update::update() { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } + }, + } +} + +fn status() -> Result<(), String> { + let panes = tmux::list_panes()?; + if panes.is_empty() { + println!("no tmux panes"); + return Ok(()); + } + println!("{:<22} {:<10} {:<12} COMMAND", "TARGET", "AGENT", "STATE"); + for pane in panes { + println!( + "{:<22} {:<10} {:<12} {}", + pane.target, + pane.agent.map(detect_core::agent_label).unwrap_or("-"), + if pane.written_state.is_empty() { + "-" + } else { + &pane.written_state + }, + pane.current_command, + ); + } + Ok(()) +} diff --git a/crates/dinggerd/src/manifest_update.rs b/crates/dinggerd/src/manifest_update.rs new file mode 100644 index 0000000..b18a153 --- /dev/null +++ b/crates/dinggerd/src/manifest_update.rs @@ -0,0 +1,79 @@ +//! Refresh detection manifests from herdr's published catalog +//! (`https://herdr.dev/agent-detection/index.toml`). Bundled manifests remain +//! the offline fallback; downloads land in dingger's state dir where the +//! detection engine picks them up on the next watcher start. + +use std::process::Command; + +use serde::Deserialize; +use tracing::info; + +use detect_core::manifest_update::{remote_manifest_path, remote_store_dir}; +use detect_core::{agent_label, Agent}; + +const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml"; +const CATALOG_URL_ENV: &str = "DINGGER_AGENT_DETECTION_MANIFEST_CATALOG_URL"; + +#[derive(Deserialize)] +struct ManifestCatalog { + #[serde(default)] + agents: Vec<ManifestCatalogAgent>, +} + +#[derive(Deserialize)] +struct ManifestCatalogAgent { + id: String, + path: String, +} + +fn catalog_url() -> String { + std::env::var(CATALOG_URL_ENV).unwrap_or_else(|_| DEFAULT_CATALOG_URL.to_string()) +} + +fn fetch_text(url: &str) -> Result<String, String> { + let output = Command::new("curl") + .args(["-fsSL", "--max-time", "30", url]) + .output() + .map_err(|err| format!("failed to run curl: {err}"))?; + if !output.status.success() { + return Err(format!("curl {url} failed: {}", output.status)); + } + String::from_utf8(output.stdout).map_err(|err| format!("non-utf8 response: {err}")) +} + +pub fn update() -> Result<(), String> { + let url = catalog_url(); + let catalog_text = fetch_text(&url)?; + let catalog: ManifestCatalog = toml::from_str(&catalog_text) + .map_err(|err| format!("invalid catalog: {err}"))?; + + let base = url.rsplit_once('/').map(|(prefix, _)| prefix).unwrap_or(""); + let store = remote_store_dir(); + std::fs::create_dir_all(&store).map_err(|err| format!("mkdir failed: {err}"))?; + + let mut stored = 0usize; + for entry in &catalog.agents { + let Some(agent) = Agent::ALL.iter().copied().find(|a| agent_label(*a) == entry.id) + else { + continue; + }; + let manifest_url = format!("{base}/{}", entry.path); + let content = match fetch_text(&manifest_url) { + Ok(content) => content, + Err(err) => { + tracing::warn!(agent = %entry.id, %err, "skipping manifest"); + continue; + } + }; + std::fs::write(remote_manifest_path(agent), content) + .map_err(|err| format!("write failed: {err}"))?; + stored += 1; + } + + info!(stored, "manifest update complete"); + println!( + "stored {stored} manifests into {}\nrestart `dingger watch` to pick them up", + store.display() + ); + Ok(()) +} diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs new file mode 100644 index 0000000..9fcfcde --- /dev/null +++ b/crates/dinggerd/src/notify.rs @@ -0,0 +1,76 @@ +//! Notifications and sounds, mirroring herdr's macOS delivery. + +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use tracing::{debug, warn}; + +pub enum NotificationKind { + NeedsAttention, + Finished, +} + +impl NotificationKind { + pub fn title(&self) -> &'static str { + match self { + Self::NeedsAttention => "needs attention", + Self::Finished => "finished", + } + } + + /// herdr's bundled notification sounds; `request` for blockers, `done` + /// for completions. + fn sound_bytes(&self) -> &'static [u8] { + match self { + Self::NeedsAttention => include_bytes!("../assets/request.mp3"), + Self::Finished => include_bytes!("../assets/done.mp3"), + } + } +} + +static SOUND_SEQ: AtomicU64 = AtomicU64::new(0); + +pub fn play_sound(kind: &NotificationKind) { + // Sounds are embedded; afplay needs a file, so stage the bytes in the + // system temp dir. + let path = std::env::temp_dir().join(format!( + "dingger-{}-{}.mp3", + std::process::id(), + SOUND_SEQ.fetch_add(1, Ordering::Relaxed) + )); + if let Err(err) = std::fs::write(&path, kind.sound_bytes()) { + warn!(%err, "failed to stage notification sound"); + return; + } + match Command::new("afplay") + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + // Fire and forget: never let audio latency block the watch loop. + Ok(mut child) => { + std::thread::spawn(move || { + let _ = child.wait(); + let _ = std::fs::remove_file(&path); + }); + } + Err(err) => { + warn!(%err, "failed to play notification sound"); + let _ = std::fs::remove_file(&path); + } + } +} + +pub fn send_notification(agent_label: &str, kind: &NotificationKind, context: &str) { + debug!(agent = agent_label, kind = kind.title(), context, "notifying"); + let title = format!("{agent_label} {}", kind.title()); + let notification = mac_notification_sys::Notification::new() + .title(title.as_str()) + .message(context) + .send(); + if let Err(err) = notification { + warn!(%err, "failed to deliver macOS notification"); + } +} diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs new file mode 100644 index 0000000..04a6c77 --- /dev/null +++ b/crates/dinggerd/src/tmux.rs @@ -0,0 +1,177 @@ +//! tmux IPC wrapper. Everything dingger knows comes from these commands. + +use std::process::Command; + +use detect_core::manifest::agent_state_label; +use detect_core::{identify_agent, Agent, AgentState}; + +#[derive(Debug, Clone)] +pub struct Pane { + /// Unique tmux pane id, e.g. `%3`. Stable for the pane's lifetime. + pub pane_id: String, + /// User-facing target, e.g. `main:1.0`. + pub target: String, + /// Foreground command name in the pane, from `#{pane_current_command}`. + pub current_command: String, + /// Last OSC title the pane's application set, from `#{pane_title}`. + pub pane_title: String, + pub dead: bool, + /// The pane's window is the active window of its session. + pub window_active: bool, + /// The pane's session has at least one attached client. + pub session_attached: bool, + pub agent: Option<Agent>, + /// The `@agent_state` user option previously written for this pane, + /// read straight from the pane format. + pub written_state: String, +} + +impl Pane { + /// Panes the user is actively looking at suppress notifications, mirroring + /// herdr's active-tab suppression. + pub fn suppresses_notifications(&self) -> bool { + self.window_active && self.session_attached + } +} + +fn run_tmux(args: &[&str]) -> Result<String, String> { + let mut command = Command::new("tmux"); + // -L selects a named server socket; lets tests and multi-server setups + // point dingger at a specific server. + if let Some(socket) = std::env::var_os("DINGGER_TMUX_SOCKET") { + command.arg("-L").arg(socket); + } + let output = command + .args(args) + .output() + .map_err(|err| format!("failed to run tmux: {err}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("tmux {} failed: {}", args.join(" "), stderr.trim())); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +pub fn list_panes() -> Result<Vec<Pane>, String> { + let format = concat!( + "#{pane_id}\t", + "#{session_name}:#{window_index}.#{pane_index}\t", + "#{pane_current_command}\t", + "#{pane_title}\t", + "#{pane_dead}\t", + "#{window_active}\t", + "#{session_attached}\t", + "#{pane_start_command}\t", + "#{pane_pid}\t", + "#{@agent_state}" + ); + let out = run_tmux(&["list-panes", "-a", "-F", format])?; + let mut panes = Vec::new(); + for line in out.lines() { + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() < 10 { + continue; + } + let current_command = fields[2].to_string(); + let start_command = fields[7].to_string(); + let pane_pid: Option<u32> = fields[8].trim().parse().ok(); + // An agent may be started via a wrapper or still be initializing + // behind the shell; `pane_start_command` covers launch-command cases. + let mut agent = + identify_agent(¤t_command).or_else(|| identify_agent(&start_command)); + // Some agents rename their process (claude sets its title to the + // version string), so `pane_current_command` alone is not enough. + let mut process_tree = None; + if agent.is_none() { + let tree = process_tree.get_or_insert_with(fetch_process_tree); + agent = resolve_agent_in_process_tree(pane_pid, tree); + } + panes.push(Pane { + pane_id: fields[0].to_string(), + target: fields[1].to_string(), + current_command, + pane_title: fields[3].to_string(), + dead: fields[4] == "1", + window_active: fields[5] == "1", + session_attached: fields[6] != "0", + agent, + written_state: fields[9].to_string(), + }); + } + Ok(panes) +} + +type ProcessTree = (std::collections::HashMap<u32, Vec<u32>>, std::collections::HashMap<u32, String>); + +/// Identify the pane's agent by walking its process tree. Some agents rename +/// their process (claude sets its title to the version string), so the kernel +/// name in `pane_current_command` is not enough; `ps` still shows the real +/// binary path. One `ps` call per `list_panes` covers all panes. +fn resolve_agent_in_process_tree(pane_pid: Option<u32>, tree: &ProcessTree) -> Option<Agent> { + let pane_pid = pane_pid?; + let (children, comm) = tree; + + // BFS from the pane's shell. + let mut queue = std::collections::VecDeque::from([pane_pid]); + while let Some(pid) = queue.pop_front() { + if let Some(name) = comm.get(&pid) { + if let Some(agent) = identify_agent(name) { + return Some(agent); + } + } + if let Some(child_ids) = children.get(&pid) { + for child in child_ids { + queue.push_back(*child); + } + } + } + None +} + +fn fetch_process_tree() -> ProcessTree { + let Ok(output) = Command::new("ps").args(["-axo", "pid=,ppid=,comm="]).output() else { + return Default::default(); + }; + if !output.status.success() { + return Default::default(); + } + let mut children: std::collections::HashMap<u32, Vec<u32>> = + std::collections::HashMap::new(); + let mut comm: std::collections::HashMap<u32, String> = std::collections::HashMap::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + // Columns are whitespace-aligned, so runs of spaces separate pid, + // ppid, and the command path; the command itself may contain spaces. + let Some((pid, rest)) = line.trim_start().split_once(char::is_whitespace) else { + continue; + }; + let Some((ppid, name)) = rest.trim_start().split_once(char::is_whitespace) else { + continue; + }; + let name = name.trim_start(); + let (Ok(pid), Ok(ppid)) = (pid.parse::<u32>(), ppid.parse::<u32>()) else { + continue; + }; + children.entry(ppid).or_default().push(pid); + let basename = name.rsplit('/').next().unwrap_or(name); + comm.insert(pid, basename.to_string()); + } + (children, comm) +} + +/// Capture the pane's visible screen as plain text — the exact input shape +/// the detection manifests expect. +pub fn capture_pane(pane_id: &str) -> Result<String, String> { + run_tmux(&["capture-pane", "-p", "-q", "-t", pane_id]) +} + +pub fn set_agent_state(pane_id: &str, state: AgentState) -> Result<(), String> { + run_tmux(&[ + "set-option", + "-p", + "-t", + pane_id, + "@agent_state", + agent_state_label(state), + ]) + .map(|_| ()) +} diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs new file mode 100644 index 0000000..46f5ebd --- /dev/null +++ b/crates/dinggerd/src/watcher.rs @@ -0,0 +1,156 @@ +//! Poll-and-detect loop: tmux pane capture → detection engine → herdr's +//! transition policy → notifications and `@agent_state` write-back. + +use std::collections::HashMap; + +use detect_core::{detect_agent_with_osc, AgentState}; +use tracing::{debug, info, warn}; + +use crate::notify::{play_sound, send_notification, NotificationKind}; +use crate::tmux; + +pub struct Watcher { + /// Last published state per pane id. `status` reads the same values back + /// from tmux's `@agent_state`, so no other state is needed. + states: HashMap<String, AgentState>, + interval_secs: u64, + sounds: bool, +} + +/// herdr's notification policy (`notification_toast_for_state_change`). +pub fn notification_kind_for_transition( + prev_state: AgentState, + new_state: AgentState, +) -> Option<NotificationKind> { + if new_state == prev_state { + return None; + } + match new_state { + AgentState::Blocked => Some(NotificationKind::NeedsAttention), + AgentState::Idle + if matches!(prev_state, AgentState::Working | AgentState::Blocked) => + { + Some(NotificationKind::Finished) + } + _ => None, + } +} + +impl Watcher { + pub fn new(interval_secs: u64, sounds: bool) -> Self { + Self { + states: HashMap::new(), + interval_secs: interval_secs.max(1), + sounds, + } + } + + pub fn run(&mut self) -> Result<(), String> { + // Fail fast when tmux is unreachable at startup; after that, keep + // retrying — the server may come back. + self.tick()?; + info!(interval = self.interval_secs, "watching tmux panes"); + loop { + if let Err(err) = self.tick() { + warn!(%err, "watch tick failed"); + } + std::thread::sleep(std::time::Duration::from_secs(self.interval_secs)); + } + } + + fn tick(&mut self) -> Result<(), String> { + let panes = tmux::list_panes()?; + + // Forget panes that went away. + self.states + .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); + + for pane in panes { + let prev_state = self + .states + .get(&pane.pane_id) + .copied() + .unwrap_or(AgentState::Unknown); + + // The detection result is an observation of live agent state. + // Viewer screens (transcripts, pickers) return + // `skip_state_update: true` — they say nothing about state and + // leave the previous one standing (herdr's publishing contract). + let observed = if pane.dead { + Some(AgentState::Idle) + } else if let Some(agent) = pane.agent { + let screen = match tmux::capture_pane(&pane.pane_id) { + Ok(screen) => screen, + Err(err) => { + warn!(target = %pane.target, %err, "capture failed"); + continue; + } + }; + // tmux pane_title carries the last OSC title the application + // set; tmux does not re-expose OSC progress sequences. + let detection = + detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, ""); + (!detection.skip_state_update).then_some(detection.state) + } else { + // A pane that stops running an agent (agent quit into a + // shell) is no longer an observation. + Some(AgentState::Unknown) + }; + + let Some(new_state) = observed else { + debug!(target = %pane.target, "observation skipped (viewer screen)"); + continue; + }; + if new_state == prev_state { + continue; + } + self.states.insert(pane.pane_id.clone(), new_state); + debug!(target = %pane.target, ?prev_state, ?new_state, "state change"); + + if let Err(err) = tmux::set_agent_state(&pane.pane_id, new_state) { + warn!(target = %pane.target, %err, "failed to write @agent_state"); + } + + let agent_label = pane + .agent + .map(detect_core::agent_label) + .unwrap_or("agent") + .to_string(); + if let Some(kind) = notification_kind_for_transition(prev_state, new_state) { + if pane.suppresses_notifications() { + debug!(target = %pane.target, "suppressed (active pane)"); + continue; + } + let context = format!("{} ({})", pane.target, agent_label); + send_notification(&agent_label, &kind, &context); + if self.sounds { + play_sound(&kind); + } + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_matches_herdr() { + assert!(notification_kind_for_transition(AgentState::Working, AgentState::Blocked) + .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); + assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Blocked).is_none()); + assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Blocked) + .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); + assert!(notification_kind_for_transition(AgentState::Working, AgentState::Idle) + .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); + assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Idle) + .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); + // Unknown → Idle is a first observation, not a completion. + assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Idle).is_none()); + assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Working).is_none()); + assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Unknown).is_none()); + } +} |
