Skip to main content

verbs/
identity_payload.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Parse harness hook stdin into an identity-cursor patch.
3//!
4//! Hot path: stdin JSON only. No JSONL, no `session.get`, no disk glob.
5//! Stamp what this event said. Missing fields stay unset so merge keeps
6//! the last cursor value.
7
8use std::collections::BTreeMap;
9
10use serde_json::Value;
11
12use crate::{
13    harness_json::{first_value_string, value_string},
14    identity_cursor::{
15        IdentityCursor, published_field, thought_level_from_payload, value_string_or_named,
16    },
17};
18
19/// Supported stamp harness names (install + stdin parsers).
20pub fn stamp_harness_name(name: &str) -> Option<&'static str> {
21    match name {
22        "claude" | "claude-code" => Some("claude-code"),
23        "codex" => Some("codex"),
24        "opencode" => Some("opencode"),
25        "pi" => Some("pi"),
26        _ => None,
27    }
28}
29
30/// Parse a hook stdin body for `harness`. Empty / invalid JSON → empty patch.
31pub fn cursor_patch_from_stdin(harness: &str, stdin: &str) -> IdentityCursor {
32    let payload = match serde_json::from_str::<Value>(stdin.trim()) {
33        Ok(value) if !value.is_null() => value,
34        _ => return IdentityCursor::default(),
35    };
36    match stamp_harness_name(harness).unwrap_or(harness) {
37        "claude-code" => claude_cursor_patch(&payload),
38        "codex" => codex_cursor_patch(&payload),
39        "opencode" => opencode_cursor_patch(&payload),
40        "pi" => pi_cursor_patch(&payload),
41        _ => IdentityCursor::default(),
42    }
43}
44
45/// Claude PreToolUse / StatusLine / SessionStart stdin.
46///
47/// Live model is StatusLine `model.id`. Hooks otherwise carry optional model
48/// on SessionStart only. Effort is `{level}` (not a string). Parent is
49/// `agent_id`. A main-agent event omits `agent_id`, so parent is set to
50/// `session` and merge treats `parent == session` as a clear. Do not invent
51/// a model.
52pub fn claude_cursor_patch(payload: &Value) -> IdentityCursor {
53    let session = first_value_string(payload, &[&["session_id"], &["sessionId"]]);
54    IdentityCursor {
55        provider: Some("anthropic".to_string()),
56        model: value_string_or_named(payload, &["model"], &["id"])
57            .or_else(|| value_string(payload, &["model", "display_name"])),
58        thought_level: thought_level_from_payload(payload),
59        session: session.clone(),
60        parent: value_string(payload, &["agent_id"]).or_else(|| session.clone()),
61    }
62    .omit_unpublished()
63}
64
65/// Codex hook stdin: `model` + session. Effort only if present on stdin
66/// (`reasoning_effort` or `turn_context`). Do not read `transcript_path`.
67pub fn codex_cursor_patch(payload: &Value) -> IdentityCursor {
68    IdentityCursor {
69        provider: Some("openai".to_string()),
70        model: value_string_or_named(payload, &["model"], &["id", "slug"]),
71        thought_level: thought_level_from_payload(payload),
72        session: first_value_string(
73            payload,
74            &[&["session_id"], &["sessionId"], &["conversation_id"]],
75        ),
76        parent: first_value_string(payload, &[&["parent_id"], &["parentId"], &["agent_id"]]),
77    }
78    .omit_unpublished()
79}
80
81/// OpenCode event payload. Use `event.type` (not `event.name`). Object
82/// `model` exposes `id` / `providerID` / `variant`.
83pub fn opencode_cursor_patch(payload: &Value) -> IdentityCursor {
84    let nested = payload.get("event").unwrap_or(payload);
85    let properties = nested
86        .get("properties")
87        .or_else(|| payload.get("properties"))
88        .unwrap_or(payload);
89    IdentityCursor {
90        provider: value_string(properties, &["model", "providerID"])
91            .or_else(|| value_string(properties, &["provider"]))
92            .or_else(|| value_string(payload, &["model", "providerID"])),
93        model: value_string_or_named(properties, &["model"], &["id"])
94            .or_else(|| value_string_or_named(payload, &["model"], &["id"])),
95        thought_level: value_string(properties, &["model", "variant"])
96            .or_else(|| thought_level_from_payload(properties))
97            .or_else(|| thought_level_from_payload(payload)),
98        session: first_value_string(
99            payload,
100            &[&["sessionID"], &["session_id"], &["session", "id"]],
101        )
102        .or_else(|| first_value_string(properties, &[&["sessionID"], &["session_id"]])),
103        parent: first_value_string(payload, &[&["parentID"], &["parent_id"]])
104            .or_else(|| first_value_string(properties, &[&["parentID"], &["parent_id"]])),
105    }
106    .omit_unpublished()
107}
108
109/// Pi payload or already-shaped cursor JSON (`PI_*` maps at the caller).
110pub fn pi_cursor_patch(payload: &Value) -> IdentityCursor {
111    IdentityCursor {
112        provider: first_value_string(payload, &[&["provider"], &["PI_PROVIDER"]]),
113        model: first_value_string(payload, &[&["model"], &["PI_MODEL"]]),
114        thought_level: first_value_string(
115            payload,
116            &[&["thought_level"], &["PI_REASONING_LEVEL"], &["variant"]],
117        ),
118        session: first_value_string(
119            payload,
120            &[&["session"], &["PI_SESSION_ID"], &["session_id"]],
121        ),
122        parent: first_value_string(payload, &[&["parent"], &["PI_PARENT_ID"]]),
123    }
124    .omit_unpublished()
125}
126
127/// Child-process fallback env. Claude: session + effort only — never
128/// `$ANTHROPIC_MODEL`. Pi: model + reasoning + session.
129pub fn cursor_patch_from_child_env(env: &BTreeMap<String, String>) -> IdentityCursor {
130    let claude_session = env.get("CLAUDE_CODE_SESSION_ID").cloned();
131    let claude_effort = env.get("CLAUDE_EFFORT").cloned();
132    let pi_model = env.get("PI_MODEL").cloned();
133    let pi_effort = env.get("PI_REASONING_LEVEL").cloned();
134    let pi_session = env.get("PI_SESSION_ID").cloned();
135    let provider = if pi_model.is_some() {
136        env.get("PI_PROVIDER").cloned()
137    } else if claude_session.is_some() || claude_effort.is_some() {
138        Some("anthropic".to_string())
139    } else {
140        None
141    };
142    IdentityCursor {
143        provider,
144        model: pi_model,
145        thought_level: claude_effort.or(pi_effort),
146        session: claude_session.or(pi_session),
147        parent: env.get("PI_PARENT_ID").cloned(),
148    }
149    .omit_unpublished()
150}
151
152/// SessionEnd / session.deleted / session.closed expire the live cursor.
153pub fn cursor_event_expires(payload: &Value, event_hint: Option<&str>) -> bool {
154    expire_event_name(event_hint)
155        || event_name_from_payload(payload).is_some_and(|name| expire_event_name(Some(&name)))
156}
157
158/// Stamp-path expire: SessionEnd-class events, plus Codex `Stop`.
159///
160/// Codex has no SessionEnd. Claude `Stop` is turn-end and stays on relay.
161pub fn stamp_event_expires(harness: &str, payload: &Value, event_hint: Option<&str>) -> bool {
162    if cursor_event_expires(payload, event_hint) {
163        return true;
164    }
165    if stamp_harness_name(harness) != Some("codex") {
166        return false;
167    }
168    expire_codex_stop(event_hint)
169        || event_name_from_payload(payload).is_some_and(|name| expire_codex_stop(Some(&name)))
170}
171
172fn event_name_from_payload(payload: &Value) -> Option<String> {
173    first_value_string(
174        payload,
175        &[
176            &["hook_event_name"],
177            &["hook_event"],
178            &["event", "type"],
179            &["type"],
180            &["event", "name"],
181            &["name"],
182        ],
183    )
184}
185
186fn expire_event_name(name: Option<&str>) -> bool {
187    let Some(name) = published_field(name) else {
188        return false;
189    };
190    matches!(
191        name,
192        "SessionEnd" | "session.end" | "session.deleted" | "session.closed" | "session.idle"
193    ) || name.eq_ignore_ascii_case("sessionend")
194}
195
196fn expire_codex_stop(name: Option<&str>) -> bool {
197    published_field(name).is_some_and(|name| name == "Stop")
198}
199
200/// OpenCode plugin should key on `event.type`, not `event.name`.
201pub fn opencode_event_type(payload: &Value) -> Option<String> {
202    first_value_string(
203        payload,
204        &[&["event", "type"], &["type"], &["event", "name"], &["name"]],
205    )
206    .and_then(|s| published_field(Some(&s)).map(str::to_string))
207}
208
209#[cfg(test)]
210mod tests {
211    use serde_json::json;
212
213    use super::*;
214
215    #[test]
216    fn session_end_payload_expires_cursor() {
217        assert!(cursor_event_expires(
218            &json!({"hook_event_name": "SessionEnd", "session_id": "s1"}),
219            None
220        ));
221        assert!(cursor_event_expires(
222            &json!({"event": {"type": "session.deleted"}}),
223            None
224        ));
225        assert!(cursor_event_expires(&json!({}), Some("SessionEnd")));
226        assert!(!cursor_event_expires(
227            &json!({"hook_event_name": "PreToolUse"}),
228            None
229        ));
230        assert!(!cursor_event_expires(
231            &json!({"event": {"type": "session.updated"}}),
232            None
233        ));
234        assert!(stamp_event_expires(
235            "codex",
236            &json!({"hook_event_name": "Stop", "session_id": "c1"}),
237            None
238        ));
239        assert!(stamp_event_expires("codex", &json!({}), Some("Stop")));
240        assert!(
241            !stamp_event_expires(
242                "claude-code",
243                &json!({"hook_event_name": "Stop", "session_id": "s1"}),
244                None
245            ),
246            "Claude Stop is turn-end; only SessionEnd expires the cursor"
247        );
248        assert!(!stamp_event_expires(
249            "codex",
250            &json!({"hook_event_name": "PreToolUse"}),
251            None
252        ));
253    }
254
255    #[test]
256    fn claude_effort_level_object_and_no_anthropic_model_invent() {
257        let patch = claude_cursor_patch(&json!({
258            "session_id": "sess-claude",
259            "agent_id": "agent-9",
260            "effort": {"level": "max"}
261        }));
262        assert_eq!(patch.provider.as_deref(), Some("anthropic"));
263        assert!(patch.model.is_none());
264        assert_eq!(patch.thought_level.as_deref(), Some("max"));
265        assert_eq!(patch.session.as_deref(), Some("sess-claude"));
266        assert_eq!(patch.parent.as_deref(), Some("agent-9"));
267    }
268
269    #[test]
270    fn claude_main_agent_omits_agent_id_and_clears_parent() {
271        let subagent = claude_cursor_patch(&json!({
272            "session_id": "sess-claude",
273            "agent_id": "agent-9"
274        }));
275        let current = IdentityCursor::default().merge_event(&subagent);
276        assert_eq!(current.parent.as_deref(), Some("agent-9"));
277
278        let main = claude_cursor_patch(&json!({
279            "session_id": "sess-claude"
280        }));
281        assert_eq!(main.session.as_deref(), Some("sess-claude"));
282        assert_eq!(
283            main.parent.as_deref(),
284            Some("sess-claude"),
285            "main-agent Claude events signal clear by setting parent to session"
286        );
287        let next = current.merge_event(&main);
288        assert!(
289            next.parent.is_none(),
290            "later main-agent events must not keep the stale subagent parent"
291        );
292        assert_eq!(next.session.as_deref(), Some("sess-claude"));
293    }
294
295    #[test]
296    fn claude_status_line_model_id() {
297        let patch = claude_cursor_patch(&json!({
298            "model": {"id": "claude-opus-4-7"}
299        }));
300        assert_eq!(patch.model.as_deref(), Some("claude-opus-4-7"));
301    }
302
303    #[test]
304    fn child_env_uses_claude_effort_not_anthropic_model() {
305        let mut env = BTreeMap::new();
306        env.insert("ANTHROPIC_MODEL".into(), "claude-sonnet-4-6".into());
307        env.insert("CLAUDE_CODE_SESSION_ID".into(), "sess-env".into());
308        env.insert("CLAUDE_EFFORT".into(), "high".into());
309        let patch = cursor_patch_from_child_env(&env);
310        assert!(patch.model.is_none());
311        assert_eq!(patch.session.as_deref(), Some("sess-env"));
312        assert_eq!(patch.thought_level.as_deref(), Some("high"));
313    }
314
315    #[test]
316    fn opencode_nested_event_properties() {
317        let payload = json!({
318            "event": {
319                "type": "session.updated",
320                "properties": {
321                    "model": {
322                        "id": "claude-sonnet-4-6",
323                        "providerID": "anthropic",
324                        "variant": "max"
325                    },
326                    "sessionID": "ses-nested"
327                }
328            }
329        });
330        let patch = opencode_cursor_patch(&payload);
331        assert_eq!(patch.provider.as_deref(), Some("anthropic"));
332        assert_eq!(patch.model.as_deref(), Some("claude-sonnet-4-6"));
333        assert_eq!(patch.thought_level.as_deref(), Some("max"));
334        assert_eq!(patch.session.as_deref(), Some("ses-nested"));
335    }
336
337    #[test]
338    fn opencode_object_model_and_event_type() {
339        let payload = json!({
340            "event": {"type": "session.updated"},
341            "model": {
342                "id": "claude-sonnet-4-6",
343                "providerID": "anthropic",
344                "variant": "max"
345            },
346            "sessionID": "ses-1"
347        });
348        assert_eq!(
349            opencode_event_type(&payload).as_deref(),
350            Some("session.updated")
351        );
352        let patch = opencode_cursor_patch(&payload);
353        assert_eq!(patch.provider.as_deref(), Some("anthropic"));
354        assert_eq!(patch.model.as_deref(), Some("claude-sonnet-4-6"));
355        assert_eq!(patch.thought_level.as_deref(), Some("max"));
356        assert_eq!(patch.session.as_deref(), Some("ses-1"));
357    }
358
359    #[test]
360    fn codex_model_from_stdin_skips_missing_effort() {
361        let patch = cursor_patch_from_stdin(
362            "codex",
363            r#"{"model":"gpt-5.4","session_id":"c1","transcript_path":"/tmp/t.jsonl"}"#,
364        );
365        assert_eq!(patch.model.as_deref(), Some("gpt-5.4"));
366        assert!(patch.thought_level.is_none());
367        assert_eq!(patch.session.as_deref(), Some("c1"));
368    }
369}