Skip to main content

mail4agent_messenger_shell/provider/
hook.rs

1//! Hook doorbells: the session's own hook process picks the letter up.
2//!
3//! Every provider CLI runs hooks inside the open session. `m4a-inbox` is
4//! the hook command; this adapter only queues the letter in the session's
5//! inbox when such a hook is installed (armed) or running (live):
6//!
7//! | Flavor | Hook | Turn starts | Consumer name |
8//! |---|---|---|---|
9//! | `claude-rewake` | Claude `Stop`/`SessionStart` with `asyncRewake: true` running `m4a-inbox wait` | immediately, idle session (exit 2 wakes Claude) | live `claude-rewake` |
10//! | `claude-stop` | Claude `Stop` running `m4a-inbox drain --format claude-stop` | at the end of the current turn (`decision: block`) | armed `stop-claude-stop` |
11//! | `codex-stop` | Codex `Stop` hook, same JSON | end of current turn | armed `stop-codex-stop` |
12//! | `kimi-stop` | Kimi `[[hooks]] event = "Stop"`, exit 2 + stderr | end of current turn | armed `stop-kimi-stop` |
13//! | `cursor-stop` | Cursor `stop` hook, `followup_message` | end of current turn (auto-submitted) | armed `stop-cursor-stop` |
14//! | `grok-stop` | Grok Build `Stop` hook (Claude-compatible JSON) | end of current turn | armed `stop-grok-stop` |
15//!
16//! A Stop-hook doorbell cannot start a turn in an idle session; it is the
17//! fallback after the in-session primary. The letter stays durable in the
18//! inbox either way.
19
20use std::path::PathBuf;
21
22use super::inbox;
23use super::{
24    ProviderKind, ProviderSession, SessionKind, WakeAdapter, WakeError, WakeLetter, WakeOutcome,
25};
26
27/// Which hook drains the inbox.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum HookFlavor {
30    /// Claude `asyncRewake` waiter (`m4a-inbox wait`): wakes an idle session.
31    ClaudeRewake,
32    /// Claude `Stop` hook, `{"decision":"block","reason":...}`.
33    ClaudeStop,
34    /// Codex `Stop` hook, same JSON as Claude.
35    CodexStop,
36    /// Kimi `Stop` hook, exit 2 with the prompt on stderr.
37    KimiStop,
38    /// Cursor `stop` hook, `{"followup_message":...}`.
39    CursorStop,
40    /// Grok Build `Stop` hook (Claude-compatible).
41    GrokStop,
42}
43
44impl HookFlavor {
45    /// Every flavor.
46    pub const ALL: [HookFlavor; 6] = [
47        HookFlavor::ClaudeRewake,
48        HookFlavor::ClaudeStop,
49        HookFlavor::CodexStop,
50        HookFlavor::KimiStop,
51        HookFlavor::CursorStop,
52        HookFlavor::GrokStop,
53    ];
54
55    /// `m4a-inbox --format` value.
56    pub fn id(self) -> &'static str {
57        match self {
58            HookFlavor::ClaudeRewake => "claude-rewake",
59            HookFlavor::ClaudeStop => "claude-stop",
60            HookFlavor::CodexStop => "codex-stop",
61            HookFlavor::KimiStop => "kimi-stop",
62            HookFlavor::CursorStop => "cursor-stop",
63            HookFlavor::GrokStop => "grok-stop",
64        }
65    }
66
67    /// Parses [`Self::id`].
68    pub fn parse(value: &str) -> Option<Self> {
69        Self::ALL.into_iter().find(|flavor| flavor.id() == value)
70    }
71
72    /// Provider whose hook this is.
73    pub fn provider(self) -> ProviderKind {
74        match self {
75            HookFlavor::ClaudeRewake | HookFlavor::ClaudeStop => ProviderKind::ClaudeCode,
76            HookFlavor::CodexStop => ProviderKind::Codex,
77            HookFlavor::KimiStop => ProviderKind::KimiCode,
78            HookFlavor::CursorStop => ProviderKind::Cursor,
79            HookFlavor::GrokStop => ProviderKind::Grok,
80        }
81    }
82
83    /// Whether the hook wakes an idle session (true) or only continues the
84    /// current turn (false).
85    pub fn wakes_idle(self) -> bool {
86        matches!(self, HookFlavor::ClaudeRewake)
87    }
88
89    /// `.live/` name of the consumer.
90    pub fn consumer(self) -> String {
91        match self {
92            HookFlavor::ClaudeRewake => inbox::consumer::CLAUDE_REWAKE.to_string(),
93            other => inbox::consumer::stop(other.id()),
94        }
95    }
96
97    /// Whether that consumer is present for `dir`.
98    pub fn present(self, dir: &std::path::Path) -> bool {
99        if self.wakes_idle() {
100            inbox::is_live(dir, &self.consumer())
101        } else {
102            inbox::is_armed(dir, &self.consumer())
103        }
104    }
105
106    /// What the hook prints for drained `prompts`: `(stdout, stderr, exit)`.
107    /// `None` when there is nothing to say (exit 0, no output).
108    pub fn render(self, prompts: &[String]) -> Option<(String, String, i32)> {
109        if prompts.is_empty() {
110            return None;
111        }
112        let text = prompts.join("\n\n");
113        Some(match self {
114            HookFlavor::ClaudeRewake | HookFlavor::KimiStop => (String::new(), text, 2),
115            HookFlavor::ClaudeStop | HookFlavor::CodexStop | HookFlavor::GrokStop => (
116                serde_json::json!({"decision": "block", "reason": text}).to_string(),
117                String::new(),
118                0,
119            ),
120            HookFlavor::CursorStop => (
121                serde_json::json!({"followup_message": text}).to_string(),
122                String::new(),
123                0,
124            ),
125        })
126    }
127}
128
129/// Queues a letter for a hook that is installed in the open session.
130pub struct InboxHookAdapter {
131    kind: SessionKind,
132    flavor: HookFlavor,
133    inbox_dir: Option<PathBuf>,
134}
135
136impl InboxHookAdapter {
137    /// Adapter for `flavor` on `kind`, draining `inbox_dir`.
138    pub fn new(kind: SessionKind, flavor: HookFlavor, inbox_dir: Option<PathBuf>) -> Self {
139        Self {
140            kind,
141            flavor,
142            inbox_dir,
143        }
144    }
145
146    /// The hook flavor.
147    pub fn flavor(&self) -> HookFlavor {
148        self.flavor
149    }
150}
151
152impl WakeAdapter for InboxHookAdapter {
153    fn kind(&self) -> SessionKind {
154        self.kind
155    }
156
157    fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
158        let Some(dir) = self.inbox_dir.as_deref() else {
159            return Err(WakeError::Unavailable("inbox dir unset".into()));
160        };
161        if self.flavor.present(dir) {
162            Ok(())
163        } else {
164            Err(WakeError::Unavailable(format!(
165                "{} hook not installed in the session",
166                self.flavor.id()
167            )))
168        }
169    }
170
171    fn wake(
172        &mut self,
173        session: &ProviderSession,
174        letter: &WakeLetter<'_>,
175    ) -> Result<WakeOutcome, WakeError> {
176        self.probe(session)?;
177        let dir = self.inbox_dir.as_deref().expect("probed");
178        inbox::write_letter(dir, session, letter).map(WakeOutcome::Queued)
179    }
180}
181
182/// Last link of every local chain: keep the letter durable in the inbox
183/// even when no consumer is present, so the next hook run or channel
184/// start delivers it. Reported as [`WakeOutcome::Queued`].
185pub struct InboxQueueAdapter {
186    kind: SessionKind,
187    inbox_dir: Option<PathBuf>,
188}
189
190impl InboxQueueAdapter {
191    /// Queue into `inbox_dir`.
192    pub fn new(kind: SessionKind, inbox_dir: Option<PathBuf>) -> Self {
193        Self { kind, inbox_dir }
194    }
195}
196
197impl WakeAdapter for InboxQueueAdapter {
198    fn kind(&self) -> SessionKind {
199        self.kind
200    }
201
202    fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
203        match self.inbox_dir {
204            Some(_) => Ok(()),
205            None => Err(WakeError::Unavailable("inbox dir unset".into())),
206        }
207    }
208
209    fn wake(
210        &mut self,
211        session: &ProviderSession,
212        letter: &WakeLetter<'_>,
213    ) -> Result<WakeOutcome, WakeError> {
214        self.probe(session)?;
215        let dir = self.inbox_dir.as_deref().expect("probed");
216        inbox::write_letter(dir, session, letter).map(WakeOutcome::Queued)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::provider::tests::{letter, session};
224
225    fn temp_dir(tag: &str) -> PathBuf {
226        std::env::temp_dir().join(format!(
227            "m4a-hook-{tag}-{}-{}",
228            std::process::id(),
229            std::time::SystemTime::now()
230                .duration_since(std::time::UNIX_EPOCH)
231                .unwrap()
232                .as_nanos()
233        ))
234    }
235
236    #[test]
237    fn flavors_round_trip_and_render_vendor_shapes() {
238        for flavor in HookFlavor::ALL {
239            assert_eq!(HookFlavor::parse(flavor.id()), Some(flavor));
240            assert!(flavor.render(&[]).is_none());
241        }
242        let prompts = vec!["a".to_string(), "b".to_string()];
243        let (out, err, code) = HookFlavor::ClaudeStop.render(&prompts).unwrap();
244        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
245        assert_eq!(v["decision"], "block");
246        assert_eq!(v["reason"], "a\n\nb");
247        assert!(err.is_empty() && code == 0);
248        let (out, _, _) = HookFlavor::CursorStop.render(&prompts).unwrap();
249        assert!(out.contains("followup_message"));
250        let (out, err, code) = HookFlavor::KimiStop.render(&prompts).unwrap();
251        assert!(out.is_empty() && err == "a\n\nb" && code == 2);
252        let (_, _, code) = HookFlavor::ClaudeRewake.render(&prompts).unwrap();
253        assert_eq!(code, 2);
254    }
255
256    #[test]
257    fn hook_adapter_needs_its_consumer_then_queues() {
258        let dir = temp_dir("q");
259        let kind = SessionKind::local(ProviderKind::Codex);
260        let s = session(kind);
261        let mut adapter = InboxHookAdapter::new(kind, HookFlavor::CodexStop, Some(dir.clone()));
262        assert!(adapter.wake(&s, &letter("x")).is_err());
263        inbox::arm(&dir, &HookFlavor::CodexStop.consumer()).unwrap();
264        assert!(matches!(
265            adapter.wake(&s, &letter("x")).unwrap(),
266            WakeOutcome::Queued(_)
267        ));
268        assert_eq!(inbox::pending(&dir), 1);
269        let mut rewake = InboxHookAdapter::new(kind, HookFlavor::ClaudeRewake, Some(dir.clone()));
270        assert!(rewake.wake(&s, &letter("y")).is_err());
271        {
272            let _live =
273                inbox::Presence::announce(&dir, &HookFlavor::ClaudeRewake.consumer()).unwrap();
274            assert!(rewake.probe(&s).is_ok());
275        }
276        let _ = std::fs::remove_dir_all(&dir);
277    }
278}