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