Skip to main content

agm_core/
agent.rs

1use core::fmt::Display;
2
3use strum::EnumIter;
4
5use crate::ParseError;
6
7pub mod session;
8#[cfg(not(target_arch = "wasm32"))]
9pub mod session_loader;
10pub mod session_parser;
11
12pub const AGENTS_PIPE: &str = "agm-agent";
13
14#[derive(Clone, Copy, Debug, EnumIter, Eq, PartialEq)]
15pub enum Agent {
16    Claude,
17    Codex,
18    Cursor,
19    Gemini,
20    Opencode,
21}
22
23impl Agent {
24    pub const fn name(self) -> &'static str {
25        match self {
26            Self::Claude => "claude",
27            Self::Codex => "codex",
28            Self::Cursor => "cursor",
29            Self::Gemini => "gemini",
30            Self::Opencode => "opencode",
31        }
32    }
33
34    pub const fn default_config(self) -> &'static str {
35        match self {
36            Self::Claude => r#"{"hooks":{}}"#,
37            Self::Cursor => r#"{"version":1,"hooks":{}}"#,
38            Self::Codex => r#"{"hooks":{}}"#,
39            Self::Gemini => r#"{"hooks":{}}"#,
40            Self::Opencode => "{}",
41        }
42    }
43
44    pub const fn root_path(self) -> &'static [&'static str] {
45        match self {
46            Self::Claude => &[".claude"],
47            Self::Cursor => &[".cursor"],
48            Self::Codex => &[".codex"],
49            Self::Gemini => &[".gemini"],
50            Self::Opencode => &[".config", "opencode"],
51        }
52    }
53
54    pub const fn sessions_root_path(self) -> &'static [&'static str] {
55        match self {
56            Self::Claude => &[".claude", "projects"],
57            Self::Cursor => &[".cursor", "chats"],
58            Self::Codex => &[".codex", "sessions"],
59            Self::Gemini => Self::root_path(self),
60            Self::Opencode => Self::root_path(self),
61        }
62    }
63
64    pub const fn config_path(self) -> &'static [&'static str] {
65        match self {
66            Self::Claude => &[".claude", "settings.json"],
67            Self::Cursor => &[".cursor", "hooks.json"],
68            Self::Codex => &[".codex", "hooks.json"],
69            Self::Gemini => &[".gemini", "settings.json"],
70            Self::Opencode => &[".config", "opencode", "plugins", "agm.ts"],
71        }
72    }
73
74    pub const fn hook_events(self) -> &'static [(&'static str, AgentEventKind)] {
75        match self {
76            Self::Claude => &[
77                ("SessionStart", AgentEventKind::Start),
78                ("UserPromptSubmit", AgentEventKind::Busy),
79                ("Stop", AgentEventKind::Idle),
80                ("SessionEnd", AgentEventKind::Exit),
81            ],
82            Self::Cursor => &[
83                ("sessionStart", AgentEventKind::Start),
84                ("beforeSubmitPrompt", AgentEventKind::Busy),
85                ("stop", AgentEventKind::Idle),
86                ("sessionEnd", AgentEventKind::Exit),
87            ],
88            Self::Codex => &[
89                // NOTE: agm plugin can only switch Codex from busy -> waiting when
90                // Codex emits `Stop`; approval UIs may stay green upstream until
91                // Codex hooks support a separate approval-pending / choice-prompt
92                // hook. Codex currently expose turn-scoped lifecycle only,
93                // `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`.
94                // Approval events requests #3247 and #3052.
95                ("SessionStart", AgentEventKind::Start),
96                ("UserPromptSubmit", AgentEventKind::Busy),
97                ("PreToolUse", AgentEventKind::Busy),
98                ("Stop", AgentEventKind::Idle),
99            ],
100            Self::Gemini => &[
101                ("SessionStart", AgentEventKind::Start),
102                ("BeforeAgent", AgentEventKind::Busy),
103                ("BeforeModel", AgentEventKind::Busy),
104                ("BeforeTool", AgentEventKind::Busy),
105                ("AfterAgent", AgentEventKind::Idle),
106                ("SessionEnd", AgentEventKind::Exit),
107            ],
108            Self::Opencode => &[],
109        }
110    }
111
112    pub fn from_name(s: &str) -> Result<Self, ParseError> {
113        match s {
114            "claude" => Ok(Self::Claude),
115            "cursor" => Ok(Self::Cursor),
116            "codex" => Ok(Self::Codex),
117            "gemini" => Ok(Self::Gemini),
118            "opencode" => Ok(Self::Opencode),
119            _ => Err(ParseError::Invalid {
120                field: "agent",
121                value: format!("{s:?}"),
122            }),
123        }
124    }
125
126    pub fn hook_command(self, kind: AgentEventKind) -> String {
127        let pipe = format!(
128            "zellij pipe --name {AGENTS_PIPE} --args \"pane_id=$ZELLIJ_PANE_ID,agent={}\" -- {} >/dev/null 2>&1 || true",
129            self.name(),
130            kind.as_str()
131        );
132        let echo = if matches!(self, Self::Gemini) {
133            "; echo '{}'"
134        } else {
135            ""
136        };
137        format!("cat >/dev/null 2>&1 || true; {pipe}{echo}")
138    }
139
140    pub const fn priority(self) -> u8 {
141        match self {
142            Self::Claude => 0,
143            Self::Codex => 1,
144            Self::Cursor => 2,
145            Self::Gemini => 3,
146            Self::Opencode => 4,
147        }
148    }
149
150    pub fn detect(name: &str) -> Option<Self> {
151        let lower = name.to_ascii_lowercase();
152        if lower.contains("claude") {
153            Some(Self::Claude)
154        } else if lower.contains("cursor") {
155            Some(Self::Cursor)
156        } else if lower.contains("codex") {
157            Some(Self::Codex)
158        } else if lower.contains("gemini") {
159            Some(Self::Gemini)
160        } else if lower.contains("opencode") {
161            Some(Self::Opencode)
162        } else {
163            None
164        }
165    }
166}
167
168impl Display for Agent {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        let repr = match self {
171            Self::Claude => "Claude",
172            Self::Codex => "Codex",
173            Self::Cursor => "Cursor",
174            Self::Gemini => "Gemini",
175            Self::Opencode => "Opencode",
176        };
177        write!(f, "{}", repr)
178    }
179}
180
181#[derive(Clone, Copy, Debug, Eq, PartialEq)]
182pub enum AgentEventKind {
183    Start,
184    Busy,
185    Idle,
186    Exit,
187}
188
189impl AgentEventKind {
190    pub const fn as_str(self) -> &'static str {
191        match self {
192            Self::Start => "start",
193            Self::Busy => "busy",
194            Self::Idle => "idle",
195            Self::Exit => "exit",
196        }
197    }
198
199    pub fn parse(s: &str) -> Result<Self, ParseError> {
200        match s.trim() {
201            "start" => Ok(Self::Start),
202            "busy" => Ok(Self::Busy),
203            "idle" => Ok(Self::Idle),
204            "exit" => Ok(Self::Exit),
205            _ => Err(ParseError::Invalid {
206                field: "event kind",
207                value: format!("{s:?}"),
208            }),
209        }
210    }
211}
212
213#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct AgentEventPayload {
215    pub pane_id: u32,
216    pub agent: Agent,
217    pub kind: AgentEventKind,
218}
219
220impl AgentEventPayload {
221    pub fn parse(pane_id: &str, agent: &str, payload: &str) -> Result<Self, ParseError> {
222        let pane_id = pane_id.trim().parse().map_err(|_| ParseError::Invalid {
223            field: "pane_id",
224            value: format!("{pane_id:?}"),
225        })?;
226        let agent = Agent::from_name(agent.trim())?;
227        let kind = AgentEventKind::parse(payload)?;
228        Ok(Self { pane_id, agent, kind })
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use rstest::rstest;
235
236    use super::*;
237
238    #[rstest]
239    #[case("claude", Ok(Agent::Claude))]
240    #[case("cursor", Ok(Agent::Cursor))]
241    #[case("codex", Ok(Agent::Codex))]
242    #[case("gemini", Ok(Agent::Gemini))]
243    #[case("opencode", Ok(Agent::Opencode))]
244    #[case("unknown", Err("invalid agent: \"unknown\"".to_string()))]
245    fn test_agent_from_name_works_as_expected(#[case] name: &str, #[case] expected: Result<Agent, String>) {
246        let actual = Agent::from_name(name).map_err(|e| e.to_string());
247        pretty_assertions::assert_eq!(actual, expected);
248    }
249
250    #[rstest]
251    #[case("Claude-3.5-Sonnet", Some(Agent::Claude))]
252    #[case("Cursor-IDE", Some(Agent::Cursor))]
253    #[case("GitHub-Codex", Some(Agent::Codex))]
254    #[case("Gemini-1.5-Pro", Some(Agent::Gemini))]
255    #[case("OpenCode-Agent", Some(Agent::Opencode))]
256    #[case("Vim", None)]
257    fn test_agent_detect_works_as_expected(#[case] name: &str, #[case] expected: Option<Agent>) {
258        pretty_assertions::assert_eq!(Agent::detect(name), expected);
259    }
260
261    #[rstest]
262    #[case(Agent::Claude)]
263    #[case(Agent::Cursor)]
264    #[case(Agent::Codex)]
265    #[case(Agent::Gemini)]
266    #[case(Agent::Opencode)]
267    fn test_hook_command_never_fails_when_zellij_unavailable(#[case] agent: Agent) {
268        let cmd = agent.hook_command(AgentEventKind::Busy);
269        assert2::assert!(cmd.contains("cat >/dev/null 2>&1 || true;"));
270        assert2::assert!(cmd.contains("zellij pipe --name agm-agent"));
271        assert2::assert!(cmd.contains(">/dev/null 2>&1 || true"));
272    }
273}