lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Codex hook-protocol rendering.
//!
//! Maps neutral [`LifecycleEventKind`] inputs to the Codex hook event
//! vocabulary (`SessionStart`, `UserPromptSubmit`, `PreCompact`, `PostCompact`,
//! `Stop`) and renders the per-event JSON payload Codex consumes on the hook's
//! stdout.
//!
//! Codex's `Stop` hook treats `decision: "block"` as a continuation
//! request: Codex synthesizes a follow-up prompt from `reason` rather
//! than rejecting the turn outright. The renderer mirrors that
//! contract: a [`super::FrameAdmissionDirective::Block`] on a
//! `frame.ending`/`frame.ended` event maps to that Stop payload.

use crate::LifecycleEventKind;
use serde_json::{Value, json};

use super::{
    FrameAdmissionDirective, ProtocolAdapter, RenderError, RenderRequest, RenderedHookPayload,
    render_additional_context,
};

/// Codex hook event tokens this renderer can emit.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum CodexHookEvent {
    SessionStart,
    UserPromptSubmit,
    PreCompact,
    PostCompact,
    Stop,
}

impl CodexHookEvent {
    pub const ALL: &'static [Self] = &[
        Self::SessionStart,
        Self::UserPromptSubmit,
        Self::PreCompact,
        Self::PostCompact,
        Self::Stop,
    ];

    pub fn as_str(self) -> &'static str {
        match self {
            Self::SessionStart => "SessionStart",
            Self::UserPromptSubmit => "UserPromptSubmit",
            Self::PreCompact => "PreCompact",
            Self::PostCompact => "PostCompact",
            Self::Stop => "Stop",
        }
    }
}

/// Map a neutral [`LifecycleEventKind`] to the Codex hook event the
/// adapter surfaces for it. Returns `None` for events Codex's hook
/// protocol does not expose.
pub fn codex_hook_event_for(event: LifecycleEventKind) -> Option<CodexHookEvent> {
    match event {
        LifecycleEventKind::SessionStarting | LifecycleEventKind::SessionStarted => {
            Some(CodexHookEvent::SessionStart)
        }
        LifecycleEventKind::FrameOpening => Some(CodexHookEvent::UserPromptSubmit),
        LifecycleEventKind::ContextPressureObserved => Some(CodexHookEvent::PreCompact),
        LifecycleEventKind::ContextCompacted => Some(CodexHookEvent::PostCompact),
        LifecycleEventKind::FrameEnding | LifecycleEventKind::FrameEnded => {
            Some(CodexHookEvent::Stop)
        }
        LifecycleEventKind::FrameOpened
        | LifecycleEventKind::SessionEnding
        | LifecycleEventKind::SessionEnded
        | LifecycleEventKind::SupervisorTick
        | LifecycleEventKind::CapabilityDegraded
        | LifecycleEventKind::ReceiptEmitted
        | LifecycleEventKind::ReceiptGapDetected => None,
    }
}

pub(crate) fn render(req: &RenderRequest<'_>) -> Result<RenderedHookPayload, RenderError> {
    let Some(event) = codex_hook_event_for(req.event) else {
        return Err(RenderError::UnsupportedEvent {
            adapter: ProtocolAdapter::Codex,
            event: req.event,
        });
    };
    let body = match event {
        CodexHookEvent::SessionStart => session_start_payload(req),
        CodexHookEvent::UserPromptSubmit => user_prompt_submit_payload(req),
        CodexHookEvent::PreCompact | CodexHookEvent::PostCompact => json!({}),
        CodexHookEvent::Stop => stop_payload(req),
    };
    Ok(RenderedHookPayload {
        hook_event_name: Some(event.as_str()),
        body,
    })
}

fn session_start_payload(req: &RenderRequest<'_>) -> Value {
    json!({
        "hookSpecificOutput": {
            "hookEventName": CodexHookEvent::SessionStart.as_str(),
            "additionalContext": render_additional_context(req.payloads),
        }
    })
}

fn user_prompt_submit_payload(req: &RenderRequest<'_>) -> Value {
    let mut payload = json!({
        "hookSpecificOutput": {
            "hookEventName": CodexHookEvent::UserPromptSubmit.as_str(),
            "additionalContext": render_additional_context(req.payloads),
        }
    });

    if let Some(FrameAdmissionDirective::Block { reason }) = req.directive {
        let obj = payload
            .as_object_mut()
            .expect("user_prompt_submit payload is a json object");
        obj.insert("decision".to_string(), Value::String("block".to_string()));
        obj.insert("reason".to_string(), Value::String(reason.clone()));
    }

    payload
}

fn stop_payload(req: &RenderRequest<'_>) -> Value {
    if let Some(FrameAdmissionDirective::Block { reason }) = req.directive {
        return json!({
            "decision": "block",
            "reason": reason,
        });
    }
    json!({})
}