Skip to main content

lifeloop/protocol/
codex.rs

1//! Codex hook-protocol rendering.
2//!
3//! Maps neutral [`LifecycleEventKind`] inputs to the Codex hook event
4//! vocabulary (`SessionStart`, `UserPromptSubmit`, `PreCompact`, `PostCompact`,
5//! `Stop`) and renders the per-event JSON payload Codex consumes on the hook's
6//! stdout.
7//!
8//! Codex's `Stop` hook treats `decision: "block"` as a continuation
9//! request: Codex synthesizes a follow-up prompt from `reason` rather
10//! than rejecting the turn outright. The renderer mirrors that
11//! contract: a [`super::FrameAdmissionDirective::Block`] on a
12//! `frame.ending`/`frame.ended` event maps to that Stop payload.
13
14use crate::LifecycleEventKind;
15use serde_json::{Value, json};
16
17use super::{
18    FrameAdmissionDirective, ProtocolAdapter, RenderError, RenderRequest, RenderedHookPayload,
19    render_additional_context, render_side_channel_context,
20};
21
22/// Codex hook event tokens this renderer can emit.
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
24pub enum CodexHookEvent {
25    SessionStart,
26    UserPromptSubmit,
27    PreCompact,
28    PostCompact,
29    Stop,
30}
31
32impl CodexHookEvent {
33    pub const ALL: &'static [Self] = &[
34        Self::SessionStart,
35        Self::UserPromptSubmit,
36        Self::PreCompact,
37        Self::PostCompact,
38        Self::Stop,
39    ];
40
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::SessionStart => "SessionStart",
44            Self::UserPromptSubmit => "UserPromptSubmit",
45            Self::PreCompact => "PreCompact",
46            Self::PostCompact => "PostCompact",
47            Self::Stop => "Stop",
48        }
49    }
50}
51
52/// Map a neutral [`LifecycleEventKind`] to the Codex hook event the
53/// adapter surfaces for it. Returns `None` for events Codex's hook
54/// protocol does not expose.
55pub fn codex_hook_event_for(event: LifecycleEventKind) -> Option<CodexHookEvent> {
56    match event {
57        LifecycleEventKind::SessionStarting | LifecycleEventKind::SessionStarted => {
58            Some(CodexHookEvent::SessionStart)
59        }
60        LifecycleEventKind::FrameOpening => Some(CodexHookEvent::UserPromptSubmit),
61        LifecycleEventKind::ContextPressureObserved => Some(CodexHookEvent::PreCompact),
62        LifecycleEventKind::ContextCompacted => Some(CodexHookEvent::PostCompact),
63        LifecycleEventKind::FrameEnding | LifecycleEventKind::FrameEnded => {
64            Some(CodexHookEvent::Stop)
65        }
66        LifecycleEventKind::FrameOpened
67        | LifecycleEventKind::SessionEnding
68        | LifecycleEventKind::SessionEnded
69        | LifecycleEventKind::SupervisorTick
70        | LifecycleEventKind::CapabilityDegraded
71        | LifecycleEventKind::ReceiptEmitted
72        | LifecycleEventKind::ReceiptGapDetected => None,
73    }
74}
75
76pub(crate) fn render(req: &RenderRequest<'_>) -> Result<RenderedHookPayload, RenderError> {
77    let Some(event) = codex_hook_event_for(req.event) else {
78        return Err(RenderError::UnsupportedEvent {
79            adapter: ProtocolAdapter::Codex,
80            event: req.event,
81        });
82    };
83    let body = match event {
84        CodexHookEvent::SessionStart => session_start_payload(req),
85        CodexHookEvent::UserPromptSubmit => user_prompt_submit_payload(req),
86        CodexHookEvent::PreCompact => json!({}),
87        CodexHookEvent::PostCompact => post_compact_payload(req),
88        CodexHookEvent::Stop => stop_payload(req),
89    };
90    Ok(RenderedHookPayload {
91        hook_event_name: Some(event.as_str()),
92        body,
93    })
94}
95
96fn session_start_payload(req: &RenderRequest<'_>) -> Value {
97    json!({
98        "hookSpecificOutput": {
99            "hookEventName": CodexHookEvent::SessionStart.as_str(),
100            "additionalContext": render_additional_context(req.payloads),
101        }
102    })
103}
104
105fn user_prompt_submit_payload(req: &RenderRequest<'_>) -> Value {
106    let mut payload = json!({
107        "hookSpecificOutput": {
108            "hookEventName": CodexHookEvent::UserPromptSubmit.as_str(),
109            "additionalContext": render_additional_context(req.payloads),
110        }
111    });
112
113    if let Some(FrameAdmissionDirective::Block { reason }) = req.directive {
114        let obj = payload
115            .as_object_mut()
116            .expect("user_prompt_submit payload is a json object");
117        obj.insert("decision".to_string(), Value::String("block".to_string()));
118        obj.insert("reason".to_string(), Value::String(reason.clone()));
119    }
120
121    payload
122}
123
124fn post_compact_payload(req: &RenderRequest<'_>) -> Value {
125    let additional_context = render_side_channel_context(req.payloads);
126    if additional_context == "{}" {
127        return json!({});
128    }
129
130    json!({
131        "hookSpecificOutput": {
132            "hookEventName": CodexHookEvent::PostCompact.as_str(),
133            "additionalContext": additional_context,
134        }
135    })
136}
137
138fn stop_payload(req: &RenderRequest<'_>) -> Value {
139    if let Some(FrameAdmissionDirective::Block { reason }) = req.directive {
140        return json!({
141            "decision": "block",
142            "reason": reason,
143        });
144    }
145    json!({})
146}