use serde_json::Value;
use std::fmt;
use std::io::Read;
use std::path::Path;
pub const STDIN_CAP: u64 = 4 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Wire {
Claude,
Codex,
}
impl Wire {
pub fn parse(value: &str) -> Option<Self> {
match value {
"claude" => Some(Self::Claude),
"codex" => Some(Self::Codex),
_ => None,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
}
}
}
impl fmt::Display for Wire {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EventKind {
SessionStart,
UserPromptSubmit,
PreToolUse,
PostToolUse,
PostToolUseFailure,
Stop,
SubagentStart,
SubagentStop,
PreCompact,
PostCompact,
SessionEnd,
Notification,
Other(String),
}
impl EventKind {
pub fn from_name(name: &str) -> Self {
match name {
"SessionStart" | "session-start" => Self::SessionStart,
"UserPromptSubmit" | "user-prompt-submit" => Self::UserPromptSubmit,
"PreToolUse" | "pre-effect" => Self::PreToolUse,
"PostToolUse" | "post-effect" => Self::PostToolUse,
"PostToolUseFailure" | "post-effect-failure" => Self::PostToolUseFailure,
"Stop" | "stop" => Self::Stop,
"SubagentStart" | "subagent-start" => Self::SubagentStart,
"SubagentStop" | "subagent-stop" => Self::SubagentStop,
"PreCompact" | "pre-compact" => Self::PreCompact,
"PostCompact" | "post-compact" => Self::PostCompact,
"SessionEnd" => Self::SessionEnd,
"Notification" => Self::Notification,
other => Self::Other(other.to_owned()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::SessionStart => "SessionStart",
Self::UserPromptSubmit => "UserPromptSubmit",
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PostToolUseFailure => "PostToolUseFailure",
Self::Stop => "Stop",
Self::SubagentStart => "SubagentStart",
Self::SubagentStop => "SubagentStop",
Self::PreCompact => "PreCompact",
Self::PostCompact => "PostCompact",
Self::SessionEnd => "SessionEnd",
Self::Notification => "Notification",
Self::Other(name) => name,
}
}
pub fn takes_additional_context(&self) -> bool {
matches!(
self,
Self::SessionStart | Self::UserPromptSubmit | Self::PostToolUse
)
}
}
impl fmt::Display for EventKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
Malformed(String),
NotObject,
EventRequired,
TooLarge,
Io(String),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed(e) => write!(f, "hook payload is not JSON: {e}"),
Self::NotObject => f.write_str("HookHost payload must be an object"),
Self::EventRequired => f.write_str("HookHost event is required"),
Self::TooLarge => write!(f, "hook payload exceeds {STDIN_CAP} bytes"),
Self::Io(e) => write!(f, "hook payload could not be read: {e}"),
}
}
}
impl std::error::Error for ParseError {}
#[derive(Clone, Debug, PartialEq)]
pub struct HookEvent {
pub wire: Wire,
pub kind: EventKind,
pub session_id: Option<String>,
pub tool_name: Option<String>,
pub tool_use_id: Option<String>,
pub cwd: Option<String>,
pub transcript_path: Option<String>,
pub turn_id: Option<String>,
pub agent_id: Option<String>,
pub parent_tool_use_id: Option<String>,
pub prompt: Option<String>,
pub stop_hook_active: Option<bool>,
pub last_assistant_message: Option<String>,
pub tool_input: Option<Value>,
pub raw: Value,
}
fn first_string(object: &serde_json::Map<String, Value>, names: &[&str]) -> Option<String> {
names.iter().find_map(|name| {
object
.get(*name)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
})
}
impl HookEvent {
pub fn from_value(wire: Wire, payload: Value) -> Result<Self, ParseError> {
let object = payload.as_object().ok_or(ParseError::NotObject)?;
let name = first_string(object, &["hook_event_name", "hookEventName", "event"])
.ok_or(ParseError::EventRequired)?;
Ok(Self {
wire,
kind: EventKind::from_name(&name),
session_id: first_string(object, &["thread_id", "session_id", "sessionId"]),
tool_name: first_string(object, &["tool_name", "toolName"]),
tool_use_id: first_string(object, &["tool_use_id", "toolUseId"]),
cwd: first_string(object, &["cwd"]),
transcript_path: first_string(object, &["transcript_path"]),
turn_id: first_string(object, &["turn_id"]),
agent_id: first_string(object, &["agent_id"]),
parent_tool_use_id: first_string(object, &["parent_tool_use_id"]),
prompt: first_string(object, &["prompt", "user_prompt"]),
stop_hook_active: object.get("stop_hook_active").and_then(Value::as_bool),
last_assistant_message: first_string(object, &["last_assistant_message"]),
tool_input: object.get("tool_input").cloned(),
raw: payload,
})
}
pub fn from_slice(wire: Wire, bytes: &[u8]) -> Result<Self, ParseError> {
let value: Value =
serde_json::from_slice(bytes).map_err(|e| ParseError::Malformed(e.to_string()))?;
Self::from_value(wire, value)
}
pub fn from_reader(wire: Wire, reader: impl Read) -> Result<Self, ParseError> {
let mut raw = Vec::new();
reader
.take(STDIN_CAP + 1)
.read_to_end(&mut raw)
.map_err(|e| ParseError::Io(e.to_string()))?;
if raw.len() as u64 > STDIN_CAP {
return Err(ParseError::TooLarge);
}
Self::from_slice(wire, &raw)
}
pub fn is_own_turn(&self) -> bool {
if self.kind != EventKind::PostToolUse {
return true;
}
match self.wire {
Wire::Claude => self.agent_id.is_none(),
Wire::Codex => match (&self.session_id, &self.transcript_path) {
(Some(session), Some(path)) => Path::new(path)
.file_name()
.is_some_and(|name| name.to_string_lossy().contains(session.as_str())),
_ => false,
},
}
}
}