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}