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
338/// Build the harness `additionalContext` string from the eligible
339/// payloads in the request.
340///
341/// # Lifeloop transport envelope
342///
343/// For harness hook protocols (Claude Code, Codex) that surface a
344/// single `hookSpecificOutput.additionalContext` slot per event,
345/// Lifeloop renders a transport envelope of the form:
346///
347/// ```json
348/// {
349/// "payloads": [
350/// { "payload_id": "...", "payload_kind": "...", "body": "<verbatim string>" },
351/// { "payload_id": "...", "payload_kind": "...", "body_ref": "..." }
352/// ]
353/// }
354/// ```
355///
356/// Each payload whose [`PlacementClass`] passes
357/// [`placement_targets_pre_prompt`] becomes exactly one object in the
358/// `payloads` array, in input order.
359///
360/// # Body opacity (issue #21, spec body.md line 385)
361///
362/// `body` and `body_ref` are passed through verbatim:
363///
364/// * If [`PayloadEnvelope::body`] is `Some(s)`, it is emitted as a
365/// JSON **string** under the key `body`. Lifeloop never calls
366/// `serde_json::from_str` on it — a body that happens to be a JSON
367/// object literal is carried as a string, preserving opacity and
368/// keeping overlapping JSON keys across payloads distinguishable.
369/// * Else if [`PayloadEnvelope::body_ref`] is `Some(r)`, it is emitted
370/// under the key `body_ref` and is never dereferenced here.
371/// * If both are `None`, the payload is skipped entirely (no eligible
372/// body to transport).
373///
374/// `payloads` is the only Lifeloop-reserved key in the wrapper.
375///
376/// If no eligible payloads are present, returns the literal `"{}"` to
377/// match the harness's quiet-default empty-context shape.
378pub(crate) fn render_additional_context(payloads: &[ProtocolPayload<'_>]) -> String {
379 let mut entries: Vec<Value> = Vec::new();
380
381 for p in payloads {
382 if !placement_targets_pre_prompt(p.placement) {
383 continue;
384 }
385 let mut entry = serde_json::Map::new();
386 entry.insert(
387 "payload_id".to_string(),
388 Value::String(p.envelope.payload_id.clone()),
389 );
390 entry.insert(
391 "payload_kind".to_string(),
392 Value::String(p.envelope.payload_kind.clone()),
393 );
394 match (&p.envelope.body, &p.envelope.body_ref) {
395 (Some(b), _) => {
396 // Verbatim body string. Never parsed — preserves opacity
397 // and keeps overlapping keys across payloads distinguishable.
398 entry.insert("body".to_string(), Value::String(b.clone()));
399 }
400 (None, Some(r)) => {
401 entry.insert("body_ref".to_string(), Value::String(r.clone()));
402 }
403 (None, None) => continue,
404 }
405 entries.push(Value::Object(entry));
406 }
407
408 if entries.is_empty() {
409 return "{}".to_string();
410 }
411
412 let mut wrapper = serde_json::Map::new();
413 wrapper.insert("payloads".to_string(), Value::Array(entries));
414 serde_json::to_string_pretty(&Value::Object(wrapper)).unwrap_or_else(|_| "{}".to_string())
415}