aboutsummaryrefslogtreecommitdiff
path: root/crates/detect-core/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/detect-core/src/lib.rs')
-rw-r--r--crates/detect-core/src/lib.rs277
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,
+ },
+ )
+}