use std::path::PathBuf;
use super::inbox;
use super::{
ProviderKind, ProviderSession, SessionKind, WakeAdapter, WakeError, WakeLetter, WakeOutcome,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookFlavor {
ClaudeRewake,
ClaudeStop,
CodexStop,
KimiStop,
CursorStop,
GrokStop,
}
impl HookFlavor {
pub const ALL: [HookFlavor; 6] = [
HookFlavor::ClaudeRewake,
HookFlavor::ClaudeStop,
HookFlavor::CodexStop,
HookFlavor::KimiStop,
HookFlavor::CursorStop,
HookFlavor::GrokStop,
];
pub fn id(self) -> &'static str {
match self {
HookFlavor::ClaudeRewake => "claude-rewake",
HookFlavor::ClaudeStop => "claude-stop",
HookFlavor::CodexStop => "codex-stop",
HookFlavor::KimiStop => "kimi-stop",
HookFlavor::CursorStop => "cursor-stop",
HookFlavor::GrokStop => "grok-stop",
}
}
pub fn parse(value: &str) -> Option<Self> {
Self::ALL.into_iter().find(|flavor| flavor.id() == value)
}
pub fn provider(self) -> ProviderKind {
match self {
HookFlavor::ClaudeRewake | HookFlavor::ClaudeStop => ProviderKind::ClaudeCode,
HookFlavor::CodexStop => ProviderKind::Codex,
HookFlavor::KimiStop => ProviderKind::KimiCode,
HookFlavor::CursorStop => ProviderKind::Cursor,
HookFlavor::GrokStop => ProviderKind::Grok,
}
}
pub fn wakes_idle(self) -> bool {
matches!(self, HookFlavor::ClaudeRewake)
}
pub fn consumer(self) -> String {
match self {
HookFlavor::ClaudeRewake => inbox::consumer::CLAUDE_REWAKE.to_string(),
other => inbox::consumer::stop(other.id()),
}
}
pub fn present(self, dir: &std::path::Path) -> bool {
if self.wakes_idle() {
inbox::is_live(dir, &self.consumer())
} else {
inbox::is_armed(dir, &self.consumer())
}
}
pub fn render(self, prompts: &[String]) -> Option<(String, String, i32)> {
if prompts.is_empty() {
return None;
}
let text = prompts.join("\n\n");
Some(match self {
HookFlavor::ClaudeRewake | HookFlavor::KimiStop => (String::new(), text, 2),
HookFlavor::ClaudeStop | HookFlavor::CodexStop | HookFlavor::GrokStop => (
serde_json::json!({"decision": "block", "reason": text}).to_string(),
String::new(),
0,
),
HookFlavor::CursorStop => (
serde_json::json!({"followup_message": text}).to_string(),
String::new(),
0,
),
})
}
}
pub struct InboxHookAdapter {
kind: SessionKind,
flavor: HookFlavor,
inbox_dir: Option<PathBuf>,
}
impl InboxHookAdapter {
pub fn new(kind: SessionKind, flavor: HookFlavor, inbox_dir: Option<PathBuf>) -> Self {
Self {
kind,
flavor,
inbox_dir,
}
}
pub fn flavor(&self) -> HookFlavor {
self.flavor
}
}
impl WakeAdapter for InboxHookAdapter {
fn kind(&self) -> SessionKind {
self.kind
}
fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
let Some(dir) = self.inbox_dir.as_deref() else {
return Err(WakeError::Unavailable("inbox dir unset".into()));
};
if self.flavor.present(dir) {
Ok(())
} else {
Err(WakeError::Unavailable(format!(
"{} hook not installed in the session",
self.flavor.id()
)))
}
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
self.probe(session)?;
let dir = self.inbox_dir.as_deref().expect("probed");
inbox::write_letter(dir, session, letter).map(WakeOutcome::Queued)
}
}
pub struct InboxQueueAdapter {
kind: SessionKind,
inbox_dir: Option<PathBuf>,
}
impl InboxQueueAdapter {
pub fn new(kind: SessionKind, inbox_dir: Option<PathBuf>) -> Self {
Self { kind, inbox_dir }
}
}
impl WakeAdapter for InboxQueueAdapter {
fn kind(&self) -> SessionKind {
self.kind
}
fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
match self.inbox_dir {
Some(_) => Ok(()),
None => Err(WakeError::Unavailable("inbox dir unset".into())),
}
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
self.probe(session)?;
let dir = self.inbox_dir.as_deref().expect("probed");
inbox::write_letter(dir, session, letter).map(WakeOutcome::Queued)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::tests::{letter, session};
fn temp_dir(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"m4a-hook-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn flavors_round_trip_and_render_vendor_shapes() {
for flavor in HookFlavor::ALL {
assert_eq!(HookFlavor::parse(flavor.id()), Some(flavor));
assert!(flavor.render(&[]).is_none());
}
let prompts = vec!["a".to_string(), "b".to_string()];
let (out, err, code) = HookFlavor::ClaudeStop.render(&prompts).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["decision"], "block");
assert_eq!(v["reason"], "a\n\nb");
assert!(err.is_empty() && code == 0);
let (out, _, _) = HookFlavor::CursorStop.render(&prompts).unwrap();
assert!(out.contains("followup_message"));
let (out, err, code) = HookFlavor::KimiStop.render(&prompts).unwrap();
assert!(out.is_empty() && err == "a\n\nb" && code == 2);
let (_, _, code) = HookFlavor::ClaudeRewake.render(&prompts).unwrap();
assert_eq!(code, 2);
}
#[test]
fn hook_adapter_needs_its_consumer_then_queues() {
let dir = temp_dir("q");
let kind = SessionKind::local(ProviderKind::Codex);
let s = session(kind);
let mut adapter = InboxHookAdapter::new(kind, HookFlavor::CodexStop, Some(dir.clone()));
assert!(adapter.wake(&s, &letter("x")).is_err());
inbox::arm(&dir, &HookFlavor::CodexStop.consumer()).unwrap();
assert!(matches!(
adapter.wake(&s, &letter("x")).unwrap(),
WakeOutcome::Queued(_)
));
assert_eq!(inbox::pending(&dir), 1);
let mut rewake = InboxHookAdapter::new(kind, HookFlavor::ClaudeRewake, Some(dir.clone()));
assert!(rewake.wake(&s, &letter("y")).is_err());
{
let _live =
inbox::Presence::announce(&dir, &HookFlavor::ClaudeRewake.consumer()).unwrap();
assert!(rewake.probe(&s).is_ok());
}
let _ = std::fs::remove_dir_all(&dir);
}
}