Skip to main content

oby_core/
hook.rs

1use serde::Deserialize;
2use std::path::PathBuf;
3
4/// Mirrors the PreToolUse / PostToolUse payload Claude Code sends to a hook on stdin.
5/// Field schema empirically verified against CC 2.1.142 (see docs/architecture.md, Appendix A).
6#[derive(Debug, Clone, Deserialize)]
7pub struct HookContext {
8    pub session_id: String,
9    pub transcript_path: PathBuf,
10    pub cwd: PathBuf,
11    pub hook_event_name: HookEvent,
12    /// Present on tool-use events; absent on session-level events like
13    /// SubagentStop. Default to empty string so the same struct deserializes
14    /// for both.
15    #[serde(default)]
16    pub tool_name: String,
17    #[serde(default)]
18    pub tool_use_id: String,
19    #[serde(default)]
20    pub permission_mode: Option<String>,
21    #[serde(default)]
22    pub effort: Option<EffortLevel>,
23    /// Present iff the call came from inside a subagent. This is the routing key.
24    #[serde(default)]
25    pub agent_id: Option<String>,
26    /// Subagent type name (e.g. "general-purpose"). Present iff `agent_id` is.
27    #[serde(default)]
28    pub agent_type: Option<String>,
29}
30
31impl HookContext {
32    /// `"main"` for the main agent, the `agent_id` otherwise. Stable per-agent routing key.
33    pub fn agent_key(&self) -> &str {
34        self.agent_id.as_deref().unwrap_or("main")
35    }
36}
37
38#[derive(Debug, Copy, Clone, Deserialize, PartialEq, Eq)]
39pub enum HookEvent {
40    #[serde(rename = "PreToolUse")]
41    Pre,
42    #[serde(rename = "PostToolUse")]
43    Post,
44    /// Fires when a tool call returned an error. CC routes failures here
45    /// instead of `PostToolUse`, so without an explicit handler the entry
46    /// would stay pending on any failed Read/Bash/etc.
47    #[serde(rename = "PostToolUseFailure")]
48    PostFailure,
49    /// Fires when a subagent is torn down (its Task call returned). The
50    /// payload includes the agent_id of the destroyed subagent; the wrapper
51    /// uses it to mark the corresponding ring as destroyed (status dot
52    /// flips from green to red).
53    #[serde(rename = "SubagentStop")]
54    SubagentStop,
55}
56
57#[derive(Debug, Clone, Deserialize)]
58pub struct EffortLevel {
59    pub level: String,
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    // Captured from the empirical probe (see docs/architecture.md Appendix A).
67    const MAIN_AGENT_PAYLOAD: &str = r#"{
68        "session_id": "a9db5455-5a02-44b1-b807-0bf79d80e6b1",
69        "transcript_path": "/Users/brandon/.claude/projects/-private-tmp-ccprobe/a9db5455-5a02-44b1-b807-0bf79d80e6b1.jsonl",
70        "cwd": "/private/tmp/ccprobe",
71        "hook_event_name": "PreToolUse",
72        "tool_name": "Bash",
73        "tool_use_id": "toolu_01",
74        "permission_mode": "bypassPermissions",
75        "effort": {"level": "medium"}
76    }"#;
77
78    const SUBAGENT_PAYLOAD: &str = r#"{
79        "session_id": "a9db5455-5a02-44b1-b807-0bf79d80e6b1",
80        "transcript_path": "/Users/brandon/.claude/projects/-private-tmp-ccprobe/a9db5455-5a02-44b1-b807-0bf79d80e6b1.jsonl",
81        "cwd": "/private/tmp/ccprobe",
82        "hook_event_name": "PreToolUse",
83        "tool_name": "Bash",
84        "tool_use_id": "toolu_02",
85        "agent_id": "a56e70ccdc442bf74",
86        "agent_type": "general-purpose"
87    }"#;
88
89    #[test]
90    fn deserializes_main_agent_payload() {
91        let ctx: HookContext = serde_json::from_str(MAIN_AGENT_PAYLOAD).unwrap();
92        assert_eq!(ctx.tool_name, "Bash");
93        assert_eq!(ctx.hook_event_name, HookEvent::Pre);
94        assert!(ctx.agent_id.is_none());
95        assert!(ctx.agent_type.is_none());
96        assert_eq!(ctx.agent_key(), "main");
97    }
98
99    /// SubagentStop is a session-level event — its payload has agent_id and
100    /// agent_type but NO tool_name or tool_use_id (it's not a tool call).
101    /// Earlier the struct required tool_name/tool_use_id, so deserialization
102    /// silently failed and the destruction signal was dropped on the floor.
103    /// This payload must round-trip cleanly so SubagentStop hooks land.
104    const SUBAGENT_STOP_PAYLOAD: &str = r#"{
105        "session_id": "abc",
106        "transcript_path": "/t",
107        "cwd": "/c",
108        "hook_event_name": "SubagentStop",
109        "agent_id": "subagent-xyz",
110        "agent_type": "general-purpose"
111    }"#;
112
113    #[test]
114    fn deserializes_subagent_stop_payload() {
115        let ctx: HookContext = serde_json::from_str(SUBAGENT_STOP_PAYLOAD).unwrap();
116        assert_eq!(ctx.hook_event_name, HookEvent::SubagentStop);
117        assert_eq!(ctx.agent_id.as_deref(), Some("subagent-xyz"));
118        assert_eq!(ctx.agent_key(), "subagent-xyz");
119        assert_eq!(ctx.tool_name, "");
120        assert_eq!(ctx.tool_use_id, "");
121    }
122
123    #[test]
124    fn deserializes_subagent_payload() {
125        let ctx: HookContext = serde_json::from_str(SUBAGENT_PAYLOAD).unwrap();
126        assert_eq!(ctx.agent_id.as_deref(), Some("a56e70ccdc442bf74"));
127        assert_eq!(ctx.agent_type.as_deref(), Some("general-purpose"));
128        assert_eq!(ctx.agent_key(), "a56e70ccdc442bf74");
129    }
130}