lifeloop/protocol/
claude.rs1use crate::LifecycleEventKind;
12use serde_json::{Value, json};
13
14use super::{
15 FrameAdmissionDirective, ProtocolAdapter, RenderError, RenderRequest, RenderedHookPayload,
16 render_additional_context,
17};
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
25pub enum ClaudeHookEvent {
26 SessionStart,
27 UserPromptSubmit,
28 PreCompact,
29 Stop,
30 SessionEnd,
31}
32
33impl ClaudeHookEvent {
34 pub const ALL: &'static [Self] = &[
35 Self::SessionStart,
36 Self::UserPromptSubmit,
37 Self::PreCompact,
38 Self::Stop,
39 Self::SessionEnd,
40 ];
41
42 pub fn as_str(self) -> &'static str {
43 match self {
44 Self::SessionStart => "SessionStart",
45 Self::UserPromptSubmit => "UserPromptSubmit",
46 Self::PreCompact => "PreCompact",
47 Self::Stop => "Stop",
48 Self::SessionEnd => "SessionEnd",
49 }
50 }
51}
52
53pub fn claude_hook_event_for(event: LifecycleEventKind) -> Option<ClaudeHookEvent> {
57 match event {
58 LifecycleEventKind::SessionStarting | LifecycleEventKind::SessionStarted => {
59 Some(ClaudeHookEvent::SessionStart)
60 }
61 LifecycleEventKind::FrameOpening => Some(ClaudeHookEvent::UserPromptSubmit),
62 LifecycleEventKind::ContextPressureObserved => Some(ClaudeHookEvent::PreCompact),
63 LifecycleEventKind::FrameEnding | LifecycleEventKind::FrameEnded => {
64 Some(ClaudeHookEvent::Stop)
65 }
66 LifecycleEventKind::SessionEnding | LifecycleEventKind::SessionEnded => {
67 Some(ClaudeHookEvent::SessionEnd)
68 }
69 LifecycleEventKind::FrameOpened
70 | LifecycleEventKind::ContextCompacted
71 | LifecycleEventKind::SupervisorTick
72 | LifecycleEventKind::CapabilityDegraded
73 | LifecycleEventKind::ReceiptEmitted
74 | LifecycleEventKind::ReceiptGapDetected => None,
75 }
76}
77
78pub(crate) fn render(req: &RenderRequest<'_>) -> Result<RenderedHookPayload, RenderError> {
79 let Some(event) = claude_hook_event_for(req.event) else {
80 return Err(RenderError::UnsupportedEvent {
81 adapter: ProtocolAdapter::Claude,
82 event: req.event,
83 });
84 };
85 let body = match event {
86 ClaudeHookEvent::SessionStart => session_start_payload(req),
87 ClaudeHookEvent::UserPromptSubmit => user_prompt_submit_payload(req),
88 ClaudeHookEvent::Stop | ClaudeHookEvent::PreCompact | ClaudeHookEvent::SessionEnd => {
89 json!({})
94 }
95 };
96 Ok(RenderedHookPayload {
97 hook_event_name: Some(event.as_str()),
98 body,
99 })
100}
101
102fn session_start_payload(req: &RenderRequest<'_>) -> Value {
103 json!({
104 "hookSpecificOutput": {
105 "hookEventName": ClaudeHookEvent::SessionStart.as_str(),
106 "additionalContext": render_additional_context(req.payloads),
107 }
108 })
109}
110
111fn user_prompt_submit_payload(req: &RenderRequest<'_>) -> Value {
112 let mut payload = json!({
113 "hookSpecificOutput": {
114 "hookEventName": ClaudeHookEvent::UserPromptSubmit.as_str(),
115 "additionalContext": render_additional_context(req.payloads),
116 }
117 });
118
119 if let Some(FrameAdmissionDirective::Block { reason }) = req.directive {
120 let obj = payload
121 .as_object_mut()
122 .expect("user_prompt_submit payload is a json object");
123 obj.insert("decision".to_string(), Value::String("block".to_string()));
126 obj.insert("reason".to_string(), Value::String(reason.clone()));
127 }
128
129 payload
130}