1use 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
19pub 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
30pub 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
45pub 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
65pub 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
81pub 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
109pub 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
127pub 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
152pub 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
158pub 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
200pub 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}