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,
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 | CodexHookEvent::PostCompact => json!({}),
87        CodexHookEvent::Stop => stop_payload(req),
88    };
89    Ok(RenderedHookPayload {
90        hook_event_name: Some(event.as_str()),
91        body,
92    })
93}
94
95fn session_start_payload(req: &RenderRequest<'_>) -> Value {
96    json!({
97        "hookSpecificOutput": {
98            "hookEventName": CodexHookEvent::SessionStart.as_str(),
99            "additionalContext": render_additional_context(req.payloads),
100        }
101    })
102}
103
104fn user_prompt_submit_payload(req: &RenderRequest<'_>) -> Value {
105    let mut payload = json!({
106        "hookSpecificOutput": {
107            "hookEventName": CodexHookEvent::UserPromptSubmit.as_str(),
108            "additionalContext": render_additional_context(req.payloads),
109        }
110    });
111
112    if let Some(FrameAdmissionDirective::Block { reason }) = req.directive {
113        let obj = payload
114            .as_object_mut()
115            .expect("user_prompt_submit payload is a json object");
116        obj.insert("decision".to_string(), Value::String("block".to_string()));
117        obj.insert("reason".to_string(), Value::String(reason.clone()));
118    }
119
120    payload
121}
122
123fn stop_payload(req: &RenderRequest<'_>) -> Value {
124    if let Some(FrameAdmissionDirective::Block { reason }) = req.directive {
125        return json!({
126            "decision": "block",
127            "reason": reason,
128        });
129    }
130    json!({})
131}