Skip to main content

agent_session/
parser.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4//! Session file parsing for Claude Code, Codex, and Gemini CLI.
5
6use serde_json::Value;
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::types::{
14    AgentSession, EditSummary, LlmResponse, PathReference, SessionCandidate, SessionDirStat,
15    SessionEvents, TokenUsage, ToolEvent, UserPrompt,
16};
17use crate::{AGENT_CLAUDE, AGENT_CODEX, AGENT_GEMINI};
18
19/// Discover all session files in the user's home directory.
20pub fn discover_session_files() -> Vec<SessionCandidate> {
21    user_home_dir()
22        .as_deref()
23        .map(discover_session_files_in_home)
24        .unwrap_or_default()
25}
26
27/// Discover session files under a specific home directory.
28pub fn discover_session_files_in_home(home: &Path) -> Vec<SessionCandidate> {
29    let roots = [
30        (AGENT_CLAUDE, home.join(".claude/projects")),
31        (AGENT_CODEX, home.join(".codex/sessions")),
32        (AGENT_GEMINI, home.join(".gemini/tmp")),
33    ];
34    let mut out = Vec::new();
35    for (agent, dir) in roots {
36        walk_agent_files(agent, &dir, &mut |path, meta| {
37            out.push(SessionCandidate {
38                agent,
39                path: path.to_path_buf(),
40                updated: meta.modified().unwrap_or(UNIX_EPOCH),
41            });
42        });
43    }
44    out
45}
46
47pub fn discover_session_files_in_dir(agent: &'static str, dir: &Path) -> Vec<SessionCandidate> {
48    let mut out = Vec::new();
49    walk_agent_files(agent, dir, &mut |path, meta| {
50        out.push(SessionCandidate {
51            agent,
52            path: path.to_path_buf(),
53            updated: meta.modified().unwrap_or(UNIX_EPOCH),
54        });
55    });
56    out
57}
58
59/// Count sessions and bytes per agent directory.
60pub fn count_session_dirs() -> Vec<SessionDirStat> {
61    let Some(home) = user_home_dir() else {
62        return Vec::new();
63    };
64    [
65        (AGENT_CLAUDE, home.join(".claude/projects")),
66        (AGENT_CODEX, home.join(".codex/sessions")),
67        (AGENT_GEMINI, home.join(".gemini/tmp")),
68    ]
69    .into_iter()
70    .filter_map(|(agent, dir)| {
71        let (mut sessions, mut bytes) = (0usize, 0u64);
72        walk_agent_files(agent, &dir, &mut |_, meta| {
73            sessions += 1;
74            bytes += meta.len();
75        });
76        (sessions > 0).then_some(SessionDirStat {
77            agent,
78            dir,
79            sessions,
80            bytes,
81        })
82    })
83    .collect()
84}
85
86pub fn session_candidate_from_path(path: &Path) -> Option<SessionCandidate> {
87    let agent = agent_source_for_path(path).or_else(|| loose_agent_source_for_path(path))?;
88    let updated = fs::metadata(path)
89        .and_then(|metadata| metadata.modified())
90        .unwrap_or(UNIX_EPOCH);
91    Some(SessionCandidate {
92        agent,
93        path: path.to_path_buf(),
94        updated,
95    })
96}
97
98/// Parse a session file from a candidate.
99pub fn parse_session_file(candidate: &SessionCandidate) -> Option<AgentSession> {
100    let content = fs::read_to_string(&candidate.path).ok()?;
101    parse_session_content(
102        candidate.agent,
103        &candidate.path,
104        candidate.updated,
105        &content,
106    )
107}
108
109/// Parse a session file by path, detecting the agent type automatically.
110pub fn parse_session_path(path: &Path) -> Option<AgentSession> {
111    parse_session_file(&session_candidate_from_path(path)?)
112}
113
114/// Parse session content given raw content string.
115pub fn parse_session_content(
116    agent: &str,
117    path: &Path,
118    updated: SystemTime,
119    content: &str,
120) -> Option<AgentSession> {
121    parse_session_impl(agent, path, updated, content)
122}
123
124fn parse_session_impl(
125    agent: &str,
126    path: &Path,
127    updated: SystemTime,
128    content: &str,
129) -> Option<AgentSession> {
130    if agent == AGENT_GEMINI {
131        parse_gemini_json(path, updated, content)
132    } else {
133        parse_jsonl(agent, path, updated, content)
134    }
135}
136
137/// Extract a session log path from a string (e.g., from /proc/fd).
138pub fn session_log_path_from_str(raw: &str) -> Option<PathBuf> {
139    let trimmed = raw.trim().trim_end_matches(" (deleted)");
140    if trimmed.is_empty() {
141        return None;
142    }
143    let path = Path::new(trimmed);
144    if !path.is_absolute() || !is_agent_session_file(path) {
145        return None;
146    }
147    agent_source_for_path(path).map(|_| normalize_session_log_path(path))
148}
149
150/// Canonicalize a session log path.
151pub fn normalize_session_log_path(path: &Path) -> PathBuf {
152    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
153}
154
155/// Detect which agent a session file belongs to based on its path.
156pub fn agent_source_for_path(path: &Path) -> Option<&'static str> {
157    let value = path.to_string_lossy();
158    if value.contains("/.claude/") && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
159    {
160        Some(AGENT_CLAUDE)
161    } else if value.contains("/.codex/")
162        && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
163    {
164        Some(AGENT_CODEX)
165    } else if value.contains("/.gemini/")
166        && path.extension().and_then(|ext| ext.to_str()) == Some("json")
167    {
168        Some(AGENT_GEMINI)
169    } else {
170        None
171    }
172}
173
174fn loose_agent_source_for_path(path: &Path) -> Option<&'static str> {
175    let value = path.to_string_lossy();
176    if value.contains("/codex/") && value.contains("sessions") {
177        Some(AGENT_CODEX)
178    } else if value.contains("/claude/") && value.contains("projects") {
179        Some(AGENT_CLAUDE)
180    } else {
181        None
182    }
183}
184
185/// Generate a fixture session path for testing.
186pub fn fixture_session_path(agent: &str, home: &Path) -> Option<PathBuf> {
187    match agent {
188        AGENT_CLAUDE => Some(home.join(".claude/projects/test/session.jsonl")),
189        AGENT_CODEX => Some(home.join(".codex/sessions/2026/06/02/session.jsonl")),
190        AGENT_GEMINI => Some(home.join(".gemini/tmp/test/chats/session-test.json")),
191        _ => None,
192    }
193}
194
195/// Check if a target path is the Codex CLI entrypoint.
196pub fn is_codex_cli_entrypoint(target: Option<&str>) -> bool {
197    target.is_some_and(|target| {
198        Path::new(target).file_name().and_then(|name| name.to_str()) == Some("codex")
199            && !target.contains("/node_modules/")
200    })
201}
202
203/// Extract the prompt from a Codex exec command.
204pub fn codex_exec_prompt(command: &str) -> Option<String> {
205    let args = shell_words(command.split_once(" exec ")?.1.trim())?;
206    let mut index = 0usize;
207    while index < args.len() {
208        let arg = args[index].as_str();
209        if arg == "--" {
210            index += 1;
211            break;
212        }
213        if !arg.starts_with('-') {
214            break;
215        }
216        let consumed = codex_exec_option_arity(arg)?;
217        index += consumed;
218    }
219    (index < args.len())
220        .then(|| args[index..].join(" "))
221        .and_then(|prompt| clean_prompt_text(&prompt))
222}
223
224// ---------------------------------------------------------------------------
225// Internal parsing implementation
226// ---------------------------------------------------------------------------
227
228fn parse_jsonl(
229    agent: &str,
230    path: &Path,
231    updated: SystemTime,
232    content: &str,
233) -> Option<AgentSession> {
234    let mut acc = SessionAccumulator::new(agent, path, updated);
235    let mut codex_model = String::new();
236    let mut claude_message_models = BTreeMap::<String, TokenUsage>::new();
237    let mut claude_seen_usage = HashSet::new();
238    let mut events = SessionEvents::default();
239    let mut current_prompt_index = 0usize;
240    let mut call_index = BTreeMap::<String, usize>::new();
241
242    for line in content.lines() {
243        let Ok(obj) = serde_json::from_str::<Value>(line) else {
244            continue;
245        };
246        let (session_id, conversation_id) = local_session_ids(&obj);
247        if let Some(id) = session_id {
248            acc.session_id = id;
249        }
250        if let Some(id) = conversation_id {
251            acc.conversation_id = Some(id);
252        }
253        if acc.cwd.is_none() {
254            acc.cwd = obj
255                .get("cwd")
256                .and_then(Value::as_str)
257                .or_else(|| obj.pointer("/payload/cwd").and_then(Value::as_str))
258                .filter(|s| !s.is_empty())
259                .map(ToString::to_string);
260        }
261        if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
262            acc.last_message_at = Some(ts.to_string());
263            if let Some(ts_ms) = iso_ms(ts) {
264                let first_timestamp = acc.start_timestamp_ms.is_none();
265                acc.start_timestamp_ms = Some(
266                    acc.start_timestamp_ms
267                        .map_or(ts_ms, |start| start.min(ts_ms)),
268                );
269                acc.end_timestamp_ms = Some(if first_timestamp {
270                    ts_ms
271                } else {
272                    acc.end_timestamp_ms.map_or(ts_ms, |end| end.max(ts_ms))
273                });
274            }
275        }
276        let typ = obj.get("type").and_then(Value::as_str).unwrap_or("");
277        match (agent, typ) {
278            (AGENT_CLAUDE, "result") => {
279                acc.duration_ms = json_u64(&obj, "duration_ms");
280                if let Some(model_usage) = obj.get("modelUsage").and_then(Value::as_object) {
281                    for (name, usage) in model_usage {
282                        acc.model.get_or_insert_with(|| name.clone());
283                        acc.add_usage(
284                            name,
285                            json_i64(usage, "inputTokens"),
286                            json_i64(usage, "outputTokens"),
287                            json_i64(usage, "cacheCreationInputTokens"),
288                            json_i64(usage, "cacheReadInputTokens"),
289                            0,
290                        );
291                    }
292                }
293            }
294            (AGENT_CLAUDE, "assistant") => {
295                if let Some(name) = obj.pointer("/message/model").and_then(Value::as_str) {
296                    acc.model.get_or_insert_with(|| name.to_string());
297                }
298                let model = obj
299                    .pointer("/message/model")
300                    .and_then(Value::as_str)
301                    .or(acc.model.as_deref())
302                    .unwrap_or(AGENT_CLAUDE)
303                    .to_string();
304                if let Some(usage) = obj.pointer("/message/usage")
305                    && claude_seen_usage.insert(claude_usage_key(&obj))
306                {
307                    let name = obj
308                        .pointer("/message/model")
309                        .and_then(Value::as_str)
310                        .unwrap_or("unknown");
311                    add_usage(
312                        &mut claude_message_models,
313                        name,
314                        json_i64(usage, "input_tokens"),
315                        json_i64(usage, "output_tokens"),
316                        json_i64(usage, "cache_creation_input_tokens"),
317                        json_i64(usage, "cache_read_input_tokens"),
318                        0,
319                    );
320                }
321                let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
322                if let Some(items) = content.as_array() {
323                    for item in items
324                        .iter()
325                        .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_use"))
326                    {
327                        let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
328                        acc.add_tool(name);
329                        if let Some(fp) = item
330                            .pointer("/input/file_path")
331                            .and_then(Value::as_str)
332                            .filter(|s| !is_noise_path(s))
333                        {
334                            acc.add_file(fp);
335                        }
336                        let call_id = item.get("id").and_then(Value::as_str).map(str::to_string);
337                        let event = tool_event_from_input(
338                            acc.cwd.as_deref(),
339                            ts_ms_from_event(&obj),
340                            current_prompt_index,
341                            name,
342                            item.get("input").unwrap_or(&Value::Null),
343                            call_id.clone(),
344                        );
345                        if let Some(id) = call_id {
346                            call_index.insert(id, events.tools.len());
347                        }
348                        events.tools.push(event);
349                    }
350                }
351                let text = content_to_text(content);
352                let usage = obj.pointer("/message/usage").unwrap_or(&Value::Null);
353                if !text.trim().is_empty() || usage.is_object() {
354                    // Build preview: prefer text content, fall back to tool names
355                    let preview_text = if !text.trim().is_empty() {
356                        text.clone()
357                    } else if let Some(items) = content.as_array() {
358                        let tool_names: Vec<_> = items
359                            .iter()
360                            .filter_map(|item| {
361                                if item.get("type").and_then(Value::as_str) == Some("tool_use") {
362                                    item.get("name").and_then(Value::as_str)
363                                } else {
364                                    None
365                                }
366                            })
367                            .collect();
368                        if tool_names.is_empty() {
369                            String::new()
370                        } else {
371                            format!("tool: {}", tool_names.join(", "))
372                        }
373                    } else {
374                        String::new()
375                    };
376                    events.llm_responses.push(LlmResponse {
377                        ts_ms: ts_ms_from_event(&obj),
378                        prompt_index: current_prompt_index,
379                        model,
380                        text_hash: short_hash(&(text.clone() + &usage.to_string()), 12),
381                        preview: truncate_clean(
382                            if preview_text.is_empty() {
383                                "token report"
384                            } else {
385                                &preview_text
386                            },
387                            140,
388                        ),
389                        input_tokens: json_u64(usage, "input_tokens"),
390                        output_tokens: json_u64(usage, "output_tokens"),
391                        cache_tokens: json_u64(usage, "cache_creation_input_tokens")
392                            + json_u64(usage, "cache_read_input_tokens"),
393                        total_tokens: 0,
394                        tag: String::new(),
395                    });
396                }
397            }
398            (AGENT_CLAUDE, "queue-operation") if acc.prompt_preview.is_none() => {
399                if obj.get("operation").and_then(Value::as_str) == Some("enqueue")
400                    && let Some(text) = obj.get("content").and_then(Value::as_str)
401                    && let Some(text) = clean_prompt_text(text)
402                {
403                    acc.prompt_preview = Some(text.clone());
404                    current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
405                }
406            }
407            (AGENT_CLAUDE, "last-prompt") if acc.prompt_preview.is_none() => {
408                if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
409                    && let Some(text) = clean_prompt_text(text)
410                {
411                    acc.prompt_preview = Some(text.clone());
412                    current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
413                }
414            }
415            (AGENT_CLAUDE, "user") => {
416                let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
417                if claude_is_tool_result(content) || is_claude_tool_result(&obj) {
418                    let is_error = obj
419                        .get("toolUseResult")
420                        .and_then(|v| v.get("is_error"))
421                        .and_then(Value::as_bool)
422                        .unwrap_or(false);
423                    for id in claude_tool_result_ids(content) {
424                        if let Some(index) = call_index.get(&id).copied()
425                            && let Some(tool) = events.tools.get_mut(index)
426                        {
427                            tool.status = if is_error { "fail" } else { "ok" }.to_string();
428                        }
429                    }
430                } else if let Some(text) = local_message_preview(content) {
431                    if acc.prompt_preview.is_none() {
432                        acc.prompt_preview = Some(text.clone());
433                    }
434                    current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
435                }
436            }
437            (AGENT_CLAUDE, "last-prompt") => {
438                if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
439                    && let Some(text) = clean_prompt_text(text)
440                {
441                    current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
442                }
443            }
444            (AGENT_CODEX, "turn_context") => {
445                if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
446                    codex_model = name.to_string();
447                    acc.model = Some(name.to_string());
448                }
449            }
450            (AGENT_CODEX, "event_msg") => {
451                let payload = obj.get("payload").unwrap_or(&Value::Null);
452                let ptype = payload.get("type").and_then(Value::as_str).unwrap_or("");
453                if ptype == "patch_apply_end" {
454                    let call_id = payload
455                        .get("call_id")
456                        .and_then(Value::as_str)
457                        .map(str::to_string);
458                    let mut event = tool_event_from_input(
459                        acc.cwd.as_deref(),
460                        ts_ms_from_event(&obj),
461                        current_prompt_index,
462                        "apply_patch",
463                        payload,
464                        call_id,
465                    );
466                    event.status = if payload
467                        .get("success")
468                        .and_then(Value::as_bool)
469                        .unwrap_or(false)
470                    {
471                        "success"
472                    } else {
473                        "fail"
474                    }
475                    .to_string();
476                    if let Some(index) = event
477                        .call_id
478                        .as_deref()
479                        .and_then(|call_id| call_index.get(call_id))
480                        .copied()
481                    {
482                        if let Some(existing) = events.tools.get_mut(index) {
483                            existing.status = event.status;
484                            if existing.path_refs.is_empty() {
485                                existing.path_refs = event.path_refs;
486                            }
487                            if existing.edit_summary.is_none() {
488                                existing.edit_summary = event.edit_summary;
489                            }
490                        }
491                    } else {
492                        acc.add_tool("apply_patch");
493                        if let Some(call_id) = event.call_id.clone() {
494                            call_index.insert(call_id, events.tools.len());
495                        }
496                        events.tools.push(event);
497                    }
498                }
499                if ptype == "token_count"
500                    && let Some(usage) = payload.pointer("/info/total_token_usage")
501                {
502                    let name = if codex_model.is_empty() {
503                        "unknown"
504                    } else {
505                        &codex_model
506                    };
507                    let raw_input = json_i64(usage, "input_tokens").max(0);
508                    let output = json_i64(usage, "output_tokens").max(0);
509                    let cache = json_i64(usage, "cached_input_tokens").max(0);
510                    let input = codex_uncached_input(raw_input, cache);
511                    acc.set_usage(name, input, output, 0, cache, input + output);
512                }
513                if matches!(ptype, "token_count" | "token_usage") {
514                    let info = payload
515                        .get("info")
516                        .or_else(|| payload.get("usage"))
517                        .unwrap_or(payload);
518                    let total_usage = info.get("total_token_usage");
519                    let token_usage = info.get("last_token_usage").or(total_usage).unwrap_or(info);
520                    let is_cumulative_usage =
521                        total_usage.is_some() && info.get("last_token_usage").is_none();
522                    let raw_input_tokens = json_u64(token_usage, "input_tokens");
523                    let output_tokens = json_u64(token_usage, "output_tokens");
524                    let cache_tokens = json_u64(token_usage, "cached_input_tokens");
525                    let uncached_input_tokens = raw_input_tokens.saturating_sub(cache_tokens);
526                    let input_tokens = if is_cumulative_usage {
527                        uncached_input_tokens
528                    } else {
529                        raw_input_tokens
530                    };
531                    let raw_total_tokens = json_u64(token_usage, "total_tokens")
532                        .max(json_u64(info, "total_tokens"))
533                        .max(json_u64(info, "tokens"));
534                    let total_tokens = if is_cumulative_usage {
535                        uncached_input_tokens + output_tokens
536                    } else {
537                        raw_total_tokens
538                    };
539                    if total_tokens > 0 {
540                        if let Some(last) = events.llm_responses.last_mut()
541                            && last.total_tokens == 0
542                        {
543                            last.input_tokens = input_tokens;
544                            last.output_tokens = output_tokens;
545                            last.cache_tokens = cache_tokens;
546                            last.total_tokens = total_tokens;
547                            continue;
548                        }
549                        if let Some(last_usage) =
550                            events.llm_responses.iter_mut().rev().find(|response| {
551                                response.prompt_index == current_prompt_index
552                                    && response.tag == "usage"
553                            })
554                        {
555                            last_usage.ts_ms = ts_ms_from_event(&obj);
556                            last_usage.input_tokens = input_tokens;
557                            last_usage.output_tokens = output_tokens;
558                            last_usage.cache_tokens = cache_tokens;
559                            last_usage.total_tokens = total_tokens;
560                            continue;
561                        }
562                        events.llm_responses.push(LlmResponse {
563                            ts_ms: ts_ms_from_event(&obj),
564                            prompt_index: current_prompt_index,
565                            model: if codex_model.is_empty() {
566                                AGENT_CODEX.to_string()
567                            } else {
568                                codex_model.clone()
569                            },
570                            text_hash: short_hash(&token_usage.to_string(), 12),
571                            preview: "token report".to_string(),
572                            input_tokens,
573                            output_tokens,
574                            cache_tokens,
575                            total_tokens,
576                            tag: "usage".to_string(),
577                        });
578                    }
579                }
580                if ptype == "user_message" {
581                    let text = payload
582                        .get("message")
583                        .or_else(|| payload.get("content"))
584                        .and_then(Value::as_str)
585                        .unwrap_or("");
586                    if let Some(text) = clean_prompt_text(text) {
587                        acc.prompt_preview = Some(text.clone());
588                        current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
589                    }
590                }
591                if ptype == "agent_message" {
592                    let text = payload
593                        .get("message")
594                        .or_else(|| payload.get("content"))
595                        .and_then(Value::as_str)
596                        .unwrap_or("");
597                    if let Some(text) = clean_prompt_text(text) {
598                        events.llm_responses.push(LlmResponse {
599                            ts_ms: ts_ms_from_event(&obj),
600                            prompt_index: current_prompt_index,
601                            model: if codex_model.is_empty() {
602                                AGENT_CODEX.to_string()
603                            } else {
604                                codex_model.clone()
605                            },
606                            text_hash: short_hash(&text, 12),
607                            preview: truncate_clean(&text, 180),
608                            input_tokens: 0,
609                            output_tokens: 0,
610                            cache_tokens: 0,
611                            total_tokens: 0,
612                            tag: String::new(),
613                        });
614                    }
615                }
616            }
617            (AGENT_CODEX, "response_item")
618                if matches!(
619                    obj.pointer("/payload/type").and_then(Value::as_str),
620                    Some("function_call" | "custom_tool_call")
621                ) =>
622            {
623                let name = obj
624                    .pointer("/payload/name")
625                    .and_then(Value::as_str)
626                    .unwrap_or("?");
627                acc.add_tool(name);
628                let payload = obj.get("payload").unwrap_or(&Value::Null);
629                let args = parse_tool_args(payload.get("arguments").unwrap_or(&Value::Null));
630                let call_id = payload
631                    .get("call_id")
632                    .and_then(Value::as_str)
633                    .map(str::to_string);
634                let event = tool_event_from_input(
635                    acc.cwd.as_deref(),
636                    ts_ms_from_event(&obj),
637                    current_prompt_index,
638                    name,
639                    &args,
640                    call_id.clone(),
641                );
642                if let Some(id) = call_id {
643                    call_index.insert(id, events.tools.len());
644                }
645                events.tools.push(event);
646            }
647            (AGENT_CODEX, "response_item")
648                if matches!(
649                    obj.pointer("/payload/type").and_then(Value::as_str),
650                    Some("function_call_output" | "custom_tool_call_output")
651                ) =>
652            {
653                if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
654                    && let Some(index) = call_index.get(call_id).copied()
655                    && let Some(tool) = events.tools.get_mut(index)
656                {
657                    let output = obj
658                        .pointer("/payload/output")
659                        .and_then(Value::as_str)
660                        .unwrap_or("");
661                    tool.status = status_from_output(output).to_string();
662                }
663            }
664            (AGENT_CODEX, "response_item")
665                if obj.pointer("/payload/type").and_then(Value::as_str) == Some("message") =>
666            {
667                let payload = obj.get("payload").unwrap_or(&Value::Null);
668                let text = payload
669                    .get("message")
670                    .or_else(|| payload.get("content"))
671                    .and_then(Value::as_str)
672                    .unwrap_or("");
673                if let Some(text) = clean_prompt_text(text) {
674                    events.llm_responses.push(LlmResponse {
675                        ts_ms: ts_ms_from_event(&obj),
676                        prompt_index: current_prompt_index,
677                        model: if codex_model.is_empty() {
678                            AGENT_CODEX.to_string()
679                        } else {
680                            codex_model.clone()
681                        },
682                        text_hash: short_hash(&text, 12),
683                        preview: truncate_clean(&text, 180),
684                        input_tokens: 0,
685                        output_tokens: 0,
686                        cache_tokens: 0,
687                        total_tokens: 0,
688                        tag: String::new(),
689                    });
690                }
691            }
692            (AGENT_CODEX, "message" | "input" | "user") => {
693                if let Some(text) = local_message_preview(&obj) {
694                    acc.prompt_preview = Some(text.clone());
695                    current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
696                }
697            }
698            _ if acc.prompt_preview.is_none() && typ.contains("user") => {
699                if let Some(text) = local_message_preview(&obj) {
700                    acc.prompt_preview = Some(text.clone());
701                    current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
702                }
703            }
704            _ => {}
705        }
706    }
707
708    if acc.model_usage.is_empty() {
709        acc.model_usage = claude_message_models;
710    }
711    acc.finish_with_events(events)
712}
713
714fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
715    let root: Value = serde_json::from_str(content).ok()?;
716    let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
717    let mut events = SessionEvents::default();
718    let mut current_prompt_index = 0usize;
719    acc.project_hash = root
720        .get("projectHash")
721        .and_then(Value::as_str)
722        .map(str::to_string);
723    if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
724        acc.session_id = id.to_string();
725        acc.conversation_id = Some(id.to_string());
726    }
727    acc.start_timestamp_ms = root
728        .get("startTime")
729        .and_then(Value::as_str)
730        .and_then(iso_ms);
731    acc.end_timestamp_ms = root
732        .get("lastUpdated")
733        .and_then(Value::as_str)
734        .and_then(iso_ms)
735        .or(acc.start_timestamp_ms);
736    acc.duration_ms = acc
737        .start_timestamp_ms
738        .zip(acc.end_timestamp_ms)
739        .map(|(start, end)| end.saturating_sub(start))
740        .unwrap_or_default();
741
742    let Some(messages) = root.get("messages").and_then(Value::as_array) else {
743        return acc.finish_with_events(events);
744    };
745    for msg in messages {
746        if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
747            acc.last_message_at = Some(ts.to_string());
748        }
749        let ts_ms = msg
750            .get("timestamp")
751            .and_then(Value::as_str)
752            .and_then(parse_ts_ms);
753        match msg.get("type").and_then(Value::as_str) {
754            Some("user") if acc.prompt_preview.is_none() => {
755                if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
756                    acc.prompt_preview = Some(text.clone());
757                    current_prompt_index = events.upsert_prompt(ts_ms, &text);
758                }
759            }
760            Some("user") => {
761                if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
762                    current_prompt_index = events.upsert_prompt(ts_ms, &text);
763                }
764            }
765            Some("gemini") | Some("assistant") | Some("model") => {
766                let mut llm_model = AGENT_GEMINI.to_string();
767                if let Some(model) = msg.get("model").and_then(Value::as_str) {
768                    llm_model = model.to_string();
769                    acc.model.get_or_insert_with(|| model.to_string());
770                    if let Some(tokens) = msg.get("tokens") {
771                        acc.add_usage(
772                            model,
773                            json_i64(tokens, "input"),
774                            json_i64(tokens, "output"),
775                            0,
776                            json_i64(tokens, "cached"),
777                            json_i64(tokens, "total"),
778                        );
779                    }
780                }
781                if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
782                    for call in tool_calls {
783                        let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
784                        acc.add_tool(name);
785                        if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
786                        {
787                            acc.add_file(path);
788                        }
789                        let tool_input = call.get("args").unwrap_or(call);
790                        let mut event = tool_event_from_input(
791                            acc.cwd.as_deref(),
792                            ts_ms,
793                            current_prompt_index,
794                            name,
795                            tool_input,
796                            call.get("id").and_then(Value::as_str).map(str::to_string),
797                        );
798                        if let Some(status) = call.get("status").and_then(Value::as_str) {
799                            event.status = status.to_ascii_lowercase();
800                        }
801                        events.tools.push(event);
802                    }
803                }
804                let content = msg.get("content").unwrap_or(msg);
805                let text = content_to_text(content);
806                let tokens = msg.get("tokens").unwrap_or(&Value::Null);
807                if !text.trim().is_empty() || tokens.is_object() {
808                    events.llm_responses.push(LlmResponse {
809                        ts_ms,
810                        prompt_index: current_prompt_index,
811                        model: llm_model,
812                        text_hash: short_hash(&(text.clone() + &tokens.to_string()), 12),
813                        preview: truncate_clean(
814                            if text.trim().is_empty() {
815                                "gemini response"
816                            } else {
817                                &text
818                            },
819                            140,
820                        ),
821                        input_tokens: json_u64(tokens, "input"),
822                        output_tokens: json_u64(tokens, "output"),
823                        cache_tokens: json_u64(tokens, "cached"),
824                        total_tokens: json_u64(tokens, "total"),
825                        tag: String::new(),
826                    });
827                }
828            }
829            _ => {}
830        }
831    }
832    acc.finish_with_events(events)
833}
834
835struct SessionAccumulator {
836    agent_type: String,
837    session_id: String,
838    conversation_id: Option<String>,
839    path: PathBuf,
840    updated: SystemTime,
841    start_timestamp_ms: Option<u64>,
842    end_timestamp_ms: Option<u64>,
843    model: Option<String>,
844    model_usage: BTreeMap<String, TokenUsage>,
845    tools: BTreeMap<String, usize>,
846    files: BTreeMap<String, usize>,
847    prompt_preview: Option<String>,
848    duration_ms: u64,
849    cwd: Option<String>,
850    project_hash: Option<String>,
851    last_message_at: Option<String>,
852}
853
854impl SessionAccumulator {
855    fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
856        let normalized = normalize_session_log_path(path);
857        let session_id = path
858            .file_stem()
859            .and_then(|stem| stem.to_str())
860            .unwrap_or("session")
861            .to_string();
862        Self {
863            agent_type: agent.to_string(),
864            session_id,
865            conversation_id: None,
866            path: normalized.clone(),
867            updated,
868            start_timestamp_ms: None,
869            end_timestamp_ms: Some(system_time_ms(updated)),
870            model: None,
871            model_usage: BTreeMap::new(),
872            tools: BTreeMap::new(),
873            files: BTreeMap::new(),
874            prompt_preview: None,
875            duration_ms: 0,
876            cwd: None,
877            project_hash: None,
878            last_message_at: None,
879        }
880    }
881
882    fn add_usage(
883        &mut self,
884        model: &str,
885        input: i64,
886        output: i64,
887        cache_creation: i64,
888        cache_read: i64,
889        total: i64,
890    ) {
891        add_usage(
892            &mut self.model_usage,
893            model,
894            input,
895            output,
896            cache_creation,
897            cache_read,
898            total,
899        );
900    }
901
902    fn set_usage(
903        &mut self,
904        model: &str,
905        input: i64,
906        output: i64,
907        cache_creation: i64,
908        cache_read: i64,
909        total: i64,
910    ) {
911        let mut usage = TokenUsage::default();
912        usage.add(input, output, cache_creation, cache_read, total);
913        self.model_usage.insert(model.to_string(), usage);
914    }
915
916    fn add_tool(&mut self, name: &str) {
917        *self.tools.entry(name.to_string()).or_default() += 1;
918    }
919
920    fn add_file(&mut self, path: &str) {
921        *self.files.entry(path.to_string()).or_default() += 1;
922    }
923
924    fn finish(self) -> Option<AgentSession> {
925        let token_usage =
926            self.model_usage
927                .values()
928                .fold(TokenUsage::default(), |mut total, usage| {
929                    total.input_tokens += usage.input_tokens;
930                    total.output_tokens += usage.output_tokens;
931                    total.cache_creation_tokens += usage.cache_creation_tokens;
932                    total.cache_read_tokens += usage.cache_read_tokens;
933                    total.total_tokens += usage.total_tokens;
934                    total
935                });
936        if token_usage.total_tokens == 0
937            && self.tools.is_empty()
938            && self.prompt_preview.is_none()
939            && self.model.is_none()
940        {
941            return None;
942        }
943        let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
944        Some(AgentSession {
945            agent_type: self.agent_type,
946            session_id: self.session_id,
947            conversation_id: self.conversation_id,
948            display_id,
949            path: self.path,
950            updated: self.updated,
951            start_timestamp_ms: self
952                .start_timestamp_ms
953                .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
954            end_timestamp_ms: self.end_timestamp_ms,
955            model: self.model,
956            usage: token_usage,
957            model_usage: self.model_usage,
958            tools: self.tools,
959            files: self.files,
960            prompt_preview: self.prompt_preview,
961            duration_ms: self.duration_ms,
962            cwd: self.cwd,
963            project_hash: self.project_hash,
964            last_message_at: self.last_message_at,
965            events: SessionEvents::default(),
966        })
967    }
968
969    fn finish_with_events(self, events: SessionEvents) -> Option<AgentSession> {
970        self.finish().map(|mut session| {
971            session.events = events;
972            session
973        })
974    }
975}
976
977// ---------------------------------------------------------------------------
978// Helper functions
979// ---------------------------------------------------------------------------
980
981fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
982    let Ok(entries) = fs::read_dir(dir) else {
983        return;
984    };
985    for entry in entries.flatten() {
986        let path = entry.path();
987        if path.is_dir() {
988            walk_agent_files(agent, &path, f);
989        } else if is_agent_file_for(agent, &path)
990            && let Ok(meta) = path.metadata()
991        {
992            f(&path, &meta);
993        }
994    }
995}
996
997fn is_agent_session_file(path: &Path) -> bool {
998    agent_source_for_path(path).is_some()
999}
1000
1001fn is_agent_file_for(agent: &str, path: &Path) -> bool {
1002    match agent {
1003        AGENT_CLAUDE | AGENT_CODEX => {
1004            path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
1005        }
1006        AGENT_GEMINI => {
1007            path.extension().and_then(|ext| ext.to_str()) == Some("json")
1008                && path
1009                    .file_name()
1010                    .and_then(|name| name.to_str())
1011                    .is_some_and(|name| name.starts_with("session-"))
1012                && path.to_string_lossy().contains("/chats/")
1013        }
1014        _ => false,
1015    }
1016}
1017
1018pub(crate) fn user_home_dir() -> Option<PathBuf> {
1019    std::env::var("SUDO_USER")
1020        .ok()
1021        .and_then(|user| {
1022            fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
1023                passwd
1024                    .lines()
1025                    .find(|line| line.starts_with(&format!("{user}:")))
1026                    .and_then(|line| line.split(':').nth(5))
1027                    .map(PathBuf::from)
1028            })
1029        })
1030        .or_else(dirs::home_dir)
1031}
1032
1033fn add_usage(
1034    models: &mut BTreeMap<String, TokenUsage>,
1035    model: &str,
1036    input: i64,
1037    output: i64,
1038    cache_creation: i64,
1039    cache_read: i64,
1040    total: i64,
1041) {
1042    models.entry(model.to_string()).or_default().add(
1043        input,
1044        output,
1045        cache_creation,
1046        cache_read,
1047        total,
1048    );
1049}
1050
1051impl SessionEvents {
1052    fn upsert_prompt(&mut self, ts_ms: Option<i64>, text: &str) -> usize {
1053        let hash = short_hash(text, 12);
1054        if let Some(existing) = self
1055            .prompts
1056            .iter()
1057            .position(|prompt| prompt.text_hash == hash)
1058        {
1059            return existing;
1060        }
1061        let index = self.prompts.len();
1062        self.prompts.push(UserPrompt {
1063            index,
1064            ts_ms,
1065            text_hash: hash,
1066            preview: truncate_clean(text, 180),
1067            tag: String::new(),
1068        });
1069        index
1070    }
1071}
1072
1073fn tool_event_from_input(
1074    cwd: Option<&str>,
1075    ts_ms: Option<i64>,
1076    prompt_index: usize,
1077    name: &str,
1078    input: &Value,
1079    call_id: Option<String>,
1080) -> ToolEvent {
1081    let command = command_from_tool_input(input);
1082    let category = tool_category(name, &command);
1083    let domains = extract_domains(&command);
1084    let command_name = if category == "shell" {
1085        basename_from_command(&command)
1086    } else if category == "network" && !domains.is_empty() {
1087        domains[0]
1088            .split(':')
1089            .next()
1090            .unwrap_or("network")
1091            .to_string()
1092    } else {
1093        one_word(name, "tool")
1094    };
1095    let lower_name = name.to_ascii_lowercase();
1096    let effect = if lower_name == "apply_patch"
1097        || lower_name.contains("write")
1098        || lower_name.contains("edit")
1099        || lower_name.contains("replace")
1100        || command.contains("*** ")
1101    {
1102        "write".to_string()
1103    } else if lower_name.contains("read")
1104        || lower_name.contains("glob")
1105        || lower_name.contains("grep")
1106        || lower_name.contains("search")
1107    {
1108        "read".to_string()
1109    } else {
1110        command_effect(&command)
1111    };
1112    let cwd = cwd.unwrap_or("");
1113    let path_groups = extract_path_groups(Path::new(cwd), name, input, &command);
1114    let path_refs = extract_path_references(Path::new(cwd), name, input, &command, &effect);
1115    let edit_summary = edit_summary_from_input(name, input, &command, &effect, &path_refs);
1116    let process_chain = if category == "shell" {
1117        command_process_chain(&command)
1118    } else {
1119        Vec::new()
1120    };
1121    ToolEvent {
1122        ts_ms,
1123        prompt_index,
1124        tool_name: name.to_string(),
1125        category,
1126        command,
1127        command_name,
1128        effect,
1129        process_chain,
1130        status: "observed".to_string(),
1131        path_groups,
1132        path_refs,
1133        edit_summary,
1134        domains,
1135        call_id,
1136    }
1137}
1138
1139fn command_from_tool_input(input: &Value) -> String {
1140    for key in ["cmd", "command", "pattern", "file_path", "path", "text"] {
1141        if let Some(value) = input.get(key).and_then(Value::as_str)
1142            && !value.is_empty()
1143        {
1144            return if key == "pattern" {
1145                format!("search {value}")
1146            } else {
1147                value.to_string()
1148            };
1149        }
1150    }
1151    if input.is_null() {
1152        String::new()
1153    } else {
1154        truncate_clean(&input.to_string(), 300)
1155    }
1156}
1157
1158fn parse_tool_args(value: &Value) -> Value {
1159    if let Some(text) = value.as_str() {
1160        serde_json::from_str(text).unwrap_or_else(|_| serde_json::json!({ "text": text }))
1161    } else {
1162        value.clone()
1163    }
1164}
1165
1166fn status_from_output(output: &str) -> &'static str {
1167    let lowered = output.to_ascii_lowercase();
1168    if lowered.contains("process exited with code 0") || lowered.contains("\"is_error\":false") {
1169        "ok"
1170    } else if lowered.contains("process exited with code")
1171        || lowered.contains("\"is_error\":true")
1172        || lowered.contains("error")
1173    {
1174        "fail"
1175    } else {
1176        "observed"
1177    }
1178}
1179
1180pub fn tool_category(name: &str, command: &str) -> String {
1181    let n = name.to_ascii_lowercase();
1182    if n.ends_with("exec_command") || n == "bash" {
1183        "shell"
1184    } else if ["apply_patch", "edit", "write", "multiedit", "notebookedit"].contains(&n.as_str()) {
1185        "edit"
1186    } else if ["read", "grep", "glob", "ls"].contains(&n.as_str()) {
1187        "read"
1188    } else if n.contains("web")
1189        || n.contains("browser")
1190        || n.contains("search")
1191        || command.contains("http")
1192    {
1193        "network"
1194    } else if n.contains("plan") || n.contains("todo") {
1195        "plan"
1196    } else if n.contains("task") || n.contains("agent") {
1197        "subagent"
1198    } else {
1199        "tool"
1200    }
1201    .to_string()
1202}
1203
1204fn command_effect(command: &str) -> String {
1205    let cmd = basename_from_command(command);
1206    let text = command.to_ascii_lowercase();
1207    if ["cargo", "pytest", "npm", "pnpm", "yarn", "go", "make"].contains(&cmd.as_str())
1208        && any_word(&text, &["test", "check", "build", "clippy"])
1209    {
1210        "test"
1211    } else if cmd == "git"
1212        && any_word(
1213            &text,
1214            &["commit", "push", "add", "checkout", "merge", "rebase"],
1215        )
1216    {
1217        "repo"
1218    } else if ["curl", "wget", "ssh", "scp", "git"].contains(&cmd.as_str())
1219        && (any_word(
1220            &text,
1221            &["clone", "fetch", "pull", "push", "curl", "wget", "ssh"],
1222        ) || text.contains("http://")
1223            || text.contains("https://"))
1224    {
1225        "network"
1226    } else if has_output_redirection(command)
1227        || ["tee", "cp", "mv", "rm", "mkdir", "touch"].contains(&cmd.as_str())
1228        || (cmd == "sed" && (text.contains(" -i") || text.contains("--in-place")))
1229        || (["python", "python3", "node", "npm"].contains(&cmd.as_str())
1230            && (text.contains('>')
1231                || text.contains("--write")
1232                || text.contains("write_text")
1233                || text.contains("writefile")))
1234    {
1235        "write"
1236    } else if [
1237        "rg", "grep", "sed", "cat", "head", "tail", "find", "ls", "nl", "wc", "jq", "git",
1238    ]
1239    .contains(&cmd.as_str())
1240    {
1241        "read"
1242    } else if text.contains("http://")
1243        || text.contains("https://")
1244        || text.contains("crates.io")
1245        || text.contains("github.com")
1246    {
1247        "network"
1248    } else {
1249        "process"
1250    }
1251    .to_string()
1252}
1253
1254fn has_output_redirection(command: &str) -> bool {
1255    shell_segments(command)
1256        .iter()
1257        .any(|parts| !output_redirection_targets(parts).is_empty())
1258}
1259
1260fn any_word(text: &str, words: &[&str]) -> bool {
1261    text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1262        .any(|part| words.contains(&part))
1263}
1264
1265fn basename_from_command(command: &str) -> String {
1266    let parts = split_shell(command);
1267    let mut idx = 0;
1268    while idx < parts.len()
1269        && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
1270            &Path::new(&parts[idx])
1271                .file_name()
1272                .and_then(|v| v.to_str())
1273                .unwrap_or(""),
1274        )
1275    {
1276        idx += 1;
1277        if idx < parts.len() && parts[idx].starts_with('-') {
1278            idx += 1;
1279        }
1280    }
1281    parts
1282        .get(idx)
1283        .and_then(|part| process_name_from_part(part))
1284        .unwrap_or_else(|| "none".to_string())
1285}
1286
1287pub fn command_process_chain(command: &str) -> Vec<String> {
1288    process_chain_from_parts(&split_shell(command))
1289}
1290
1291fn process_chain_from_parts(parts: &[String]) -> Vec<String> {
1292    if parts.is_empty() {
1293        return Vec::new();
1294    }
1295    let mut idx = 0;
1296    while idx < parts.len()
1297        && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
1298            &Path::new(&parts[idx])
1299                .file_name()
1300                .and_then(|v| v.to_str())
1301                .unwrap_or(""),
1302        )
1303    {
1304        idx += 1;
1305        if idx < parts.len() && parts[idx].starts_with('-') {
1306            idx += 1;
1307        }
1308    }
1309    let Some(proc_name) = parts.get(idx).and_then(|part| process_name_from_part(part)) else {
1310        return Vec::new();
1311    };
1312    let mut chain = vec![proc_name.clone()];
1313    if ["bash", "sh", "zsh"].contains(&proc_name.as_str()) {
1314        for flag_idx in idx + 1..parts.len().saturating_sub(1) {
1315            if ["-c", "-lc", "-cl"].contains(&parts[flag_idx].as_str()) {
1316                chain.extend(command_process_chain(&parts[flag_idx + 1]));
1317                break;
1318            }
1319        }
1320    }
1321    chain
1322}
1323
1324fn process_name_from_part(part: &str) -> Option<String> {
1325    let raw = part.trim_matches(['"', '\'']);
1326    if raw.is_empty() {
1327        return None;
1328    }
1329    let path = Path::new(raw);
1330    let file_name = path.file_name().and_then(|v| v.to_str()).unwrap_or(raw);
1331    let parts = path_component_strings(path);
1332    if looks_like_home_directory(&parts) && parts.len() <= 2 {
1333        return Some("external".to_string());
1334    }
1335    if contains_private_marker(file_name) {
1336        return Some("external".to_string());
1337    }
1338    Some(file_name.to_string())
1339}
1340
1341fn split_shell(command: &str) -> Vec<String> {
1342    let mut parts = Vec::new();
1343    let mut current = String::new();
1344    let mut quote = None;
1345    let mut escaped = false;
1346    for ch in command.chars() {
1347        if escaped {
1348            current.push(ch);
1349            escaped = false;
1350        } else if ch == '\\' {
1351            escaped = true;
1352        } else if quote == Some(ch) {
1353            quote = None;
1354        } else if quote.is_some() {
1355            current.push(ch);
1356        } else if ch == '\'' || ch == '"' {
1357            quote = Some(ch);
1358        } else if ch.is_whitespace() {
1359            if !current.is_empty() {
1360                parts.push(std::mem::take(&mut current));
1361            }
1362        } else {
1363            current.push(ch);
1364        }
1365    }
1366    if !current.is_empty() {
1367        parts.push(current);
1368    }
1369    parts
1370}
1371
1372/// Tokenize the small, high-confidence shell subset used for path evidence.
1373///
1374/// This deliberately does not try to implement Bash. It preserves quoted
1375/// words, separates compound commands and redirections (including `x>file`),
1376/// and drops comments. Each returned segment is then interpreted according to
1377/// its own command rather than assigning the first command's semantics to the
1378/// entire line.
1379fn shell_segments(command: &str) -> Vec<Vec<String>> {
1380    fn flush_word(tokens: &mut Vec<String>, current: &mut String) {
1381        if !current.is_empty() {
1382            tokens.push(std::mem::take(current));
1383        }
1384    }
1385
1386    fn flush_segment(segments: &mut Vec<Vec<String>>, tokens: &mut Vec<String>) {
1387        if !tokens.is_empty() {
1388            segments.push(std::mem::take(tokens));
1389        }
1390    }
1391
1392    let command = strip_heredoc_bodies(command);
1393    let mut segments = Vec::new();
1394    let mut tokens = Vec::new();
1395    let mut current = String::new();
1396    let mut quote = None;
1397    let mut escaped = false;
1398    let mut chars = command.chars().peekable();
1399    while let Some(ch) = chars.next() {
1400        if escaped {
1401            current.push(ch);
1402            escaped = false;
1403            continue;
1404        }
1405        if ch == '\\' {
1406            escaped = true;
1407            continue;
1408        }
1409        if quote == Some(ch) {
1410            quote = None;
1411            continue;
1412        }
1413        if quote.is_some() {
1414            current.push(ch);
1415            continue;
1416        }
1417        if ch == '\'' || ch == '"' {
1418            quote = Some(ch);
1419            continue;
1420        }
1421        if ch == '#' && current.is_empty() {
1422            for next in chars.by_ref() {
1423                if next == '\n' {
1424                    flush_segment(&mut segments, &mut tokens);
1425                    break;
1426                }
1427            }
1428            continue;
1429        }
1430        if ch.is_whitespace() {
1431            flush_word(&mut tokens, &mut current);
1432            if ch == '\n' {
1433                flush_segment(&mut segments, &mut tokens);
1434            }
1435            continue;
1436        }
1437        if ch == '&' && chars.peek() == Some(&'>') {
1438            flush_word(&mut tokens, &mut current);
1439            chars.next();
1440            let operator = if chars.peek() == Some(&'>') {
1441                chars.next();
1442                "&>>"
1443            } else {
1444                "&>"
1445            };
1446            tokens.push(operator.to_string());
1447            continue;
1448        }
1449        if matches!(ch, ';' | '|' | '(' | ')')
1450            || (ch == '&' && !is_redirection_token(tokens.last().map(String::as_str).unwrap_or("")))
1451        {
1452            flush_word(&mut tokens, &mut current);
1453            if (ch == '|' || ch == '&') && chars.peek() == Some(&ch) {
1454                chars.next();
1455            }
1456            flush_segment(&mut segments, &mut tokens);
1457            continue;
1458        }
1459        if ch == '>' || ch == '<' {
1460            flush_word(&mut tokens, &mut current);
1461            let mut operator = ch.to_string();
1462            while chars.peek() == Some(&ch) && operator.len() < 3 {
1463                operator.push(chars.next().expect("peeked redirection"));
1464            }
1465            tokens.push(operator);
1466            continue;
1467        }
1468        current.push(ch);
1469    }
1470    flush_word(&mut tokens, &mut current);
1471    flush_segment(&mut segments, &mut tokens);
1472    segments
1473}
1474
1475/// Remove heredoc payloads before applying the deliberately small shell
1476/// grammar. A heredoc body is data, not a sequence of executed commands, so
1477/// promoting path-like text from it would create fabricated file evidence.
1478fn strip_heredoc_bodies(command: &str) -> String {
1479    fn delimiters(line: &str) -> Vec<String> {
1480        let bytes = line.as_bytes();
1481        let mut output = Vec::new();
1482        let mut index = 0;
1483        while index + 1 < bytes.len() {
1484            if bytes[index] != b'<' || bytes[index + 1] != b'<' {
1485                index += 1;
1486                continue;
1487            }
1488            index += 2;
1489            if bytes.get(index) == Some(&b'<') {
1490                index += 1;
1491                continue;
1492            }
1493            if bytes.get(index) == Some(&b'-') {
1494                index += 1;
1495            }
1496            while bytes.get(index).is_some_and(u8::is_ascii_whitespace) {
1497                index += 1;
1498            }
1499            let quote = bytes
1500                .get(index)
1501                .copied()
1502                .filter(|value| *value == b'\'' || *value == b'"');
1503            if quote.is_some() {
1504                index += 1;
1505            }
1506            let start = index;
1507            while let Some(value) = bytes.get(index) {
1508                if quote.is_some_and(|quote| *value == quote)
1509                    || (quote.is_none()
1510                        && (value.is_ascii_whitespace() || b";|&><".contains(value)))
1511                {
1512                    break;
1513                }
1514                index += 1;
1515            }
1516            if start < index {
1517                output.push(line[start..index].to_string());
1518            }
1519            if quote.is_some() && bytes.get(index) == quote.as_ref() {
1520                index += 1;
1521            }
1522        }
1523        output
1524    }
1525
1526    let mut pending = VecDeque::<String>::new();
1527    let mut output = Vec::new();
1528    for line in command.lines() {
1529        if let Some(delimiter) = pending.front() {
1530            if line.trim_start_matches('\t').trim_end() == delimiter {
1531                pending.pop_front();
1532            }
1533            continue;
1534        }
1535        output.push(line);
1536        pending.extend(delimiters(line));
1537    }
1538    output.join("\n")
1539}
1540
1541fn is_redirection_token(token: &str) -> bool {
1542    [">", ">>", "&>", "&>>", "<", "<<", "<<<", "<>"].contains(&token)
1543}
1544
1545fn is_output_redirection_token(token: &str) -> bool {
1546    [">", ">>", "&>", "&>>"].contains(&token)
1547}
1548
1549fn output_redirection_targets(parts: &[String]) -> Vec<String> {
1550    let mut targets = Vec::new();
1551    let mut index = 0;
1552    while index < parts.len() {
1553        let token = parts[index].as_str();
1554        if is_output_redirection_token(token) {
1555            if let Some(target) = parts.get(index + 1)
1556                && !target.starts_with('&')
1557                && plausible_path_token(target)
1558            {
1559                targets.push(target.clone());
1560            }
1561            index += 2;
1562            continue;
1563        }
1564        index += 1;
1565    }
1566    targets
1567}
1568
1569fn input_redirection_targets(parts: &[String]) -> Vec<String> {
1570    let mut targets = Vec::new();
1571    let mut index = 0;
1572    while index < parts.len() {
1573        if ["<", "<>"].contains(&parts[index].as_str()) {
1574            if let Some(target) = parts.get(index + 1)
1575                && plausible_path_token(target)
1576            {
1577                targets.push(target.clone());
1578            }
1579            index += 2;
1580            continue;
1581        }
1582        index += 1;
1583    }
1584    targets
1585}
1586
1587fn extract_domains(text: &str) -> Vec<String> {
1588    let mut domains = BTreeSet::new();
1589    for part in text.split(|c: char| c.is_whitespace() || ['"', '\'', ')', '('].contains(&c)) {
1590        let stripped = part
1591            .strip_prefix("https://")
1592            .or_else(|| part.strip_prefix("http://"));
1593        if let Some(rest) = stripped
1594            && let Some(domain) = rest.split('/').next()
1595            && !domain.is_empty()
1596        {
1597            domains.insert(domain.to_ascii_lowercase());
1598        }
1599        for known in [
1600            "github.com",
1601            "crates.io",
1602            "huggingface.co",
1603            "hf.co",
1604            "openai.com",
1605            "anthropic.com",
1606        ] {
1607            if part.contains(known) {
1608                domains.insert(known.to_string());
1609            }
1610        }
1611    }
1612    domains.into_iter().collect()
1613}
1614
1615fn extract_path_groups(
1616    project_root: &Path,
1617    name: &str,
1618    input: &Value,
1619    command: &str,
1620) -> Vec<String> {
1621    let mut groups = BTreeSet::new();
1622    if ["write", "edit", "multiedit", "notebookedit", "read"]
1623        .contains(&name.to_ascii_lowercase().as_str())
1624    {
1625        for key in ["file_path", "path"] {
1626            if let Some(path) = input.get(key).and_then(Value::as_str) {
1627                groups.insert(path_group(path, project_root));
1628            }
1629        }
1630    }
1631    for (path, _) in shell_path_references_at(
1632        command,
1633        &command_effect(command),
1634        shell_working_directory(input),
1635    ) {
1636        groups.insert(path_group(&path, project_root));
1637    }
1638    groups.into_iter().filter(|v| v != "none").collect()
1639}
1640
1641fn extract_path_references(
1642    project_root: &Path,
1643    name: &str,
1644    input: &Value,
1645    command: &str,
1646    effect: &str,
1647) -> Vec<PathReference> {
1648    let lower_name = name.to_ascii_lowercase();
1649    let default_access = if effect == "write" {
1650        "write"
1651    } else if lower_name.contains("read")
1652        || lower_name.contains("search")
1653        || lower_name.contains("glob")
1654        || lower_name.contains("grep")
1655    {
1656        "read"
1657    } else {
1658        "reference"
1659    };
1660    let is_shell =
1661        lower_name.contains("bash") || lower_name.contains("exec") || lower_name.contains("shell");
1662    let mut refs = BTreeMap::<(String, String), String>::new();
1663    // Shell payloads are command languages, not structured path records. A
1664    // nested field named `path` may belong to jq, JSON, or a child process and
1665    // must not be promoted to file evidence. Parse shell operands below.
1666    if !is_shell {
1667        collect_input_path_references(input, project_root, default_access, 0, &mut refs);
1668    }
1669
1670    for (path, access) in patch_path_references(command) {
1671        if let Some(path) = repository_relative_path(project_root, &path) {
1672            refs.insert((path, access.to_string()), "patch".to_string());
1673        }
1674    }
1675    if let Some(patch) = find_string_field(input, &["patch", "input"], 0) {
1676        for (path, access) in patch_path_references(patch) {
1677            if let Some(path) = repository_relative_path(project_root, &path) {
1678                refs.insert((path, access.to_string()), "patch".to_string());
1679            }
1680        }
1681    }
1682
1683    if is_shell {
1684        for (raw, access) in
1685            shell_path_references_at(command, effect, shell_working_directory(input))
1686        {
1687            if let Some(path) = repository_relative_path(project_root, &raw) {
1688                refs.entry((path, access))
1689                    .or_insert_with(|| "command".to_string());
1690            }
1691        }
1692    }
1693
1694    refs.into_iter()
1695        .map(|((path, access), source)| PathReference {
1696            path,
1697            access,
1698            source,
1699        })
1700        .collect()
1701}
1702
1703fn shell_path_references_at(
1704    command: &str,
1705    effect: &str,
1706    working_directory: Option<&Path>,
1707) -> Vec<(String, String)> {
1708    shell_path_references_inner(
1709        command,
1710        effect,
1711        0,
1712        Some(working_directory.map_or_else(PathBuf::new, Path::to_path_buf)),
1713    )
1714}
1715
1716fn shell_path_references_inner(
1717    command: &str,
1718    effect: &str,
1719    depth: usize,
1720    mut working_directory: Option<PathBuf>,
1721) -> Vec<(String, String)> {
1722    if depth > 2 {
1723        return Vec::new();
1724    }
1725    let mut refs = Vec::new();
1726    for parts in shell_segments(command) {
1727        if update_shell_working_directory(&parts, &mut working_directory) {
1728            continue;
1729        }
1730        refs.extend(shell_segment_path_references(
1731            &parts,
1732            effect,
1733            depth,
1734            working_directory.as_deref(),
1735        ));
1736    }
1737    refs
1738}
1739
1740fn shell_segment_path_references(
1741    parts: &[String],
1742    effect: &str,
1743    depth: usize,
1744    working_directory: Option<&Path>,
1745) -> Vec<(String, String)> {
1746    let Some(command_index) = shell_command_index(parts) else {
1747        return Vec::new();
1748    };
1749    let command_name = process_name_from_part(&parts[command_index]).unwrap_or_default();
1750    let redirect_targets = output_redirection_targets(parts);
1751    let input_targets = input_redirection_targets(parts);
1752    let mut operands = Vec::new();
1753    let mut index = command_index + 1;
1754    while index < parts.len() {
1755        if is_redirection_token(&parts[index]) {
1756            index += 2;
1757            continue;
1758        }
1759        operands.push(parts[index].clone());
1760        index += 1;
1761    }
1762    let mut refs = redirect_targets
1763        .into_iter()
1764        .map(|path| (path, "write".to_string()))
1765        .collect::<Vec<_>>();
1766    refs.extend(
1767        input_targets
1768            .into_iter()
1769            .map(|path| (path, "read".to_string())),
1770    );
1771
1772    if ["bash", "sh", "zsh"].contains(&command_name.as_str()) {
1773        for flag_index in 0..operands.len().saturating_sub(1) {
1774            if ["-c", "-lc", "-cl"].contains(&operands[flag_index].as_str()) {
1775                refs.extend(shell_path_references_inner(
1776                    &operands[flag_index + 1],
1777                    effect,
1778                    depth + 1,
1779                    working_directory.map(Path::to_path_buf),
1780                ));
1781                break;
1782            }
1783        }
1784        return resolve_shell_paths(refs, working_directory);
1785    }
1786
1787    let path_operands = |values: &[String]| {
1788        values
1789            .iter()
1790            .filter(|part| !part.starts_with('-') && plausible_path_token(part))
1791            .cloned()
1792            .collect::<Vec<_>>()
1793    };
1794    match command_name.as_str() {
1795        "cp" => refs.extend(cp_path_references(&operands)),
1796        "mv" | "rm" | "mkdir" | "touch" | "tee" => refs.extend(
1797            path_operands(&operands)
1798                .into_iter()
1799                .map(|path| (path, "write".to_string())),
1800        ),
1801        "sed" => refs.extend(sed_path_references(
1802            &operands,
1803            operands.iter().any(|operand| {
1804                operand == "-i"
1805                    || operand.starts_with("-i")
1806                    || operand == "--in-place"
1807                    || operand.starts_with("--in-place=")
1808            }),
1809        )),
1810        "cat" | "head" | "tail" | "ls" | "nl" | "wc" | "source" | "." => {
1811            refs.extend(
1812                path_operands(&operands)
1813                    .into_iter()
1814                    .map(|path| (path, "read".to_string())),
1815            );
1816        }
1817        "find" => refs.extend(find_path_operands(&operands)),
1818        "rg" | "grep" => refs.extend(search_path_operands(&operands)),
1819        "jq" => refs.extend(jq_path_operands(&operands)),
1820        // Unknown command grammars are deliberately pathless. Exact paths are
1821        // evidence, so a false negative is safer than turning a revspec, API
1822        // route, image name, loop variable, or expression into a file write.
1823        _ => {}
1824    }
1825    resolve_shell_paths(refs, working_directory)
1826}
1827
1828fn cp_path_references(operands: &[String]) -> Vec<(String, String)> {
1829    let plausible_operand = |value: &str| {
1830        plausible_path_token(value)
1831            || (!value.is_empty()
1832                && !value.starts_with(['-', '$'])
1833                && value
1834                    .chars()
1835                    .all(|ch| ch.is_ascii_alphanumeric() || "._-/".contains(ch)))
1836    };
1837    let mut paths = Vec::new();
1838    let mut target_directory = None;
1839    let mut index = 0;
1840    while index < operands.len() {
1841        let operand = &operands[index];
1842        if ["-t", "--target-directory"].contains(&operand.as_str()) {
1843            if let Some(target) = operands.get(index + 1)
1844                && plausible_operand(target)
1845            {
1846                target_directory = Some(target.clone());
1847            }
1848            index += 2;
1849            continue;
1850        }
1851        if let Some(target) = operand.strip_prefix("--target-directory=") {
1852            if plausible_operand(target) {
1853                target_directory = Some(target.to_string());
1854            }
1855            index += 1;
1856            continue;
1857        }
1858        if ["-S", "--suffix"].contains(&operand.as_str()) {
1859            index += 2;
1860            continue;
1861        }
1862        if !operand.starts_with('-') && plausible_operand(operand) {
1863            paths.push(operand.clone());
1864        }
1865        index += 1;
1866    }
1867
1868    let mut refs = Vec::new();
1869    if let Some(target) = target_directory {
1870        let preserve_parents = operands.iter().any(|value| value == "--parents");
1871        for source in paths {
1872            refs.push((source.clone(), "read".to_string()));
1873            let relative_target = if preserve_parents {
1874                Some(source.trim_start_matches('/'))
1875            } else {
1876                Path::new(&source)
1877                    .file_name()
1878                    .and_then(|value| value.to_str())
1879            };
1880            if let Some(relative_target) = relative_target {
1881                refs.push((
1882                    Path::new(&target)
1883                        .join(relative_target)
1884                        .to_string_lossy()
1885                        .into_owned(),
1886                    "write".to_string(),
1887                ));
1888            }
1889        }
1890    } else if let Some((target, sources)) = paths.split_last() {
1891        refs.extend(
1892            sources
1893                .iter()
1894                .cloned()
1895                .map(|path| (path, "read".to_string())),
1896        );
1897        refs.push((target.clone(), "write".to_string()));
1898    }
1899    refs
1900}
1901
1902fn resolve_shell_paths(
1903    refs: Vec<(String, String)>,
1904    working_directory: Option<&Path>,
1905) -> Vec<(String, String)> {
1906    refs.into_iter()
1907        .filter_map(|(path, access)| {
1908            shell_path_at_working_directory(&path, working_directory).map(|path| (path, access))
1909        })
1910        .collect()
1911}
1912
1913fn shell_working_directory(input: &Value) -> Option<&Path> {
1914    ["workdir", "cwd"]
1915        .iter()
1916        .find_map(|key| input.get(*key).and_then(Value::as_str))
1917        .map(Path::new)
1918}
1919
1920fn shell_path_at_working_directory(path: &str, working_directory: Option<&Path>) -> Option<String> {
1921    if path.starts_with('~') || path.starts_with('$') {
1922        return None;
1923    }
1924    let path = Path::new(path);
1925    if path.is_absolute() {
1926        return Some(path.to_string_lossy().into_owned());
1927    }
1928    let working_directory = working_directory?;
1929    Some(working_directory.join(path).to_string_lossy().into_owned())
1930}
1931
1932fn update_shell_working_directory(
1933    parts: &[String],
1934    working_directory: &mut Option<PathBuf>,
1935) -> bool {
1936    let Some(command_index) = shell_command_index(parts) else {
1937        return false;
1938    };
1939    if process_name_from_part(&parts[command_index]).as_deref() != Some("cd") {
1940        return false;
1941    }
1942    let target = parts[command_index + 1..]
1943        .iter()
1944        .find(|part| !part.starts_with('-'))
1945        .filter(|part| !part.starts_with('$') && !part.is_empty())
1946        .map(PathBuf::from);
1947    *working_directory = match (working_directory.take(), target) {
1948        (_, None) => None,
1949        (_, Some(target)) if target.is_absolute() => Some(target),
1950        (Some(base), Some(target)) => Some(base.join(target)),
1951        (None, Some(_)) => None,
1952    };
1953    true
1954}
1955
1956fn shell_command_index(parts: &[String]) -> Option<usize> {
1957    let mut index = 0;
1958    while index < parts.len() {
1959        let part = parts[index].as_str();
1960        if ["then", "do", "else"].contains(&part) || is_shell_assignment(part) {
1961            index += 1;
1962            continue;
1963        }
1964        if ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(&part) {
1965            index += 1;
1966            while index < parts.len()
1967                && (parts[index].starts_with('-') || is_shell_assignment(&parts[index]))
1968            {
1969                index += 1;
1970            }
1971            continue;
1972        }
1973        return Some(index);
1974    }
1975    None
1976}
1977
1978fn is_shell_assignment(value: &str) -> bool {
1979    value.split_once('=').is_some_and(|(name, _)| {
1980        !name.is_empty()
1981            && name
1982                .chars()
1983                .all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1984    })
1985}
1986
1987fn find_path_operands(operands: &[String]) -> Vec<(String, String)> {
1988    operands
1989        .iter()
1990        .take_while(|part| !part.starts_with('-') && !["!", "("].contains(&part.as_str()))
1991        .filter(|part| plausible_path_token(part))
1992        .cloned()
1993        .map(|path| (path, "read".to_string()))
1994        .collect()
1995}
1996
1997fn search_path_operands(operands: &[String]) -> Vec<(String, String)> {
1998    let mut refs = Vec::new();
1999    let mut pattern_seen = operands.iter().any(|part| part == "--files");
2000    let mut index = 0;
2001    while index < operands.len() {
2002        let part = operands[index].as_str();
2003        if part == "--" {
2004            refs.extend(
2005                operands[index + 1..]
2006                    .iter()
2007                    .filter(|path| plausible_path_token(path))
2008                    .cloned()
2009                    .map(|path| (path, "read".to_string())),
2010            );
2011            break;
2012        }
2013        let option_with_value = [
2014            "-e",
2015            "--regexp",
2016            "-f",
2017            "--file",
2018            "-g",
2019            "--glob",
2020            "-t",
2021            "--type",
2022            "-m",
2023            "--max-count",
2024            "-A",
2025            "-B",
2026            "-C",
2027            "--context",
2028            "--encoding",
2029        ];
2030        if option_with_value.contains(&part) {
2031            if ["-e", "--regexp"].contains(&part) {
2032                pattern_seen = true;
2033            } else if ["-f", "--file"].contains(&part)
2034                && let Some(path) = operands.get(index + 1)
2035                && plausible_path_token(path)
2036            {
2037                refs.push((path.clone(), "read".to_string()));
2038            }
2039            index += 2;
2040            continue;
2041        }
2042        if part.starts_with('-') {
2043            index += 1;
2044            continue;
2045        }
2046        if !pattern_seen {
2047            pattern_seen = true;
2048        } else if plausible_path_token(part) {
2049            refs.push((part.to_string(), "read".to_string()));
2050        }
2051        index += 1;
2052    }
2053    refs
2054}
2055
2056fn jq_path_operands(operands: &[String]) -> Vec<(String, String)> {
2057    let mut refs = Vec::new();
2058    let mut filter_seen = false;
2059    let mut index = 0;
2060    while index < operands.len() {
2061        let part = operands[index].as_str();
2062        if ["-f", "--from-file"].contains(&part) {
2063            filter_seen = true;
2064            if let Some(path) = operands.get(index + 1)
2065                && plausible_path_token(path)
2066            {
2067                refs.push((path.clone(), "read".to_string()));
2068            }
2069            index += 2;
2070            continue;
2071        }
2072        if part.starts_with('-') {
2073            index += 1;
2074            continue;
2075        }
2076        if !filter_seen {
2077            filter_seen = true;
2078        } else if plausible_path_token(part) {
2079            refs.push((part.to_string(), "read".to_string()));
2080        }
2081        index += 1;
2082    }
2083    refs
2084}
2085
2086fn sed_path_references(operands: &[String], in_place: bool) -> Vec<(String, String)> {
2087    let mut refs = Vec::new();
2088    let mut script_seen = false;
2089    let mut index = 0;
2090    while index < operands.len() {
2091        let part = &operands[index];
2092        if ["-e", "--expression"].contains(&part.as_str()) {
2093            script_seen = true;
2094            index += 2;
2095            continue;
2096        }
2097        if ["-f", "--file"].contains(&part.as_str()) {
2098            script_seen = true;
2099            if let Some(path) = operands.get(index + 1)
2100                && plausible_path_token(path)
2101            {
2102                refs.push((path.clone(), "read".to_string()));
2103            }
2104            index += 2;
2105            continue;
2106        }
2107        if let Some(path) = part.strip_prefix("--file=") {
2108            script_seen = true;
2109            if plausible_path_token(path) {
2110                refs.push((path.to_string(), "read".to_string()));
2111            }
2112            index += 1;
2113            continue;
2114        }
2115        if part.starts_with('-') {
2116            index += 1;
2117            continue;
2118        }
2119        if !script_seen {
2120            script_seen = true;
2121        } else if plausible_path_token(part) {
2122            refs.push((
2123                part.clone(),
2124                if in_place { "write" } else { "read" }.to_string(),
2125            ));
2126        }
2127        index += 1;
2128    }
2129    refs
2130}
2131
2132fn collect_input_path_references(
2133    value: &Value,
2134    project_root: &Path,
2135    access: &str,
2136    depth: usize,
2137    out: &mut BTreeMap<(String, String), String>,
2138) {
2139    if depth > 4 {
2140        return;
2141    }
2142    match value {
2143        Value::Object(map) => {
2144            for (key, value) in map {
2145                let lower = key.to_ascii_lowercase();
2146                if [
2147                    "file_path",
2148                    "filepath",
2149                    "path",
2150                    "notebook_path",
2151                    "dir_path",
2152                    "directory",
2153                ]
2154                .contains(&lower.as_str())
2155                    && let Some(raw) = value.as_str()
2156                    && let Some(path) = repository_relative_path(project_root, raw)
2157                {
2158                    out.insert((path, access.to_string()), format!("input:{key}"));
2159                }
2160                if lower == "changes"
2161                    && let Some(changes) = value.as_object()
2162                {
2163                    for raw in changes.keys() {
2164                        if let Some(path) = repository_relative_path(project_root, raw) {
2165                            out.insert((path, "write".to_string()), "input:changes".to_string());
2166                        }
2167                    }
2168                }
2169                collect_input_path_references(value, project_root, access, depth + 1, out);
2170            }
2171        }
2172        Value::Array(values) => {
2173            for value in values {
2174                collect_input_path_references(value, project_root, access, depth + 1, out);
2175            }
2176        }
2177        _ => {}
2178    }
2179}
2180
2181fn repository_relative_path(project_root: &Path, raw: &str) -> Option<String> {
2182    let raw = raw.trim().trim_matches(['"', '\'']);
2183    // A shell-relative token beginning with a home/environment expansion is
2184    // not relative to the repository, even though `Path` treats it as such.
2185    // Never serialize it as a repository path: apart from being semantically
2186    // wrong, native histories commonly expose private tool-state locations in
2187    // this form (for example `~/.claude/...`).
2188    if raw.is_empty() || raw.starts_with('~') || raw.starts_with('$') {
2189        return None;
2190    }
2191    let path = Path::new(raw);
2192    let repository_root = containing_git_root(project_root);
2193    let relative = if path.is_absolute() {
2194        if project_root.as_os_str().is_empty() {
2195            return None;
2196        }
2197        if let Some(repository_root) = repository_root.as_deref() {
2198            let target = lexical_normalize(path)?;
2199            if !target.starts_with(repository_root) {
2200                return None;
2201            }
2202            lexical_relative(project_root, &target)?
2203        } else {
2204            path.strip_prefix(project_root).ok()?.to_path_buf()
2205        }
2206    } else if path
2207        .components()
2208        .any(|component| component == std::path::Component::ParentDir)
2209    {
2210        let repository_root = repository_root?;
2211        let target = lexical_normalize(&project_root.join(path))?;
2212        if !target.starts_with(&repository_root) {
2213            return None;
2214        }
2215        lexical_relative(project_root, &target)?
2216    } else {
2217        path.to_path_buf()
2218    };
2219    let mut parts = Vec::new();
2220    for component in relative.components() {
2221        match component {
2222            std::path::Component::CurDir => {}
2223            std::path::Component::Normal(part) => {
2224                let part = part.to_str()?;
2225                if part.is_empty() || part == ".git" {
2226                    return None;
2227                }
2228                parts.push(part);
2229            }
2230            std::path::Component::ParentDir => parts.push(".."),
2231            std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
2232        }
2233    }
2234    (!parts.is_empty()).then(|| parts.join("/"))
2235}
2236
2237fn containing_git_root(cwd: &Path) -> Option<PathBuf> {
2238    let mut cursor = cwd;
2239    loop {
2240        if cursor.join(".git").exists() {
2241            return Some(cursor.to_path_buf());
2242        }
2243        cursor = cursor.parent()?;
2244    }
2245}
2246
2247fn lexical_normalize(path: &Path) -> Option<PathBuf> {
2248    let mut output = PathBuf::new();
2249    for component in path.components() {
2250        match component {
2251            std::path::Component::Prefix(prefix) => output.push(prefix.as_os_str()),
2252            std::path::Component::RootDir => output.push(Path::new("/")),
2253            std::path::Component::CurDir => {}
2254            std::path::Component::Normal(part) => output.push(part),
2255            std::path::Component::ParentDir => {
2256                if !output.pop() {
2257                    return None;
2258                }
2259            }
2260        }
2261    }
2262    Some(output)
2263}
2264
2265fn lexical_relative(base: &Path, target: &Path) -> Option<PathBuf> {
2266    let base = lexical_normalize(base)?;
2267    let target = lexical_normalize(target)?;
2268    let base_parts = base.components().collect::<Vec<_>>();
2269    let target_parts = target.components().collect::<Vec<_>>();
2270    let common = base_parts
2271        .iter()
2272        .zip(&target_parts)
2273        .take_while(|(left, right)| left == right)
2274        .count();
2275    if common == 0 {
2276        return None;
2277    }
2278    let mut relative = PathBuf::new();
2279    for _ in common..base_parts.len() {
2280        relative.push("..");
2281    }
2282    for component in &target_parts[common..] {
2283        relative.push(component.as_os_str());
2284    }
2285    Some(relative)
2286}
2287
2288fn patch_path_references(text: &str) -> Vec<(String, &'static str)> {
2289    let mut refs = Vec::new();
2290    for line in text.lines() {
2291        for (prefix, access) in [
2292            ("*** Add File: ", "write"),
2293            ("*** Update File: ", "write"),
2294            ("*** Delete File: ", "write"),
2295            ("*** Move to: ", "write"),
2296        ] {
2297            if let Some(path) = line.trim().strip_prefix(prefix) {
2298                refs.push((path.trim().to_string(), access));
2299            }
2300        }
2301    }
2302    refs
2303}
2304
2305fn edit_summary_from_input(
2306    name: &str,
2307    input: &Value,
2308    command: &str,
2309    effect: &str,
2310    path_refs: &[PathReference],
2311) -> Option<EditSummary> {
2312    if effect != "write" {
2313        return None;
2314    }
2315    let write_paths = path_refs
2316        .iter()
2317        .filter(|reference| reference.access == "write")
2318        .map(|reference| reference.path.as_str())
2319        .collect::<BTreeSet<_>>();
2320    // An event-level fingerprint can only be compared safely with a Git hunk
2321    // when it describes exactly one repository path. Multi-file patches keep
2322    // their path observations but deliberately carry no exact-hunk claim.
2323    if write_paths.len() != 1 {
2324        return None;
2325    }
2326    let before = find_string_field(input, &["old_string", "oldText", "before"], 0);
2327    let after = find_string_field(input, &["new_string", "newText", "content", "after"], 0);
2328    if before.is_some() || after.is_some() {
2329        return Some(EditSummary {
2330            before_bytes: before.map_or(0, |value| value.len() as u64),
2331            after_bytes: after.map_or(0, |value| value.len() as u64),
2332            removed_lines: before.map_or(0, line_count),
2333            added_lines: after.map_or(0, line_count),
2334            before_hash: before.map(content_fingerprint),
2335            after_hash: after.map(content_fingerprint),
2336            payload_kind: if before.is_some() {
2337                "replace".to_string()
2338            } else {
2339                "write".to_string()
2340            },
2341        });
2342    }
2343
2344    let patch = find_string_field(input, &["patch", "input", "unified_diff"], 0)
2345        .filter(|value| value.contains("*** Begin Patch") || value.contains("@@"))
2346        .or_else(|| {
2347            (command.contains("*** Begin Patch") || command.contains("@@")).then_some(command)
2348        });
2349    if let Some(patch) = patch {
2350        let hunk_markers = patch.lines().filter(|line| line.starts_with("@@")).count();
2351        let file_markers = patch
2352            .lines()
2353            .filter(|line| {
2354                [
2355                    "*** Add File: ",
2356                    "*** Update File: ",
2357                    "*** Delete File: ",
2358                    "*** Move to: ",
2359                ]
2360                .iter()
2361                .any(|prefix| line.trim().starts_with(prefix))
2362            })
2363            .count();
2364        if hunk_markers > 1 || file_markers > 1 {
2365            return None;
2366        }
2367        let added = patch
2368            .lines()
2369            .filter(|line| line.starts_with('+') && !line.starts_with("+++"))
2370            .map(|line| &line[1..])
2371            .collect::<Vec<_>>();
2372        let removed = patch
2373            .lines()
2374            .filter(|line| line.starts_with('-') && !line.starts_with("---"))
2375            .map(|line| &line[1..])
2376            .collect::<Vec<_>>();
2377        let added_text = added.join("\n");
2378        let removed_text = removed.join("\n");
2379        return Some(EditSummary {
2380            before_bytes: removed_text.len() as u64,
2381            after_bytes: added_text.len() as u64,
2382            added_lines: added.len() as u64,
2383            removed_lines: removed.len() as u64,
2384            before_hash: (!removed.is_empty()).then(|| content_fingerprint(&removed_text)),
2385            after_hash: (!added.is_empty()).then(|| content_fingerprint(&added_text)),
2386            payload_kind: "patch".to_string(),
2387        });
2388    }
2389
2390    Some(EditSummary {
2391        payload_kind: one_word(name, "write"),
2392        ..EditSummary::default()
2393    })
2394}
2395
2396fn find_string_field<'a>(value: &'a Value, keys: &[&str], depth: usize) -> Option<&'a str> {
2397    if depth > 4 {
2398        return None;
2399    }
2400    match value {
2401        Value::Object(map) => {
2402            for key in keys {
2403                if let Some(value) = map.get(*key).and_then(Value::as_str) {
2404                    return Some(value);
2405                }
2406            }
2407            map.values()
2408                .find_map(|value| find_string_field(value, keys, depth + 1))
2409        }
2410        Value::Array(values) => values
2411            .iter()
2412            .find_map(|value| find_string_field(value, keys, depth + 1)),
2413        _ => None,
2414    }
2415}
2416
2417fn line_count(value: &str) -> u64 {
2418    if value.is_empty() {
2419        0
2420    } else {
2421        value.lines().count() as u64
2422    }
2423}
2424
2425fn content_fingerprint(value: &str) -> String {
2426    let normalized = value.replace("\r\n", "\n");
2427    short_hash(normalized.trim_end_matches('\n'), 24)
2428}
2429
2430fn plausible_path_token(part: &str) -> bool {
2431    let part = part.trim_matches(['"', '\'']);
2432    let lower = part.to_ascii_lowercase();
2433    let components = part.split('/').collect::<Vec<_>>();
2434    let looks_like_slash_separated_phrase = components.len() >= 3
2435        && components.iter().all(|component| {
2436            component.chars().all(char::is_alphabetic)
2437                && component.chars().next().is_some_and(char::is_uppercase)
2438        });
2439    if part.is_empty()
2440        || part.starts_with('-')
2441        || part.starts_with('$')
2442        || part.starts_with("http://")
2443        || part.starts_with("https://")
2444        || lower.starts_with("origin/")
2445        || lower.starts_with("refs/")
2446        || lower.starts_with("repos/")
2447        || part == "HEAD"
2448        || part.starts_with("HEAD.")
2449        || part.contains("...")
2450        || looks_like_slash_separated_phrase
2451        || part.len() > 140
2452        || part.chars().any(char::is_whitespace)
2453        || part.chars().any(|c| "{}()=;<>|`*?[]\"#$,:@^!".contains(c))
2454    {
2455        return false;
2456    }
2457    let suffix = Path::new(part)
2458        .extension()
2459        .and_then(|v| v.to_str())
2460        .unwrap_or("");
2461    part.contains('/')
2462        || [
2463            "rs", "py", "md", "json", "ts", "tsx", "toml", "lock", "js", "c", "h", "svg", "html",
2464            "css",
2465        ]
2466        .contains(&suffix)
2467}
2468
2469pub fn path_group(path: &str, project_root: &Path) -> String {
2470    let path = path.trim_matches(['"', '\'']);
2471    if path.is_empty() {
2472        return "none".to_string();
2473    }
2474    let p = Path::new(path);
2475    let parts = if p.is_absolute() {
2476        if let Ok(rel) = p.strip_prefix(project_root) {
2477            path_component_strings(rel)
2478        } else {
2479            return external_path_group(path, &path_component_strings(p));
2480        }
2481    } else {
2482        let parts = path_component_strings(p);
2483        if let Some(group) = sensitive_relative_path_group(path, &parts) {
2484            return group;
2485        }
2486        parts
2487    };
2488    collapse_project_path(parts)
2489}
2490
2491pub fn path_component_strings(path: &Path) -> Vec<String> {
2492    path.components()
2493        .filter_map(|c| {
2494            let part = c.as_os_str().to_string_lossy();
2495            let part = part.as_ref();
2496            if part == "." || part == "/" || part.is_empty() {
2497                None
2498            } else {
2499                Some(part.to_string())
2500            }
2501        })
2502        .collect()
2503}
2504
2505pub fn collapse_project_path(parts: Vec<String>) -> String {
2506    let parts = parts
2507        .into_iter()
2508        .filter(|part| part != "." && !part.is_empty())
2509        .map(|part| truncate_path_component(&part))
2510        .collect::<Vec<_>>();
2511    if parts.is_empty() {
2512        "repo".to_string()
2513    } else if [
2514        "collector",
2515        "frontend",
2516        "docs",
2517        "bpf",
2518        "agentpprof",
2519        "agent-session",
2520    ]
2521    .contains(&parts[0].as_str())
2522    {
2523        parts.into_iter().take(3).collect::<Vec<_>>().join("/")
2524    } else {
2525        parts.into_iter().take(2).collect::<Vec<_>>().join("/")
2526    }
2527}
2528
2529fn truncate_path_component(part: &str) -> String {
2530    if part.chars().count() > 48 {
2531        format!("{}...", part.chars().take(45).collect::<String>())
2532    } else {
2533        part.to_string()
2534    }
2535}
2536
2537fn external_path_group(raw: &str, parts: &[String]) -> String {
2538    sensitive_relative_path_group(raw, parts).unwrap_or_else(|| "external/path".to_string())
2539}
2540
2541fn sensitive_relative_path_group(raw: &str, parts: &[String]) -> Option<String> {
2542    let lowered = raw.to_ascii_lowercase();
2543    let lower_parts = parts
2544        .iter()
2545        .map(|part| part.to_ascii_lowercase())
2546        .collect::<Vec<_>>();
2547    if lower_parts.iter().any(|part| part == ".codex") {
2548        Some("external/codex".to_string())
2549    } else if lower_parts.iter().any(|part| part == ".claude") {
2550        Some("external/claude".to_string())
2551    } else if lower_parts.first().is_some_and(|part| part == "tmp")
2552        || lowered.contains("/tmp")
2553        || lowered.contains("_/tmp")
2554        || lower_parts
2555            .windows(2)
2556            .any(|window| window[0] == "var" && window[1] == "tmp")
2557    {
2558        Some("external/tmp".to_string())
2559    } else if lowered.starts_with("~/")
2560        || lowered == "~"
2561        || lowered.contains("/home")
2562        || lowered.contains("_/home")
2563        || lowered.contains("-home-")
2564        || lowered.contains("/users")
2565        || lowered.contains("_/users")
2566        || looks_like_home_directory(&lower_parts)
2567        || contains_private_marker(&lowered)
2568    {
2569        Some("external/home".to_string())
2570    } else {
2571        None
2572    }
2573}
2574
2575pub fn looks_like_home_directory(parts: &[String]) -> bool {
2576    parts
2577        .first()
2578        .is_some_and(|part| part == "home" || part == "users")
2579}
2580
2581fn current_username() -> Option<String> {
2582    dirs::home_dir()
2583        .and_then(|home| {
2584            home.file_name()
2585                .map(|part| part.to_string_lossy().to_string())
2586        })
2587        .filter(|name| !name.is_empty())
2588}
2589
2590pub fn contains_private_marker(text: &str) -> bool {
2591    let lowered = text.to_ascii_lowercase();
2592    current_username()
2593        .map(|name| lowered.contains(&name.to_ascii_lowercase()))
2594        .unwrap_or(false)
2595}
2596
2597fn content_to_text(value: &Value) -> String {
2598    match value {
2599        Value::String(s) => s.clone(),
2600        Value::Array(items) => items
2601            .iter()
2602            .filter_map(|item| {
2603                if let Some(text) = item.as_str() {
2604                    return Some(text.to_string());
2605                }
2606                let typ = item.get("type").and_then(Value::as_str).unwrap_or("");
2607                if typ == "tool_result" || typ == "tool_use" || typ == "function_call" {
2608                    return None;
2609                }
2610                // For thinking blocks, extract the thinking field
2611                if typ == "thinking" {
2612                    return item
2613                        .get("thinking")
2614                        .and_then(Value::as_str)
2615                        .filter(|s| !s.is_empty())
2616                        .map(str::to_string);
2617                }
2618                item.get("text")
2619                    .or_else(|| item.get("content"))
2620                    .and_then(Value::as_str)
2621                    .map(str::to_string)
2622            })
2623            .collect::<Vec<_>>()
2624            .join("\n"),
2625        Value::Object(_) => value
2626            .get("text")
2627            .or_else(|| value.get("content"))
2628            .and_then(Value::as_str)
2629            .unwrap_or("")
2630            .to_string(),
2631        _ => String::new(),
2632    }
2633}
2634
2635fn claude_is_tool_result(content: &Value) -> bool {
2636    content.as_array().is_some_and(|items| {
2637        !items.is_empty()
2638            && items
2639                .iter()
2640                .all(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
2641    })
2642}
2643
2644fn claude_tool_result_ids(content: &Value) -> Vec<String> {
2645    content
2646        .as_array()
2647        .into_iter()
2648        .flatten()
2649        .filter_map(|item| {
2650            item.get("tool_use_id")
2651                .and_then(Value::as_str)
2652                .map(str::to_string)
2653        })
2654        .collect()
2655}
2656
2657fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
2658    let session_id = first_json_string(
2659        obj,
2660        &["sessionId", "session_id"],
2661        &["/payload/session_id", "/payload/sessionId"],
2662    );
2663    let conversation_id = first_json_string(
2664        obj,
2665        &["conversation_id", "conversationId", "thread_id", "threadId"],
2666        &[
2667            "/payload/conversation_id",
2668            "/payload/conversationId",
2669            "/payload/thread_id",
2670            "/payload/threadId",
2671        ],
2672    )
2673    .or_else(|| session_id.clone());
2674    (
2675        session_id.or_else(|| conversation_id.clone()),
2676        conversation_id,
2677    )
2678}
2679
2680fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
2681    keys.iter()
2682        .filter_map(|key| obj.get(*key).and_then(Value::as_str))
2683        .chain(
2684            pointers
2685                .iter()
2686                .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
2687        )
2688        .find(|value| !value.is_empty())
2689        .map(str::to_string)
2690}
2691
2692fn codex_exec_option_arity(arg: &str) -> Option<usize> {
2693    if arg.contains('=') && arg.starts_with("--") {
2694        return Some(1);
2695    }
2696
2697    match arg {
2698        "--json"
2699        | "--skip-git-repo-check"
2700        | "--ephemeral"
2701        | "--ignore-user-config"
2702        | "--full-auto"
2703        | "--dangerously-bypass-approvals-and-sandbox" => Some(1),
2704        "-C" | "-a" | "-s" | "-m" | "-c" | "-p" | "--cd" | "--model" | "--sandbox"
2705        | "--profile" | "--config" | "--ask-for-approval" | "--approval-policy"
2706        | "--output-format" | "--color" => Some(2),
2707        _ => None,
2708    }
2709}
2710
2711fn shell_words(input: &str) -> Option<Vec<String>> {
2712    let mut words = Vec::new();
2713    let mut current = String::new();
2714    let mut quote = None::<char>;
2715    let mut chars = input.chars().peekable();
2716
2717    while let Some(ch) = chars.next() {
2718        match (quote, ch) {
2719            (None, c) if c.is_whitespace() => {
2720                if !current.is_empty() {
2721                    words.push(std::mem::take(&mut current));
2722                }
2723            }
2724            (None, '\'' | '"') => quote = Some(ch),
2725            (Some(q), c) if c == q => quote = None,
2726            (_, '\\') => {
2727                if let Some(next) = chars.next() {
2728                    current.push(next);
2729                }
2730            }
2731            _ => current.push(ch),
2732        }
2733    }
2734    if quote.is_some() {
2735        return None;
2736    }
2737    if !current.is_empty() {
2738        words.push(current);
2739    }
2740    Some(words)
2741}
2742
2743fn claude_usage_key(obj: &Value) -> String {
2744    obj.get("requestId")
2745        .or_else(|| obj.pointer("/message/id"))
2746        .or_else(|| obj.get("uuid"))
2747        .and_then(Value::as_str)
2748        .unwrap_or("usage")
2749        .to_string()
2750}
2751
2752fn local_message_preview(value: &Value) -> Option<String> {
2753    let mut parts = Vec::new();
2754    collect_local_text(value, &mut parts);
2755    clean_prompt_text(&parts.join(" "))
2756}
2757
2758fn collect_local_text(value: &Value, out: &mut Vec<String>) {
2759    match value {
2760        Value::String(text) => out.push(text.clone()),
2761        Value::Array(items) => {
2762            for item in items {
2763                collect_local_text(item, out);
2764            }
2765        }
2766        Value::Object(obj) => {
2767            if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
2768                typ == "tool_use" || typ == "function_call" || typ == "tool_result"
2769            }) {
2770                return;
2771            }
2772            for key in ["text", "content", "message", "input", "prompt"] {
2773                if let Some(value) = obj.get(key) {
2774                    collect_local_text(value, out);
2775                }
2776            }
2777        }
2778        _ => {}
2779    }
2780}
2781
2782fn is_claude_tool_result(obj: &Value) -> bool {
2783    obj.get("toolUseResult").is_some()
2784        || obj.get("tool_use_result").is_some()
2785        || obj
2786            .pointer("/message/content")
2787            .and_then(Value::as_array)
2788            .is_some_and(|items| {
2789                items
2790                    .iter()
2791                    .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
2792            })
2793}
2794
2795fn find_file_arg(value: &Value) -> Option<&str> {
2796    match value {
2797        Value::Object(obj) => {
2798            for key in ["file_path", "path", "filepath"] {
2799                if let Some(path) = obj.get(key).and_then(Value::as_str) {
2800                    return Some(path);
2801                }
2802            }
2803            obj.values().find_map(find_file_arg)
2804        }
2805        Value::Array(items) => items.iter().find_map(find_file_arg),
2806        _ => None,
2807    }
2808}
2809
2810fn is_noise_path(path: &str) -> bool {
2811    const NOISE: &[&str] = &[
2812        "/.claude/",
2813        "/.codex/",
2814        "/.gemini/",
2815        "/.git/",
2816        "/node_modules/",
2817        "/.npm/",
2818        "/.cache/",
2819        "CLAUDE.md",
2820        "AGENTS.md",
2821    ];
2822    NOISE.iter().any(|pat| path.contains(pat))
2823}
2824
2825fn clean_prompt_text(text: &str) -> Option<String> {
2826    let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
2827    let text = text
2828        .strip_prefix("<session>")
2829        .and_then(|text| text.strip_suffix("</session>"))
2830        .unwrap_or(&text)
2831        .trim();
2832    (!text.is_empty()).then(|| text.to_string())
2833}
2834
2835pub fn short_hash(text: &str, n: usize) -> String {
2836    let digest = Sha256::digest(text.as_bytes());
2837    hex::encode(digest).chars().take(n).collect()
2838}
2839
2840pub fn truncate_clean(text: &str, limit: usize) -> String {
2841    let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
2842    if text.chars().count() <= limit {
2843        return text;
2844    }
2845    text.chars()
2846        .take(limit.saturating_sub(1))
2847        .collect::<String>()
2848        + "."
2849}
2850
2851pub fn one_word(text: &str, default: &str) -> String {
2852    let mut cur = String::new();
2853    for ch in text.to_ascii_lowercase().chars() {
2854        if ch.is_ascii_alphanumeric() {
2855            cur.push(ch);
2856        } else if cur.len() >= 2 {
2857            break;
2858        } else {
2859            cur.clear();
2860        }
2861    }
2862    if cur.len() >= 2 {
2863        cur
2864    } else {
2865        default.to_string()
2866    }
2867}
2868
2869fn short_session_id(id: &str) -> String {
2870    let id = id.trim();
2871    if id.is_empty() {
2872        return "session".to_string();
2873    }
2874    let compact = id
2875        .rsplit(['/', '\\'])
2876        .next()
2877        .unwrap_or(id)
2878        .trim_end_matches(".jsonl");
2879    const MAX_SESSION_ID_CHARS: usize = 12;
2880    if compact.chars().count() <= MAX_SESSION_ID_CHARS {
2881        return compact.to_string();
2882    }
2883    let head = compact.chars().take(6).collect::<String>();
2884    let tail = compact
2885        .chars()
2886        .rev()
2887        .take(5)
2888        .collect::<Vec<_>>()
2889        .into_iter()
2890        .rev()
2891        .collect::<String>();
2892    format!("{head}.{tail}")
2893}
2894
2895fn json_i64(value: &Value, key: &str) -> i64 {
2896    value.get(key).and_then(Value::as_i64).unwrap_or(0)
2897}
2898
2899fn json_u64(value: &Value, key: &str) -> u64 {
2900    value.get(key).and_then(Value::as_u64).unwrap_or(0)
2901}
2902
2903fn codex_uncached_input(input: i64, cache: i64) -> i64 {
2904    input.saturating_sub(cache)
2905}
2906
2907fn ts_ms_from_event(value: &Value) -> Option<i64> {
2908    value
2909        .get("timestamp")
2910        .and_then(Value::as_str)
2911        .and_then(parse_ts_ms)
2912}
2913
2914fn parse_ts_ms(value: &str) -> Option<i64> {
2915    chrono::DateTime::parse_from_rfc3339(value)
2916        .ok()
2917        .map(|ts| ts.timestamp_millis())
2918}
2919
2920fn iso_ms(value: &str) -> Option<u64> {
2921    chrono::DateTime::parse_from_rfc3339(value)
2922        .ok()
2923        .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
2924}
2925
2926fn system_time_ms(value: SystemTime) -> u64 {
2927    value
2928        .duration_since(UNIX_EPOCH)
2929        .unwrap_or_default()
2930        .as_millis() as u64
2931}
2932
2933#[cfg(test)]
2934mod tests {
2935    use super::*;
2936    use serde_json::json;
2937    use std::time::UNIX_EPOCH;
2938
2939    #[test]
2940    fn local_session_ids_keep_distinct_conversation_id() {
2941        assert_eq!(
2942            local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
2943            (Some("run".to_string()), Some("conv".to_string()))
2944        );
2945        assert_eq!(
2946            local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
2947            (Some("thread".to_string()), Some("thread".to_string()))
2948        );
2949        assert_eq!(
2950            local_session_ids(&json!({"payload": {"model": "gpt"}})),
2951            (None, None)
2952        );
2953    }
2954
2955    #[test]
2956    fn agent_jsonl_events_share_one_ir() {
2957        let codex = concat!(
2958            r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
2959            "\n",
2960            r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
2961            "\n",
2962            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
2963            "\n",
2964            r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}}"#,
2965        );
2966        let claude = concat!(
2967            r#"{"type":"user","message":{"content":"check build"}}"#,
2968            "\n",
2969            r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"cmd":"cargo check"}},{"type":"text","text":"checking"}],"usage":{"input_tokens":7,"cache_creation_input_tokens":2,"output_tokens":3}}}"#,
2970        );
2971
2972        for (agent, content, tool, model, tokens) in [
2973            (AGENT_CODEX, codex, "exec_command", "gpt-5", 15),
2974            (AGENT_CLAUDE, claude, "Bash", "claude-opus", 12),
2975        ] {
2976            let session = parse_session_content(
2977                agent,
2978                &PathBuf::from("/tmp/session.jsonl"),
2979                UNIX_EPOCH,
2980                content,
2981            )
2982            .expect("session");
2983            assert_eq!(session.events.tools[0].tool_name, tool);
2984            assert_eq!(session.events.tools[0].category, "shell");
2985            assert_eq!(session.events.llm_responses[0].model, model);
2986            let usage = &session.events.llm_responses[0];
2987            let total = usage
2988                .total_tokens
2989                .max(usage.input_tokens + usage.output_tokens + usage.cache_tokens);
2990            assert_eq!(total, tokens);
2991        }
2992    }
2993
2994    #[test]
2995    fn codex_token_count_uses_uncached_cumulative_totals() {
2996        let content = concat!(
2997            r#"{"type":"turn_context","payload":{"model":"gpt-5.6-sol","cwd":"/repo"}}"#,
2998            "\n",
2999            r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
3000            "\n",
3001            r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2109758505,"output_tokens":5136738,"cached_input_tokens":2069213696,"total_tokens":2114895243}}}}"#,
3002        );
3003
3004        let session = parse_session_content(
3005            AGENT_CODEX,
3006            &PathBuf::from("/tmp/session.jsonl"),
3007            UNIX_EPOCH,
3008            content,
3009        )
3010        .expect("session");
3011
3012        assert_eq!(session.usage.input_tokens, 40_544_809);
3013        assert_eq!(session.usage.output_tokens, 5_136_738);
3014        assert_eq!(session.usage.cache_read_tokens, 2_069_213_696);
3015        assert_eq!(session.usage.total_tokens, 45_681_547);
3016
3017        let response = &session.events.llm_responses[0];
3018        assert_eq!(response.input_tokens, 40_544_809);
3019        assert_eq!(response.output_tokens, 5_136_738);
3020        assert_eq!(response.cache_tokens, 2_069_213_696);
3021        assert_eq!(response.total_tokens, 45_681_547);
3022        assert_eq!(codex_uncached_input(99_000_000, 88_000_000), 11_000_000);
3023    }
3024
3025    #[test]
3026    fn codex_exec_prompt_handles_latest_cli_options() {
3027        let command = concat!(
3028            "/tmp/tools/bin/codex exec --skip-git-repo-check --ignore-user-config ",
3029            "-c model_provider=\"agentsight-mock\" ",
3030            "-c model_providers.agentsight-mock.name=\"AgentSight Mock\" ",
3031            "--sandbox read-only --model gpt-agentsight-mock ",
3032            "agentsight mock prompt collect this exact text"
3033        );
3034
3035        assert_eq!(
3036            codex_exec_prompt(command).as_deref(),
3037            Some("agentsight mock prompt collect this exact text")
3038        );
3039    }
3040
3041    #[test]
3042    fn common_shell_mutators_are_classified_as_writes() {
3043        for command in [
3044            "rm src/old.rs",
3045            "mv src/old.rs src/new.rs",
3046            "cp src/a.rs src/b.rs",
3047            "sed -i 's/a/b/' src/lib.rs",
3048            "sed --in-place=.bak 's/a/b/' src/lib.rs",
3049            "touch src/new.rs",
3050        ] {
3051            assert_eq!(command_effect(command), "write", "{command}");
3052        }
3053        assert_eq!(command_effect("sed -n '1,5p' src/lib.rs"), "read");
3054    }
3055
3056    #[test]
3057    fn shell_operands_preserve_read_write_roles_and_redirections() {
3058        let cp = tool_event_from_input(
3059            Some("/repo"),
3060            Some(42),
3061            0,
3062            "Bash",
3063            &json!({"command": "cp src/a.rs src/b.rs"}),
3064            None,
3065        );
3066        assert!(
3067            cp.path_refs
3068                .iter()
3069                .any(|reference| { reference.path == "src/a.rs" && reference.access == "read" })
3070        );
3071        assert!(
3072            cp.path_refs
3073                .iter()
3074                .any(|reference| { reference.path == "src/b.rs" && reference.access == "write" })
3075        );
3076
3077        let sed = tool_event_from_input(
3078            Some("/repo"),
3079            Some(42),
3080            0,
3081            "Bash",
3082            &json!({"command": "sed -i 's/a/b/' src/lib.rs"}),
3083            None,
3084        );
3085        assert_eq!(sed.path_refs.len(), 1);
3086        assert_eq!(sed.path_refs[0].path, "src/lib.rs");
3087        assert_eq!(sed.path_refs[0].access, "write");
3088
3089        let input_redirect = tool_event_from_input(
3090            Some("/repo"),
3091            Some(42),
3092            0,
3093            "Bash",
3094            &json!({"command": "cat < docs/input.md > docs/output.md"}),
3095            None,
3096        );
3097        assert!(
3098            input_redirect.path_refs.iter().any(|reference| {
3099                reference.path == "docs/input.md" && reference.access == "read"
3100            })
3101        );
3102        assert!(input_redirect.path_refs.iter().any(|reference| {
3103            reference.path == "docs/output.md" && reference.access == "write"
3104        }));
3105
3106        for command in [
3107            "cp -t generated src/a.rs src/b.rs",
3108            "cp --target-directory=generated src/a.rs src/b.rs",
3109        ] {
3110            let event = tool_event_from_input(
3111                Some("/repo"),
3112                Some(42),
3113                0,
3114                "Bash",
3115                &json!({"command": command}),
3116                None,
3117            );
3118            assert!(
3119                event.path_refs.iter().any(|reference| {
3120                    reference.path == "src/a.rs" && reference.access == "read"
3121                })
3122            );
3123            assert!(event.path_refs.iter().any(|reference| {
3124                reference.path == "generated/a.rs" && reference.access == "write"
3125            }));
3126            assert!(event.path_refs.iter().any(|reference| {
3127                reference.path == "generated/b.rs" && reference.access == "write"
3128            }));
3129            assert!(
3130                !event.path_refs.iter().any(|reference| {
3131                    reference.path == "generated" && reference.access == "write"
3132                })
3133            );
3134        }
3135
3136        let parents = tool_event_from_input(
3137            Some("/repo"),
3138            Some(42),
3139            0,
3140            "Bash",
3141            &json!({"command": "cp --parents -t generated src/a.rs"}),
3142            None,
3143        );
3144        assert!(parents.path_refs.iter().any(|reference| {
3145            reference.path == "generated/src/a.rs" && reference.access == "write"
3146        }));
3147
3148        let heredoc = tool_event_from_input(
3149            Some("/repo"),
3150            Some(42),
3151            0,
3152            "Bash",
3153            &json!({"command": "cat <<'EOF' > src/generated.rs\ncat private/fiction.rs\nEOF\ncat src/real.rs"}),
3154            None,
3155        );
3156        assert!(heredoc.path_refs.iter().any(|reference| {
3157            reference.path == "src/generated.rs" && reference.access == "write"
3158        }));
3159        assert!(
3160            heredoc
3161                .path_refs
3162                .iter()
3163                .any(|reference| { reference.path == "src/real.rs" && reference.access == "read" })
3164        );
3165        assert!(
3166            !heredoc
3167                .path_refs
3168                .iter()
3169                .any(|reference| reference.path == "private/fiction.rs")
3170        );
3171
3172        for command in [
3173            "cat src/template.rs > src/generated.rs",
3174            "echo generated >> docs/output.md",
3175            "echo generated>docs/output.md",
3176            "printf generated >docs/output.md",
3177            "cat <<'EOF' > src/heredoc.rs",
3178        ] {
3179            let event = tool_event_from_input(
3180                Some("/repo"),
3181                Some(42),
3182                0,
3183                "Bash",
3184                &json!({"command": command}),
3185                None,
3186            );
3187            assert_eq!(event.effect, "write", "{command}");
3188            assert!(
3189                event
3190                    .path_refs
3191                    .iter()
3192                    .any(|reference| reference.access == "write"),
3193                "{command}"
3194            );
3195        }
3196
3197        let command_fragment = tool_event_from_input(
3198            Some("/repo"),
3199            Some(42),
3200            0,
3201            "exec_command",
3202            &json!({
3203                "cmd": "bash -lc 'cargo run --manifest-path collector/Cargo.toml -- top'",
3204                "path": "cargo run --manifest-path collector/Cargo.toml -- top",
3205                "pattern": "*.rs"
3206            }),
3207            None,
3208        );
3209        assert!(command_fragment.path_refs.is_empty());
3210
3211        let compound = tool_event_from_input(
3212            Some("/repo"),
3213            Some(42),
3214            0,
3215            "Bash",
3216            &json!({"command": "cd /tmp && cp src/a.rs src/b.rs | tee logs/copied.txt"}),
3217            None,
3218        );
3219        assert!(compound.path_refs.is_empty());
3220
3221        let nested = tool_event_from_input(
3222            Some("/repo"),
3223            Some(42),
3224            0,
3225            "Bash",
3226            &json!({"command": "bash -lc 'cp src/a.rs src/b.rs'"}),
3227            None,
3228        );
3229        assert_eq!(
3230            nested
3231                .path_refs
3232                .iter()
3233                .map(|reference| (reference.path.as_str(), reference.access.as_str()))
3234                .collect::<Vec<_>>(),
3235            vec![("src/a.rs", "read"), ("src/b.rs", "write")]
3236        );
3237
3238        let search = tool_event_from_input(
3239            Some("/repo"),
3240            Some(42),
3241            0,
3242            "Bash",
3243            &json!({"command": "rg '[/agentsight/usage/,' collector/src/main.rs"}),
3244            None,
3245        );
3246        assert_eq!(search.path_refs.len(), 1);
3247        assert_eq!(search.path_refs[0].path, "collector/src/main.rs");
3248
3249        for command in [
3250            "git diff origin/master...HEAD -- collector/src/main.rs",
3251            "git show HEAD:collector/src/main.rs",
3252            "gh api repos/eunomia-bpf/agentsight/pulls/109/comments",
3253            "docker pull ghcr.io/eunomia-bpf/agentsight:latest",
3254            "printf '#!/bin/bash'",
3255            "for f in bpf/*; do echo $f; done",
3256            "echo 100644,$blob,collector/src/main.rs",
3257            "echo CLI/TUI/Web",
3258        ] {
3259            let event = tool_event_from_input(
3260                Some("/repo"),
3261                Some(42),
3262                0,
3263                "exec_command",
3264                &json!({"cmd": command}),
3265                None,
3266            );
3267            assert!(
3268                event.path_refs.is_empty(),
3269                "{command}: {:?}",
3270                event.path_refs
3271            );
3272        }
3273    }
3274
3275    #[test]
3276    fn subdirectory_paths_remain_resolvable_inside_the_repository() {
3277        let root = std::env::temp_dir().join(format!(
3278            "agent-session-subdir-path-test-{}",
3279            std::process::id()
3280        ));
3281        let subdir = root.join("nested");
3282        std::fs::create_dir_all(root.join(".git")).expect("create git marker");
3283        std::fs::create_dir_all(&subdir).expect("create cwd");
3284
3285        assert_eq!(
3286            repository_relative_path(&subdir, "../src/lib.rs").as_deref(),
3287            Some("../src/lib.rs")
3288        );
3289        assert_eq!(
3290            repository_relative_path(&subdir, root.join("src/lib.rs").to_str().unwrap()).as_deref(),
3291            Some("../src/lib.rs")
3292        );
3293        assert!(repository_relative_path(&subdir, "/outside/secret.txt").is_none());
3294
3295        let explicit_cd = tool_event_from_input(
3296            Some(root.to_str().unwrap()),
3297            Some(42),
3298            0,
3299            "exec_command",
3300            &json!({
3301                "cmd": "cd collector && cp ../bpf/stdiocap vendor/bpf/stdiocap"
3302            }),
3303            None,
3304        );
3305        assert!(
3306            explicit_cd.path_refs.iter().any(|reference| {
3307                reference.path == "bpf/stdiocap" && reference.access == "read"
3308            })
3309        );
3310        assert!(explicit_cd.path_refs.iter().any(|reference| {
3311            reference.path == "collector/vendor/bpf/stdiocap" && reference.access == "write"
3312        }));
3313
3314        let command_workdir = tool_event_from_input(
3315            Some(root.to_str().unwrap()),
3316            Some(42),
3317            0,
3318            "exec_command",
3319            &json!({
3320                "cmd": "cp ../bpf/stdiocap vendor/bpf/stdiocap",
3321                "workdir": subdir
3322            }),
3323            None,
3324        );
3325        assert!(
3326            command_workdir.path_refs.iter().any(|reference| {
3327                reference.path == "bpf/stdiocap" && reference.access == "read"
3328            })
3329        );
3330        assert!(command_workdir.path_refs.iter().any(|reference| {
3331            reference.path == "nested/vendor/bpf/stdiocap" && reference.access == "write"
3332        }));
3333
3334        for private_path in ["~/.cargo/registry/src", "$HOME/.codex/sessions"] {
3335            let event = tool_event_from_input(
3336                Some(root.to_str().unwrap()),
3337                Some(42),
3338                0,
3339                "exec_command",
3340                &json!({
3341                    "cmd": format!("cat {private_path}"),
3342                    "workdir": subdir
3343                }),
3344                None,
3345            );
3346            assert!(event.path_refs.is_empty(), "{private_path}");
3347        }
3348        std::fs::remove_dir_all(root).expect("remove path fixture");
3349    }
3350
3351    #[test]
3352    fn tool_events_preserve_private_body_free_path_and_edit_evidence() {
3353        let event = tool_event_from_input(
3354            Some("/repo"),
3355            Some(42),
3356            0,
3357            "Edit",
3358            &json!({
3359                "file_path": "/repo/src/lib.rs",
3360                "old_string": "fn old() {}\n",
3361                "new_string": "fn new() {}\n"
3362            }),
3363            Some("call-1".to_string()),
3364        );
3365
3366        assert_eq!(event.effect, "write");
3367        assert_eq!(event.path_refs.len(), 1);
3368        assert_eq!(event.path_refs[0].path, "src/lib.rs");
3369        assert_eq!(event.path_refs[0].access, "write");
3370        let summary = event.edit_summary.expect("edit summary");
3371        assert_eq!(summary.payload_kind, "replace");
3372        assert_eq!(summary.removed_lines, 1);
3373        assert_eq!(summary.added_lines, 1);
3374        assert_ne!(summary.before_hash, summary.after_hash);
3375
3376        let encoded = serde_json::to_string(&summary).expect("serialize summary");
3377        assert!(!encoded.contains("fn old"));
3378        assert!(!encoded.contains("fn new"));
3379    }
3380
3381    #[test]
3382    fn patch_paths_keep_repository_scope_and_drop_external_paths() {
3383        let patch = concat!(
3384            "*** Begin Patch\n",
3385            "*** Update File: src/main.rs\n",
3386            "@@\n-old\n+new\n",
3387            "*** End Patch"
3388        );
3389        let event = tool_event_from_input(
3390            Some("/repo"),
3391            Some(42),
3392            0,
3393            "apply_patch",
3394            &json!({"input": patch, "path": "/outside/secret.txt"}),
3395            None,
3396        );
3397
3398        assert_eq!(event.path_refs.len(), 1);
3399        assert_eq!(event.path_refs[0].path, "src/main.rs");
3400        assert_eq!(event.edit_summary.expect("patch summary").added_lines, 1);
3401    }
3402
3403    #[test]
3404    fn repository_paths_reject_home_and_environment_relative_tokens() {
3405        let event = tool_event_from_input(
3406            Some("/repo"),
3407            Some(42),
3408            0,
3409            "Bash",
3410            &json!({
3411                "command": "rg needle ~/.claude/projects $HOME/.codex ~/workspace/other .git/HEAD src/lib.rs"
3412            }),
3413            None,
3414        );
3415
3416        assert_eq!(event.path_refs.len(), 1);
3417        assert_eq!(event.path_refs[0].path, "src/lib.rs");
3418        assert!(event.path_refs.iter().all(|reference| {
3419            !reference.path.starts_with('~') && !reference.path.starts_with('$')
3420        }));
3421    }
3422
3423    #[test]
3424    fn multi_file_and_multi_hunk_patches_do_not_claim_one_exact_edit() {
3425        let multi_file = concat!(
3426            "*** Begin Patch\n",
3427            "*** Update File: src/a.rs\n",
3428            "@@\n-old-a\n+new-a\n",
3429            "*** Update File: src/b.rs\n",
3430            "@@\n-old-b\n+new-b\n",
3431            "*** End Patch"
3432        );
3433        let event = tool_event_from_input(
3434            Some("/repo"),
3435            Some(42),
3436            0,
3437            "apply_patch",
3438            &json!({"input": multi_file}),
3439            None,
3440        );
3441        assert_eq!(event.path_refs.len(), 2);
3442        assert!(event.edit_summary.is_none());
3443
3444        let multi_hunk = concat!(
3445            "*** Begin Patch\n",
3446            "*** Update File: src/a.rs\n",
3447            "@@\n-old-a\n+new-a\n",
3448            "@@\n-old-b\n+new-b\n",
3449            "*** End Patch"
3450        );
3451        let event = tool_event_from_input(
3452            Some("/repo"),
3453            Some(42),
3454            0,
3455            "apply_patch",
3456            &json!({"input": multi_hunk}),
3457            None,
3458        );
3459        assert_eq!(event.path_refs.len(), 1);
3460        assert!(event.edit_summary.is_none());
3461    }
3462
3463    #[test]
3464    fn gemini_project_hash_and_relative_paths_survive_normalization() {
3465        let content = r#"{
3466            "sessionId":"gemini-session",
3467            "projectHash":"abc123",
3468            "startTime":"2026-07-14T10:00:00Z",
3469            "lastUpdated":"2026-07-14T10:01:00Z",
3470            "messages":[{
3471                "type":"gemini",
3472                "timestamp":"2026-07-14T10:00:30Z",
3473                "model":"gemini-test",
3474                "content":"done",
3475                "toolCalls":[{
3476                    "id":"tool-1",
3477                    "name":"read_file",
3478                    "args":{"file_path":"src/lib.rs"},
3479                    "status":"success"
3480                }]
3481            }]
3482        }"#;
3483        let session = parse_session_content(
3484            AGENT_GEMINI,
3485            Path::new("/tmp/session-gemini.json"),
3486            UNIX_EPOCH,
3487            content,
3488        )
3489        .expect("gemini session");
3490
3491        assert_eq!(session.project_hash.as_deref(), Some("abc123"));
3492        assert_eq!(session.events.tools[0].effect, "read");
3493        assert_eq!(session.events.tools[0].status, "success");
3494        assert_eq!(session.events.tools[0].path_refs[0].path, "src/lib.rs");
3495    }
3496
3497    #[test]
3498    fn codex_usage_updates_coalesce_within_a_prompt() {
3499        let content = concat!(
3500            r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
3501            "\n",
3502            r#"{"timestamp":"2026-07-14T10:00:00Z","type":"event_msg","payload":{"type":"user_message","message":"work"}}"#,
3503            "\n",
3504            r#"{"timestamp":"2026-07-14T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":2,"total_tokens":12}}}}"#,
3505            "\n",
3506            r#"{"timestamp":"2026-07-14T10:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":20,"output_tokens":4,"total_tokens":24}}}}"#,
3507        );
3508        let session = parse_session_content(
3509            AGENT_CODEX,
3510            Path::new("/tmp/session-codex.jsonl"),
3511            UNIX_EPOCH,
3512            content,
3513        )
3514        .expect("codex session");
3515
3516        assert_eq!(session.events.llm_responses.len(), 1);
3517        assert_eq!(session.events.llm_responses[0].total_tokens, 24);
3518        assert_eq!(session.events.llm_responses[0].tag, "usage");
3519    }
3520
3521    #[test]
3522    fn codex_patch_apply_events_expose_write_paths_without_bodies() {
3523        let content = concat!(
3524            r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
3525            "\n",
3526            r#"{"timestamp":"2026-07-14T10:00:00Z","type":"event_msg","payload":{"type":"user_message","message":"edit"}}"#,
3527            "\n",
3528            r#"{"timestamp":"2026-07-14T10:00:01Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"patch-1","success":true,"changes":{"/repo/src/lib.rs":{"type":"update","unified_diff":"@@ -1 +1 @@\n-old\n+new"}}}}"#,
3529        );
3530        let session = parse_session_content(
3531            AGENT_CODEX,
3532            Path::new("/tmp/session-codex.jsonl"),
3533            UNIX_EPOCH,
3534            content,
3535        )
3536        .expect("codex session");
3537
3538        let event = &session.events.tools[0];
3539        assert_eq!(event.effect, "write");
3540        assert_eq!(event.status, "success");
3541        assert_eq!(event.path_refs[0].path, "src/lib.rs");
3542        assert_eq!(event.edit_summary.as_ref().unwrap().payload_kind, "patch");
3543    }
3544
3545    #[test]
3546    fn codex_patch_completion_updates_the_existing_tool_call() {
3547        let content = concat!(
3548            r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
3549            "\n",
3550            r#"{"timestamp":"2026-07-14T10:00:00Z","type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"patch-1","arguments":"{\"input\":\"*** Begin Patch\\n*** Update File: src/lib.rs\\n@@\\n-old\\n+new\\n*** End Patch\"}"}}"#,
3551            "\n",
3552            r#"{"timestamp":"2026-07-14T10:00:01Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"patch-1","success":true,"changes":{"/repo/src/lib.rs":{"type":"update","unified_diff":"@@ -1 +1 @@\n-old\n+new"}}}}"#,
3553        );
3554        let session = parse_session_content(
3555            AGENT_CODEX,
3556            Path::new("/tmp/session-codex.jsonl"),
3557            UNIX_EPOCH,
3558            content,
3559        )
3560        .expect("codex session");
3561
3562        assert_eq!(session.events.tools.len(), 1);
3563        assert_eq!(session.events.tools[0].call_id.as_deref(), Some("patch-1"));
3564        assert_eq!(session.events.tools[0].status, "success");
3565        assert_eq!(session.events.tools[0].path_refs[0].path, "src/lib.rs");
3566    }
3567}