Skip to main content

rightkit_hooks/
event.rs

1//! Typed hook input for Claude Code and Codex.
2//!
3//! Field spellings follow `@rightkit/hooks` `normalizeHookEvent` (event: `hook_event_name`,
4//! `hookEventName`, `event`; session: `thread_id`, `session_id`, `sessionId`; tool: `tool_name`,
5//! `toolName`; tool use: `tool_use_id`, `toolUseId`). Every other typed field is one that shipped
6//! Pulse, Legion or Membrane code already reads. The untouched payload stays in [`HookEvent::raw`].
7
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use serde_json::Value;
10use std::fmt;
11use std::io::Read;
12use std::path::Path;
13
14/// Largest hook payload read from stdin (Pulse's `STDIN_CAP`).
15pub const STDIN_CAP: u64 = 4 * 1024 * 1024;
16
17/// Which harness sent the hook. The harness is chosen by the registration (Pulse's
18/// `claude-hook` / `codex-hook`), not sniffed from the payload.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub enum Wire {
21    Claude,
22    Codex,
23}
24
25impl Wire {
26    pub fn parse(value: &str) -> Option<Self> {
27        match value {
28            "claude" => Some(Self::Claude),
29            "codex" => Some(Self::Codex),
30            _ => None,
31        }
32    }
33
34    pub const fn as_str(self) -> &'static str {
35        match self {
36            Self::Claude => "claude",
37            Self::Codex => "codex",
38        }
39    }
40}
41
42impl fmt::Display for Wire {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(self.as_str())
45    }
46}
47
48/// A hook event name. Legion's lower-case aliases (`session-start`, `pre-effect`, ...) normalize to
49/// the host names. Unknown names are kept verbatim, never rejected.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub enum EventKind {
52    SessionStart,
53    UserPromptSubmit,
54    PreToolUse,
55    PostToolUse,
56    PostToolUseFailure,
57    Stop,
58    SubagentStart,
59    SubagentStop,
60    PreCompact,
61    PostCompact,
62    SessionEnd,
63    Notification,
64    Other(String),
65}
66
67impl EventKind {
68    pub fn from_name(name: &str) -> Self {
69        match name {
70            "SessionStart" | "session-start" => Self::SessionStart,
71            "UserPromptSubmit" | "user-prompt-submit" => Self::UserPromptSubmit,
72            "PreToolUse" | "pre-effect" => Self::PreToolUse,
73            "PostToolUse" | "post-effect" => Self::PostToolUse,
74            "PostToolUseFailure" | "post-effect-failure" => Self::PostToolUseFailure,
75            "Stop" | "stop" => Self::Stop,
76            "SubagentStart" | "subagent-start" => Self::SubagentStart,
77            "SubagentStop" | "subagent-stop" => Self::SubagentStop,
78            "PreCompact" | "pre-compact" => Self::PreCompact,
79            "PostCompact" | "post-compact" => Self::PostCompact,
80            "SessionEnd" => Self::SessionEnd,
81            "Notification" => Self::Notification,
82            other => Self::Other(other.to_owned()),
83        }
84    }
85
86    pub fn as_str(&self) -> &str {
87        match self {
88            Self::SessionStart => "SessionStart",
89            Self::UserPromptSubmit => "UserPromptSubmit",
90            Self::PreToolUse => "PreToolUse",
91            Self::PostToolUse => "PostToolUse",
92            Self::PostToolUseFailure => "PostToolUseFailure",
93            Self::Stop => "Stop",
94            Self::SubagentStart => "SubagentStart",
95            Self::SubagentStop => "SubagentStop",
96            Self::PreCompact => "PreCompact",
97            Self::PostCompact => "PostCompact",
98            Self::SessionEnd => "SessionEnd",
99            Self::Notification => "Notification",
100            Self::Other(name) => name,
101        }
102    }
103
104    /// Events whose host schema accepts `hookSpecificOutput.additionalContext` in shipped
105    /// Pulse (`SessionStart`, `UserPromptSubmit`, `PostToolUse`). Other events would make the
106    /// host reject the whole response, so the output builders refuse them.
107    pub fn takes_additional_context(&self) -> bool {
108        matches!(
109            self,
110            Self::SessionStart | Self::UserPromptSubmit | Self::PostToolUse
111        )
112    }
113}
114
115impl Serialize for EventKind {
116    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
117        serializer.serialize_str(self.as_str())
118    }
119}
120
121impl<'de> Deserialize<'de> for EventKind {
122    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
123        let name = String::deserialize(deserializer)?;
124        Ok(Self::from_name(&name))
125    }
126}
127
128impl fmt::Display for EventKind {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.write_str(self.as_str())
131    }
132}
133
134/// Why a payload was not accepted.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum ParseError {
137    /// The bytes are not JSON.
138    Malformed(String),
139    /// The JSON is not an object (`HookHost payload must be an object`).
140    NotObject,
141    /// No event name field (`HookHost event is required`).
142    EventRequired,
143    /// Input larger than [`STDIN_CAP`].
144    TooLarge,
145    /// Reading stdin failed.
146    Io(String),
147}
148
149impl fmt::Display for ParseError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Malformed(e) => write!(f, "hook payload is not JSON: {e}"),
153            Self::NotObject => f.write_str("HookHost payload must be an object"),
154            Self::EventRequired => f.write_str("HookHost event is required"),
155            Self::TooLarge => write!(f, "hook payload exceeds {STDIN_CAP} bytes"),
156            Self::Io(e) => write!(f, "hook payload could not be read: {e}"),
157        }
158    }
159}
160
161impl std::error::Error for ParseError {}
162
163/// One hook invocation, normalized.
164#[derive(Clone, Debug, PartialEq)]
165pub struct HookEvent {
166    pub wire: Wire,
167    pub kind: EventKind,
168    /// `thread_id`, `session_id` or `sessionId`, first present non-blank string in that order.
169    pub session_id: Option<String>,
170    pub tool_name: Option<String>,
171    pub tool_use_id: Option<String>,
172    pub cwd: Option<String>,
173    pub transcript_path: Option<String>,
174    /// Codex's per-turn id (Legion's Codex Stop fixture).
175    pub turn_id: Option<String>,
176    /// Claude marks a subagent's hook input with `agent_id`.
177    pub agent_id: Option<String>,
178    pub parent_tool_use_id: Option<String>,
179    /// `prompt`, falling back to `user_prompt` (Membrane's reader).
180    pub prompt: Option<String>,
181    pub stop_hook_active: Option<bool>,
182    pub last_assistant_message: Option<String>,
183    pub tool_input: Option<Value>,
184    /// The original payload, unmodified.
185    pub raw: Value,
186}
187
188fn first_string(object: &serde_json::Map<String, Value>, names: &[&str]) -> Option<String> {
189    names.iter().find_map(|name| {
190        object
191            .get(*name)
192            .and_then(Value::as_str)
193            .filter(|value| !value.trim().is_empty())
194            .map(str::to_owned)
195    })
196}
197
198impl HookEvent {
199    /// Normalize a payload that is already JSON.
200    pub fn from_value(wire: Wire, payload: Value) -> Result<Self, ParseError> {
201        let object = payload.as_object().ok_or(ParseError::NotObject)?;
202        let name = first_string(object, &["hook_event_name", "hookEventName", "event"])
203            .ok_or(ParseError::EventRequired)?;
204        Ok(Self {
205            wire,
206            kind: EventKind::from_name(&name),
207            session_id: first_string(object, &["thread_id", "session_id", "sessionId"]),
208            tool_name: first_string(object, &["tool_name", "toolName"]),
209            tool_use_id: first_string(object, &["tool_use_id", "toolUseId"]),
210            cwd: first_string(object, &["cwd"]),
211            transcript_path: first_string(object, &["transcript_path"]),
212            turn_id: first_string(object, &["turn_id"]),
213            agent_id: first_string(object, &["agent_id"]),
214            parent_tool_use_id: first_string(object, &["parent_tool_use_id"]),
215            prompt: first_string(object, &["prompt", "user_prompt"]),
216            stop_hook_active: object.get("stop_hook_active").and_then(Value::as_bool),
217            last_assistant_message: first_string(object, &["last_assistant_message"]),
218            tool_input: object.get("tool_input").cloned(),
219            raw: payload,
220        })
221    }
222
223    pub fn from_slice(wire: Wire, bytes: &[u8]) -> Result<Self, ParseError> {
224        let value: Value =
225            serde_json::from_slice(bytes).map_err(|e| ParseError::Malformed(e.to_string()))?;
226        Self::from_value(wire, value)
227    }
228
229    /// Read at most [`STDIN_CAP`] bytes and parse. Larger input is [`ParseError::TooLarge`].
230    pub fn from_reader(wire: Wire, reader: impl Read) -> Result<Self, ParseError> {
231        let mut raw = Vec::new();
232        reader
233            .take(STDIN_CAP + 1)
234            .read_to_end(&mut raw)
235            .map_err(|e| ParseError::Io(e.to_string()))?;
236        if raw.len() as u64 > STDIN_CAP {
237            return Err(ParseError::TooLarge);
238        }
239        Self::from_slice(wire, &raw)
240    }
241
242    /// Whether this hook belongs to the chat's own turn rather than a subagent's, Pulse's rule:
243    /// every event but `PostToolUse` is the chat's own; for `PostToolUse` Claude's subagent hooks
244    /// carry `agent_id`, and Codex reports the parent's session id with a different transcript
245    /// file, so the transcript file name must contain the session id.
246    pub fn is_own_turn(&self) -> bool {
247        if self.kind != EventKind::PostToolUse {
248            return true;
249        }
250        match self.wire {
251            Wire::Claude => self.agent_id.is_none(),
252            Wire::Codex => match (&self.session_id, &self.transcript_path) {
253                (Some(session), Some(path)) => Path::new(path)
254                    .file_name()
255                    .is_some_and(|name| name.to_string_lossy().contains(session.as_str())),
256                _ => false,
257            },
258        }
259    }
260}