Skip to main content

lifeloop/protocol/
claude.rs

1//! Claude hook-protocol rendering.
2//!
3//! Maps neutral [`LifecycleEventKind`] inputs to the Claude Code hook
4//! event vocabulary (`SessionStart`, `UserPromptSubmit`, `PreCompact`,
5//! `Stop`, `SessionEnd`) and renders the per-event JSON payload Claude
6//! Code consumes on the hook's stdout.
7//!
8//! Hook event names here are external, harness-defined wire tokens —
9//! see the [`super`] module-level docs.
10
11use crate::LifecycleEventKind;
12use serde_json::{Value, json};
13
14use super::{
15    FrameAdmissionDirective, ProtocolAdapter, RenderError, RenderRequest, RenderedHookPayload,
16    render_additional_context,
17};
18
19/// The set of Claude hook event tokens this renderer can emit.
20///
21/// String values mirror the wire tokens Claude Code's hook contract
22/// publishes; they are not Lifeloop semantics. Variant order matches
23/// Claude's published lifecycle order.
24#[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
53/// Map a neutral [`LifecycleEventKind`] to the Claude hook event the
54/// adapter surfaces for it. Returns `None` for events Claude's hook
55/// protocol does not expose (e.g. `supervisor.tick`).
56pub 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            // Quiet-by-default. The frame-admission directive does not
90            // currently apply to these events under Claude's hook
91            // protocol; a future Lifeloop revision may map a `Block`
92            // directive to a Stop continuation request.
93            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        // Per Claude Code's hooks contract, `decision` and `reason`
124        // sit at the top level (not nested in `hookSpecificOutput`).
125        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}