agent-status 1.0.2

Tmux-integrated indicator showing which AI coding agent sessions are waiting on user input.
Documentation
pub mod claude_code;
pub mod opencode;
pub mod pi_coding_agent;

/// CLI-facing enumeration of every registered agent.
///
/// Mirrors the [`Agent`] trait registry but lives in the type system so the
/// `--agent` flag, [`AgentName::agent`] dispatch, and the
/// [`crate::commands::build_extension`] match are all compile-time checked.
/// Wire strings (`claude-code`, etc.) match each variant's `Agent::name()`
/// return value so on-disk state files and CLI flags use the same identifiers.
#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)]
pub enum AgentName {
    #[value(name = "claude-code")]
    ClaudeCode,
    #[value(name = "pi-coding-agent")]
    PiCodingAgent,
    #[value(name = "opencode")]
    Opencode,
}

impl AgentName {
    /// Stable wire string for this agent (matches `Agent::name()`).
    #[must_use]
    pub fn name(self) -> &'static str {
        match self {
            Self::ClaudeCode => "claude-code",
            Self::PiCodingAgent => "pi-coding-agent",
            Self::Opencode => "opencode",
        }
    }

    /// Dispatch to the trait object for this agent. Equivalent to
    /// `by_name(self.name()).expect("AgentName must always resolve")` but
    /// the compiler knows it can't fail.
    #[must_use]
    pub fn agent(self) -> Box<dyn Agent> {
        match self {
            Self::ClaudeCode => Box::new(claude_code::ClaudeCodeAgent),
            Self::PiCodingAgent => Box::new(pi_coding_agent::PiCodingAgent),
            Self::Opencode => Box::new(opencode::OpencodeAgent),
        }
    }
}

/// An agent implementation: knows how to extract a session ID from the JSON payload that
/// agent's hook delivers on stdin.
pub trait Agent {
    /// Stable, lowercase, hyphenated identifier (e.g. `"claude-code"`). Used for the
    /// `--agent` CLI flag and the `agent` field on persisted entries.
    fn name(&self) -> &'static str;

    /// Extract the session ID from the agent's hook event JSON. Returns `None` for
    /// invalid JSON, missing field, non-string value, or empty string.
    fn extract_session_id(&self, stdin_json: &str) -> Option<String>;

    /// Extract the agent's last-response text from the hook event JSON, when the
    /// payload carries one. Returns `None` when the field is absent, empty, or
    /// non-string. Default returns `None`; override in agents whose payload includes
    /// such text.
    fn extract_message(&self, _stdin_json: &str) -> Option<String> {
        None
    }
}

/// Resolve an agent by its `--agent` flag value.
#[must_use]
pub fn by_name(name: &str) -> Option<Box<dyn Agent>> {
    match name {
        "claude-code" => Some(Box::new(claude_code::ClaudeCodeAgent)),
        "opencode" => Some(Box::new(opencode::OpencodeAgent)),
        "pi-coding-agent" => Some(Box::new(pi_coding_agent::PiCodingAgent)),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn by_name_resolves_claude_code() {
        let agent = by_name("claude-code").expect("claude-code is a registered agent");
        assert_eq!(agent.name(), "claude-code");
    }

    #[test]
    fn by_name_returns_none_for_unknown() {
        assert!(by_name("frobnicator").is_none());
    }

    #[test]
    fn by_name_is_case_sensitive() {
        assert!(by_name("Claude-Code").is_none());
    }

    #[test]
    fn by_name_resolves_pi_coding_agent() {
        let agent = by_name("pi-coding-agent").expect("pi-coding-agent is a registered agent");
        assert_eq!(agent.name(), "pi-coding-agent");
    }

    #[test]
    fn by_name_resolves_opencode() {
        let agent = by_name("opencode").expect("opencode is a registered agent");
        assert_eq!(agent.name(), "opencode");
    }

    #[test]
    fn agent_name_returns_wire_string_for_each_variant() {
        assert_eq!(AgentName::ClaudeCode.name(), "claude-code");
        assert_eq!(AgentName::PiCodingAgent.name(), "pi-coding-agent");
        assert_eq!(AgentName::Opencode.name(), "opencode");
    }

    #[test]
    fn agent_name_dispatch_returns_matching_trait_object() {
        assert_eq!(AgentName::ClaudeCode.agent().name(), "claude-code");
        assert_eq!(AgentName::PiCodingAgent.agent().name(), "pi-coding-agent");
        assert_eq!(AgentName::Opencode.agent().name(), "opencode");
    }

    #[test]
    fn agent_name_parses_kebab_case_via_value_enum() {
        use clap::ValueEnum;
        assert_eq!(
            AgentName::from_str("claude-code", true).unwrap(),
            AgentName::ClaudeCode,
        );
        assert_eq!(
            AgentName::from_str("pi-coding-agent", true).unwrap(),
            AgentName::PiCodingAgent,
        );
        assert_eq!(
            AgentName::from_str("opencode", true).unwrap(),
            AgentName::Opencode,
        );
        assert!(AgentName::from_str("frobnicator", true).is_err());
    }

    #[test]
    fn extract_message_default_returns_none() {
        // A hand-rolled agent that doesn't override extract_message should get None.
        struct NoopAgent;
        impl Agent for NoopAgent {
            fn name(&self) -> &'static str { "noop" }
            fn extract_session_id(&self, _: &str) -> Option<String> { None }
        }
        assert!(NoopAgent.extract_message(r#"{"message":"hi"}"#).is_none());
    }
}