mail4agent_messenger_shell/provider/
hook.rs1use std::path::PathBuf;
21
22use super::inbox;
23use super::{
24 ProviderKind, ProviderSession, SessionKind, WakeAdapter, WakeError, WakeLetter, WakeOutcome,
25};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum HookFlavor {
30 ClaudeRewake,
32 ClaudeStop,
34 CodexStop,
36 KimiStop,
38 CursorStop,
40 GrokStop,
42}
43
44impl HookFlavor {
45 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 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 pub fn parse(value: &str) -> Option<Self> {
69 Self::ALL.into_iter().find(|flavor| flavor.id() == value)
70 }
71
72 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 pub fn wakes_idle(self) -> bool {
86 matches!(self, HookFlavor::ClaudeRewake)
87 }
88
89 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 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 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
129pub struct InboxHookAdapter {
131 kind: SessionKind,
132 flavor: HookFlavor,
133 inbox_dir: Option<PathBuf>,
134}
135
136impl InboxHookAdapter {
137 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 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
182pub struct InboxQueueAdapter {
186 kind: SessionKind,
187 inbox_dir: Option<PathBuf>,
188}
189
190impl InboxQueueAdapter {
191 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}