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/detect-core/src/lib.rs | |
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/detect-core/src/lib.rs')
| -rw-r--r-- | crates/detect-core/src/lib.rs | 277 |
1 files changed, 277 insertions, 0 deletions
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, + }, + ) +} |
