Skip to main content

lifeloop/protocol/
mod.rs

1//! Hook-protocol payload rendering for harness adapters.
2//!
3//! Lifeloop owns the translation from neutral lifecycle inputs (event kind,
4//! adapter identity, integration mode, frame context, opaque payload
5//! envelopes, placement classes, frame-admission directives) into the
6//! adapter-shaped JSON the harness's hook protocol consumes on stdout.
7//!
8//! # Boundary (issue #3)
9//!
10//! This module owns:
11//! * mapping a [`LifecycleEventKind`] plus an adapter id to the harness's
12//!   own hook event name (e.g. Claude's `SessionStart`, Codex's
13//!   `UserPromptSubmit`),
14//! * rendering the per-event JSON payload with optional contextual
15//!   payloads and frame-admission directives,
16//! * the neutral [`FrameAdmissionDirective`] vocabulary that lets a client
17//!   tell a harness "block the next input" or "request a continuation"
18//!   without naming any client-specific session policy.
19//!
20//! This module does **not** own:
21//! * the meaning of those directives (a client decides when to block,
22//!   when to allow);
23//! * memory, recall, promotion, compaction, or any other client
24//!   continuity vocabulary;
25//! * receipt emission (callers wrap the rendered payload in their own
26//!   receipt flow);
27//! * filesystem IO, hook registration, or asset installation (issue #4
28//!   `host_assets` owns that),
29//! * adapter manifest negotiation (issue #6).
30//!
31//! # Compatibility labels
32//!
33//! Hook event names like `"SessionStart"`, `"UserPromptSubmit"`,
34//! `"PreCompact"`, `"Stop"`, `"SessionEnd"` are **harness-defined wire
35//! tokens**, not Lifeloop semantics. They appear here only because the
36//! harness's hook protocol contract requires them as the
37//! `hookSpecificOutput.hookEventName` value. They are documented here
38//! analogously to the `CCD_COMPAT_*` pattern in [`crate::host_assets`]:
39//! external-vocabulary tokens grouped in one auditable place.
40//!
41//! # Format-agnostic rendering
42//!
43//! The payload body is produced as `serde_json::Value` because the
44//! current harness hook protocols (Claude Code, Codex) consume JSON on
45//! stdout. The renderer entry point also exposes a string form
46//! ([`RenderedHookPayload::body_string`]) for direct stdout emission.
47
48use crate::{FrameContext, IntegrationMode, LifecycleEventKind, PayloadEnvelope, PlacementClass};
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51
52mod claude;
53mod codex;
54
55pub use claude::{ClaudeHookEvent, claude_hook_event_for};
56pub use codex::{CodexHookEvent, codex_hook_event_for};
57
58// ============================================================================
59// Neutral inputs
60// ============================================================================
61
62/// Adapter targeted by a render. Renderer dispatch is keyed on this.
63///
64/// The wire string mirrors the canonical adapter id used elsewhere
65/// (`AdapterManifest::adapter_id`, [`crate::host_assets::HostAdapter`]).
66#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum ProtocolAdapter {
69    Claude,
70    Codex,
71}
72
73impl ProtocolAdapter {
74    pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex];
75
76    pub fn as_str(self) -> &'static str {
77        match self {
78            Self::Claude => "claude",
79            Self::Codex => "codex",
80        }
81    }
82
83    /// Recognizes the canonical adapter id; returns `None` for unknown
84    /// names. Aliases used by host-asset rendering (e.g. `claude-code`)
85    /// are accepted so callers can pass through whatever the harness
86    /// reports.
87    pub fn from_id(value: &str) -> Option<Self> {
88        match value {
89            "claude" | "claude-code" => Some(Self::Claude),
90            "codex" => Some(Self::Codex),
91            _ => None,
92        }
93    }
94}
95
96/// Neutral directive a client can attach to a render request to ask the
97/// harness to admit, block, or request continuation of the next
98/// lifecycle moment.
99///
100/// This vocabulary is intentionally policy-free: it carries the
101/// transport intent (allow/block) and a free-form `reason` the harness
102/// surfaces to the model. Clients decide *why* to block; Lifeloop only
103/// transports the directive.
104#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
105#[serde(tag = "kind", rename_all = "snake_case")]
106pub enum FrameAdmissionDirective {
107    /// Default: do nothing, let the harness proceed.
108    Allow,
109    /// Ask the harness to block the next input/turn and surface `reason`
110    /// to the model. On harnesses where `block` means "request a
111    /// continuation prompt" (Codex `Stop`), the same shape applies.
112    Block { reason: String },
113}
114
115impl FrameAdmissionDirective {
116    pub fn allow() -> Self {
117        Self::Allow
118    }
119
120    pub fn block(reason: impl Into<String>) -> Self {
121        Self::Block {
122            reason: reason.into(),
123        }
124    }
125
126    pub fn is_block(&self) -> bool {
127        matches!(self, Self::Block { .. })
128    }
129}
130
131/// One opaque contextual payload to render into the harness's
132/// per-event payload slot (e.g. Claude's `additionalContext`).
133///
134/// Lifeloop does not parse or rewrite the payload body — it is
135/// delivered as the client supplied it. The `placement` field is the
136/// trust-neutral routing class the client requested for this payload;
137/// the renderer uses it only to decide which payload slot the body goes
138/// into and to skip payloads whose placement is not appropriate for
139/// the target event.
140#[derive(Clone, Debug)]
141pub struct ProtocolPayload<'a> {
142    pub envelope: &'a PayloadEnvelope,
143    pub placement: PlacementClass,
144}
145
146impl<'a> ProtocolPayload<'a> {
147    pub fn new(envelope: &'a PayloadEnvelope, placement: PlacementClass) -> Self {
148        Self {
149            envelope,
150            placement,
151        }
152    }
153}
154
155/// Inputs for a hook-protocol render.
156///
157/// `adapter_id` and `adapter_version` are passed as neutral strings so
158/// the renderer can be invoked without a manifest registry lookup;
159/// callers that have a [`crate::AdapterManifest`] in hand can pass its
160/// fields directly. `integration_mode` and `frame` are accepted but
161/// currently advisory — they enable later renderer variants without
162/// signature churn.
163#[derive(Clone, Debug)]
164pub struct RenderRequest<'a> {
165    pub adapter: ProtocolAdapter,
166    pub adapter_id: &'a str,
167    pub adapter_version: &'a str,
168    pub integration_mode: IntegrationMode,
169    pub event: LifecycleEventKind,
170    pub frame: Option<&'a FrameContext>,
171    pub payloads: &'a [ProtocolPayload<'a>],
172    pub directive: Option<&'a FrameAdmissionDirective>,
173}
174
175impl<'a> RenderRequest<'a> {
176    /// Convenience constructor for the common no-payload, no-directive case.
177    pub fn minimal(
178        adapter: ProtocolAdapter,
179        adapter_id: &'a str,
180        adapter_version: &'a str,
181        integration_mode: IntegrationMode,
182        event: LifecycleEventKind,
183    ) -> Self {
184        Self {
185            adapter,
186            adapter_id,
187            adapter_version,
188            integration_mode,
189            event,
190            frame: None,
191            payloads: &[],
192            directive: None,
193        }
194    }
195}
196
197// ============================================================================
198// Output
199// ============================================================================
200
201/// A rendered harness hook-protocol payload, ready to be emitted on
202/// the harness's hook stdout.
203#[derive(Clone, Debug, Eq, PartialEq)]
204pub struct RenderedHookPayload {
205    /// The harness's wire token for the event (e.g. Claude's
206    /// `SessionStart`). May be `None` for events the adapter does not
207    /// surface a hook payload for under its own protocol.
208    pub hook_event_name: Option<&'static str>,
209    /// The JSON payload body the harness expects on stdout. For events
210    /// the adapter does not carry, this is `{}` (the harness's
211    /// quiet-default contract).
212    pub body: Value,
213}
214
215impl RenderedHookPayload {
216    /// Compact JSON suitable for emission on stdout.
217    pub fn body_string(&self) -> String {
218        serde_json::to_string(&self.body).unwrap_or_else(|_| "{}".to_string())
219    }
220}
221
222// ============================================================================
223// Render errors
224// ============================================================================
225
226/// Why a hook-protocol render failed.
227#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
228#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
229pub enum RenderError {
230    /// The adapter does not surface a hook payload for the requested
231    /// lifecycle event under its native hook protocol.
232    UnsupportedEvent {
233        adapter: ProtocolAdapter,
234        event: LifecycleEventKind,
235    },
236    /// The adapter id passed in does not match the requested adapter.
237    /// Renderer is paranoid about this so a client passing the wrong
238    /// adapter manifest cannot silently produce a Claude payload while
239    /// claiming Codex.
240    AdapterIdMismatch {
241        adapter: ProtocolAdapter,
242        adapter_id: String,
243    },
244    /// The frame-admission directive is invalid (e.g. a `Block` with an
245    /// empty `reason`).
246    InvalidDirective(String),
247}
248
249impl std::fmt::Display for RenderError {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match self {
252            Self::UnsupportedEvent { adapter, event } => {
253                write!(
254                    f,
255                    "adapter `{}` does not surface a hook payload for lifecycle event `{:?}`",
256                    adapter.as_str(),
257                    event
258                )
259            }
260            Self::AdapterIdMismatch {
261                adapter,
262                adapter_id,
263            } => write!(
264                f,
265                "adapter id `{}` does not match requested adapter `{}`",
266                adapter_id,
267                adapter.as_str()
268            ),
269            Self::InvalidDirective(msg) => write!(f, "invalid frame-admission directive: {msg}"),
270        }
271    }
272}
273
274impl std::error::Error for RenderError {}
275
276// ============================================================================
277// Entry point
278// ============================================================================
279
280/// Render a harness hook-protocol payload from neutral lifecycle inputs.
281///
282/// Dispatches on [`RenderRequest::adapter`]. Returns
283/// [`RenderError::UnsupportedEvent`] when the adapter's hook protocol
284/// does not surface a payload for the requested
285/// [`LifecycleEventKind`]; callers in routing layers may treat that as
286/// "skip emission" rather than an error. The future router (issue #7)
287/// is the expected primary caller of this entry point.
288pub fn render_hook_payload(req: &RenderRequest<'_>) -> Result<RenderedHookPayload, RenderError> {
289    if let Some(d) = req.directive {
290        validate_directive(d)?;
291    }
292    if !matches_adapter_id(req.adapter, req.adapter_id) {
293        return Err(RenderError::AdapterIdMismatch {
294            adapter: req.adapter,
295            adapter_id: req.adapter_id.to_string(),
296        });
297    }
298    match req.adapter {
299        ProtocolAdapter::Claude => claude::render(req),
300        ProtocolAdapter::Codex => codex::render(req),
301    }
302}
303
304fn validate_directive(d: &FrameAdmissionDirective) -> Result<(), RenderError> {
305    if let FrameAdmissionDirective::Block { reason } = d
306        && reason.trim().is_empty()
307    {
308        return Err(RenderError::InvalidDirective(
309            "Block directive requires a non-empty reason".into(),
310        ));
311    }
312    Ok(())
313}
314
315fn matches_adapter_id(adapter: ProtocolAdapter, id: &str) -> bool {
316    ProtocolAdapter::from_id(id) == Some(adapter)
317}
318
319// ============================================================================
320// Shared rendering helpers (used by claude.rs and codex.rs)
321// ============================================================================
322
323/// The set of payload placements that flow into the per-event
324/// "additional context" slot the Claude/Codex hook protocols expose
325/// inside `hookSpecificOutput.additionalContext`.
326///
327/// This is a routing decision local to the renderer: payloads whose
328/// placement is one of these classes are concatenated into the
329/// additional-context slot; others are ignored by the renderer (they
330/// flow through other Lifeloop transports).
331pub(crate) fn placement_targets_pre_prompt(p: PlacementClass) -> bool {
332    matches!(
333        p,
334        PlacementClass::PrePromptFrame | PlacementClass::DeveloperEquivalentFrame
335    )
336}
337
338pub(crate) fn placement_targets_side_channel(p: PlacementClass) -> bool {
339    matches!(p, PlacementClass::SideChannelContext)
340}
341
342/// Build the harness `additionalContext` string from the eligible
343/// payloads in the request.
344///
345/// # Lifeloop transport envelope
346///
347/// For harness hook protocols (Claude Code, Codex) that surface a
348/// single `hookSpecificOutput.additionalContext` slot per event,
349/// Lifeloop renders a transport envelope of the form:
350///
351/// ```json
352/// {
353///   "payloads": [
354///     { "payload_id": "...", "payload_kind": "...", "body": "<verbatim string>" },
355///     { "payload_id": "...", "payload_kind": "...", "body_ref": "..." }
356///   ]
357/// }
358/// ```
359///
360/// Each payload whose [`PlacementClass`] passes
361/// [`placement_targets_pre_prompt`] becomes exactly one object in the
362/// `payloads` array, in input order.
363///
364/// # Body opacity (issue #21, spec body.md line 385)
365///
366/// `body` and `body_ref` are passed through verbatim:
367///
368/// * If [`PayloadEnvelope::body`] is `Some(s)`, it is emitted as a
369///   JSON **string** under the key `body`. Lifeloop never calls
370///   `serde_json::from_str` on it — a body that happens to be a JSON
371///   object literal is carried as a string, preserving opacity and
372///   keeping overlapping JSON keys across payloads distinguishable.
373/// * Else if [`PayloadEnvelope::body_ref`] is `Some(r)`, it is emitted
374///   under the key `body_ref` and is never dereferenced here.
375/// * If both are `None`, the payload is skipped entirely (no eligible
376///   body to transport).
377///
378/// `payloads` is the only Lifeloop-reserved key in the wrapper.
379///
380/// If no eligible payloads are present, returns the literal `"{}"` to
381/// match the harness's quiet-default empty-context shape.
382pub(crate) fn render_additional_context(payloads: &[ProtocolPayload<'_>]) -> String {
383    render_context_payloads(payloads, placement_targets_pre_prompt)
384}
385
386pub(crate) fn render_side_channel_context(payloads: &[ProtocolPayload<'_>]) -> String {
387    render_context_payloads(payloads, placement_targets_side_channel)
388}
389
390fn render_context_payloads(
391    payloads: &[ProtocolPayload<'_>],
392    eligible: fn(PlacementClass) -> bool,
393) -> String {
394    let mut entries: Vec<Value> = Vec::new();
395
396    for p in payloads {
397        if !eligible(p.placement) {
398            continue;
399        }
400        let mut entry = serde_json::Map::new();
401        entry.insert(
402            "payload_id".to_string(),
403            Value::String(p.envelope.payload_id.clone()),
404        );
405        entry.insert(
406            "payload_kind".to_string(),
407            Value::String(p.envelope.payload_kind.clone()),
408        );
409        match (&p.envelope.body, &p.envelope.body_ref) {
410            (Some(b), _) => {
411                // Verbatim body string. Never parsed — preserves opacity
412                // and keeps overlapping keys across payloads distinguishable.
413                entry.insert("body".to_string(), Value::String(b.clone()));
414            }
415            (None, Some(r)) => {
416                entry.insert("body_ref".to_string(), Value::String(r.clone()));
417            }
418            (None, None) => continue,
419        }
420        entries.push(Value::Object(entry));
421    }
422
423    if entries.is_empty() {
424        return "{}".to_string();
425    }
426
427    let mut wrapper = serde_json::Map::new();
428    wrapper.insert("payloads".to_string(), Value::Array(entries));
429    serde_json::to_string_pretty(&Value::Object(wrapper)).unwrap_or_else(|_| "{}".to_string())
430}