//! 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 { 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 { let agent = lookup_agent(label)?; (agent_label(agent) == label).then_some(agent) } fn lookup_agent(name: &str) -> Option { 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-` (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 { 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::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, 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, }, ) }