1use serde_json::Value;
9use std::fmt;
10use std::io::Read;
11use std::path::Path;
12
13pub const STDIN_CAP: u64 = 4 * 1024 * 1024;
15
16#[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#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum ParseError {
123 Malformed(String),
125 NotObject,
127 EventRequired,
129 TooLarge,
131 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#[derive(Clone, Debug, PartialEq)]
151pub struct HookEvent {
152 pub wire: Wire,
153 pub kind: EventKind,
154 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 pub turn_id: Option<String>,
162 pub agent_id: Option<String>,
164 pub parent_tool_use_id: Option<String>,
165 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 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 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 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 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}