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, LlmResponse, SessionCandidate, SessionDirStat, SessionEvents, TokenUsage,
15    ToolEvent, ToolPath, UserPrompt,
16};
17use crate::{AGENT_CLAUDE, AGENT_CODEX, AGENT_CURSOR, 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        (AGENT_CURSOR, home.join(".cursor/projects")),
34    ];
35    let mut out = Vec::new();
36    for (agent, dir) in roots {
37        walk_agent_files(agent, &dir, &mut |path, meta| {
38            out.push(SessionCandidate {
39                agent,
40                path: path.to_path_buf(),
41                updated: candidate_updated(agent, path, meta),
42            });
43        });
44    }
45    dedupe_cursor_candidates(&mut out);
46    out
47}
48
49pub fn discover_session_files_in_dir(agent: &'static str, dir: &Path) -> Vec<SessionCandidate> {
50    let mut out = Vec::new();
51    walk_agent_files(agent, dir, &mut |path, meta| {
52        out.push(SessionCandidate {
53            agent,
54            path: path.to_path_buf(),
55            updated: candidate_updated(agent, path, meta),
56        });
57    });
58    dedupe_cursor_candidates(&mut out);
59    out
60}
61
62fn candidate_updated(agent: &str, path: &Path, meta: &fs::Metadata) -> SystemTime {
63    let updated = meta.modified().unwrap_or(UNIX_EPOCH);
64    if agent == AGENT_CURSOR {
65        cursor_candidate_updated(path, updated)
66    } else {
67        updated
68    }
69}
70
71fn cursor_candidate_updated(path: &Path, parent_updated: SystemTime) -> SystemTime {
72    let mut updated = parent_updated;
73    let Some(subagents) = path.parent().map(|dir| dir.join("subagents")) else {
74        return updated;
75    };
76    let Ok(entries) = fs::read_dir(subagents) else {
77        return updated;
78    };
79    for entry in entries.flatten() {
80        if entry.path().extension().and_then(|ext| ext.to_str()) == Some("jsonl")
81            && let Ok(meta) = entry.metadata()
82        {
83            updated = updated.max(meta.modified().unwrap_or(UNIX_EPOCH));
84        }
85    }
86    updated
87}
88
89fn dedupe_cursor_candidates(out: &mut Vec<SessionCandidate>) {
90    let mut best: BTreeMap<String, (bool, SystemTime, usize)> = BTreeMap::new();
91    let mut drop = vec![false; out.len()];
92    for (idx, candidate) in out.iter().enumerate() {
93        if candidate.agent != AGENT_CURSOR {
94            continue;
95        }
96        let Some(stem) = candidate.path.file_stem().and_then(|stem| stem.to_str()) else {
97            continue;
98        };
99        let rank = (
100            !cursor_is_empty_window(&candidate.path),
101            candidate.updated,
102            idx,
103        );
104        match best.get_mut(stem) {
105            None => {
106                best.insert(stem.to_string(), rank);
107            }
108            Some(entry) => {
109                if (rank.0, rank.1) > (entry.0, entry.1) {
110                    drop[entry.2] = true;
111                    *entry = rank;
112                } else {
113                    drop[idx] = true;
114                }
115            }
116        }
117    }
118    let mut drop = drop.into_iter();
119    out.retain(|_| !drop.next().unwrap_or_default());
120}
121
122fn cursor_is_empty_window(path: &Path) -> bool {
123    let mut previous = None;
124    for component in path.components() {
125        let name = component.as_os_str();
126        if name == "agent-transcripts" {
127            return previous.is_some_and(|project| project == "empty-window");
128        }
129        previous = Some(name);
130    }
131    false
132}
133
134/// Count sessions and bytes per agent directory.
135pub fn count_session_dirs() -> Vec<SessionDirStat> {
136    user_home_dir()
137        .as_deref()
138        .map(count_session_dirs_in_home)
139        .unwrap_or_default()
140}
141
142/// Count sessions and bytes per agent directory under a specific home directory.
143pub fn count_session_dirs_in_home(home: &Path) -> Vec<SessionDirStat> {
144    [
145        (AGENT_CLAUDE, home.join(".claude/projects")),
146        (AGENT_CODEX, home.join(".codex/sessions")),
147        (AGENT_GEMINI, home.join(".gemini/tmp")),
148        (AGENT_CURSOR, home.join(".cursor/projects")),
149    ]
150    .into_iter()
151    .filter_map(|(agent, dir)| {
152        let (mut sessions, mut bytes) = (0usize, 0u64);
153        walk_agent_files(agent, &dir, &mut |_, meta| {
154            sessions += 1;
155            bytes += meta.len();
156        });
157        (sessions > 0).then_some(SessionDirStat {
158            agent,
159            dir,
160            sessions,
161            bytes,
162        })
163    })
164    .collect()
165}
166
167pub fn session_candidate_from_path(path: &Path) -> Option<SessionCandidate> {
168    let agent = agent_source_for_path(path).or_else(|| loose_agent_source_for_path(path))?;
169    let updated = fs::metadata(path)
170        .and_then(|metadata| metadata.modified())
171        .unwrap_or(UNIX_EPOCH);
172    Some(SessionCandidate {
173        agent,
174        path: path.to_path_buf(),
175        updated,
176    })
177}
178
179/// Parse a session file from a candidate.
180pub fn parse_session_file(candidate: &SessionCandidate) -> Option<AgentSession> {
181    let content = fs::read_to_string(&candidate.path).ok()?;
182    parse_session_content(
183        candidate.agent,
184        &candidate.path,
185        candidate.updated,
186        &content,
187    )
188}
189
190/// Parse a session file by path, detecting the agent type automatically.
191pub fn parse_session_path(path: &Path) -> Option<AgentSession> {
192    parse_session_file(&session_candidate_from_path(path)?)
193}
194
195/// Parse session content given raw content string.
196pub fn parse_session_content(
197    agent: &str,
198    path: &Path,
199    updated: SystemTime,
200    content: &str,
201) -> Option<AgentSession> {
202    parse_session_impl(agent, path, updated, content)
203}
204
205fn parse_session_impl(
206    agent: &str,
207    path: &Path,
208    updated: SystemTime,
209    content: &str,
210) -> Option<AgentSession> {
211    if agent == AGENT_GEMINI {
212        parse_gemini_json(path, updated, content)
213    } else if agent == AGENT_CURSOR {
214        // Read siblings here so the parser stays a pure function of its inputs.
215        let children = read_cursor_subagents(path);
216        parse_cursor_jsonl(path, updated, content, &children)
217    } else {
218        parse_jsonl(agent, path, updated, content)
219    }
220}
221
222/// Extract a session log path from a string (e.g., from /proc/fd).
223pub fn session_log_path_from_str(raw: &str) -> Option<PathBuf> {
224    let trimmed = raw.trim().trim_end_matches(" (deleted)");
225    if trimmed.is_empty() {
226        return None;
227    }
228    let path = Path::new(trimmed);
229    if !is_absolute_path_text(trimmed) || !is_agent_session_file(path) {
230        return None;
231    }
232    agent_source_for_path(path).map(|_| normalize_session_log_path(path))
233}
234
235/// Canonicalize a session log path.
236pub fn normalize_session_log_path(path: &Path) -> PathBuf {
237    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
238}
239
240/// Detect which agent a session file belongs to based on its path.
241pub fn agent_source_for_path(path: &Path) -> Option<&'static str> {
242    let value = normalize_path_text(&path.to_string_lossy());
243    if value.contains("/.claude/") && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
244    {
245        Some(AGENT_CLAUDE)
246    } else if value.contains("/.codex/")
247        && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
248    {
249        Some(AGENT_CODEX)
250    } else if value.contains("/.gemini/")
251        && path.extension().and_then(|ext| ext.to_str()) == Some("json")
252    {
253        Some(AGENT_GEMINI)
254    } else if value.contains("/.cursor/") && is_cursor_transcript(path) {
255        Some(AGENT_CURSOR)
256    } else {
257        None
258    }
259}
260
261fn is_cursor_transcript(path: &Path) -> bool {
262    path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
263        && normalize_path_text(&path.to_string_lossy()).contains("/agent-transcripts/")
264}
265
266fn is_cursor_parent_transcript(path: &Path) -> bool {
267    is_cursor_transcript(path)
268        && path.file_stem().is_some_and(|stem| {
269            path.parent()
270                .and_then(|dir| dir.file_name())
271                .is_some_and(|dir| dir == stem)
272        })
273}
274
275fn loose_agent_source_for_path(path: &Path) -> Option<&'static str> {
276    let value = normalize_path_text(&path.to_string_lossy());
277    if value.contains("/codex/") && value.contains("sessions") {
278        Some(AGENT_CODEX)
279    } else if value.contains("/claude/") && value.contains("projects") {
280        Some(AGENT_CLAUDE)
281    } else if value.contains("/cursor/") && value.contains("agent-transcripts") {
282        Some(AGENT_CURSOR)
283    } else {
284        None
285    }
286}
287
288/// Generate a fixture session path for testing.
289pub fn fixture_session_path(agent: &str, home: &Path) -> Option<PathBuf> {
290    match agent {
291        AGENT_CLAUDE => Some(home.join(".claude/projects/test/session.jsonl")),
292        AGENT_CODEX => Some(home.join(".codex/sessions/2026/06/02/session.jsonl")),
293        AGENT_GEMINI => Some(home.join(".gemini/tmp/test/chats/session-test.json")),
294        AGENT_CURSOR => {
295            Some(home.join(".cursor/projects/test/agent-transcripts/session/session.jsonl"))
296        }
297        _ => None,
298    }
299}
300
301/// Check if a target path is the Codex CLI entrypoint.
302pub fn is_codex_cli_entrypoint(target: Option<&str>) -> bool {
303    target.is_some_and(|target| {
304        Path::new(target).file_name().and_then(|name| name.to_str()) == Some("codex")
305            && !target.contains("/node_modules/")
306    })
307}
308
309/// Extract the prompt from a Codex exec command.
310pub fn codex_exec_prompt(command: &str) -> Option<String> {
311    let args = shell_words(command.split_once(" exec ")?.1.trim())?;
312    let mut index = 0usize;
313    while index < args.len() {
314        let arg = args[index].as_str();
315        if arg == "--" {
316            index += 1;
317            break;
318        }
319        if !arg.starts_with('-') {
320            break;
321        }
322        let consumed = codex_exec_option_arity(arg)?;
323        index += consumed;
324    }
325    (index < args.len())
326        .then(|| args[index..].join(" "))
327        .and_then(|prompt| clean_prompt_text(&prompt))
328}
329
330fn codex_exec_option_arity(arg: &str) -> Option<usize> {
331    if arg.contains('=') && arg.starts_with("--") {
332        return Some(1);
333    }
334
335    match arg {
336        "--json"
337        | "--skip-git-repo-check"
338        | "--ephemeral"
339        | "--ignore-user-config"
340        | "--full-auto"
341        | "--dangerously-bypass-approvals-and-sandbox" => Some(1),
342        "-C" | "-a" | "-s" | "-m" | "-c" | "-p" | "--cd" | "--model" | "--sandbox"
343        | "--profile" | "--config" | "--ask-for-approval" | "--approval-policy"
344        | "--output-format" | "--color" => Some(2),
345        _ => None,
346    }
347}
348
349fn shell_words(input: &str) -> Option<Vec<String>> {
350    let mut words = Vec::new();
351    let mut current = String::new();
352    let mut quote = None::<char>;
353    let mut chars = input.chars().peekable();
354
355    while let Some(ch) = chars.next() {
356        match (quote, ch) {
357            (None, c) if c.is_whitespace() => {
358                if !current.is_empty() {
359                    words.push(std::mem::take(&mut current));
360                }
361            }
362            (None, '\'' | '"') => quote = Some(ch),
363            (Some(q), c) if c == q => quote = None,
364            (_, '\\') => {
365                if let Some(next) = chars.next() {
366                    current.push(next);
367                }
368            }
369            _ => current.push(ch),
370        }
371    }
372    if quote.is_some() {
373        return None;
374    }
375    if !current.is_empty() {
376        words.push(current);
377    }
378    Some(words)
379}
380
381// ---------------------------------------------------------------------------
382// Internal parsing implementation
383// ---------------------------------------------------------------------------
384
385#[derive(Default)]
386struct SemanticTaskStack {
387    root: Option<String>,
388    active_plan: Option<String>,
389}
390
391impl SemanticTaskStack {
392    fn observe_user(&mut self, text: &str) {
393        let label = semantic_task_label(text);
394        if self.root.is_some() && is_continuation_prompt(&label) {
395            return;
396        }
397        self.root = Some(label);
398        self.active_plan = None;
399    }
400
401    fn observe_plan(&mut self, input: &Value) {
402        let active = input
403            .get("plan")
404            .and_then(Value::as_array)
405            .into_iter()
406            .flatten()
407            .filter_map(|item| {
408                (item.get("status").and_then(Value::as_str) == Some("in_progress"))
409                    .then(|| item.get("step").and_then(Value::as_str))
410                    .flatten()
411                    .map(semantic_task_label)
412            })
413            .collect::<Vec<_>>();
414        self.active_plan = match active.as_slice() {
415            [] => None,
416            [only] => Some(only.clone()),
417            many => self
418                .active_plan
419                .as_ref()
420                .filter(|current| many.contains(current))
421                .cloned()
422                .or_else(|| many.first().cloned()),
423        };
424    }
425
426    fn path(&self) -> Vec<String> {
427        self.root
428            .iter()
429            .chain(self.active_plan.iter())
430            .cloned()
431            .collect()
432    }
433
434    fn path_for_tool(&self, name: &str, input: &Value) -> Vec<String> {
435        let mut path = self.path();
436        if name == "spawn_agent"
437            && let Some(label) = input
438                .get("task_name")
439                .or_else(|| input.get("message"))
440                .and_then(Value::as_str)
441        {
442            path.push(semantic_task_label(label));
443        }
444        path
445    }
446}
447
448pub fn semantic_task_label(text: &str) -> String {
449    let mut selected = text.trim();
450    if let Some(start) = selected.rfind("## My request for Codex:") {
451        selected = &selected[start + "## My request for Codex:".len()..];
452    } else if let Some(start) = selected.find("<objective>")
453        && let Some(end) = selected[start + "<objective>".len()..].find("</objective>")
454    {
455        selected = &selected[start + "<objective>".len()..start + "<objective>".len() + end];
456    }
457    let label = truncate_clean(selected.trim_matches(['\'', '"']), 120);
458    if label.is_empty() {
459        "unnamed task".to_string()
460    } else {
461        label
462    }
463}
464
465fn is_continuation_prompt(text: &str) -> bool {
466    let lowered = text.trim().to_lowercase();
467    matches!(
468        lowered.as_str(),
469        "继续"
470            | "继续做"
471            | "去做"
472            | "开始"
473            | "嗯"
474            | "好"
475            | "好的"
476            | "continue"
477            | "go on"
478            | "proceed"
479            | "do it"
480            | "ok"
481            | "okay"
482    )
483}
484
485fn parse_jsonl(
486    agent: &str,
487    path: &Path,
488    updated: SystemTime,
489    content: &str,
490) -> Option<AgentSession> {
491    let mut acc = SessionAccumulator::new(agent, path, updated);
492    let mut codex_model = String::new();
493    let mut claude_message_models = BTreeMap::<String, TokenUsage>::new();
494    let mut claude_seen_usage = HashSet::new();
495    let mut events = SessionEvents::default();
496    let mut current_prompt_index = 0usize;
497    let mut call_index = BTreeMap::<String, usize>::new();
498    let mut task_stack = SemanticTaskStack::default();
499    let mut active_skill: Option<String> = None;
500    let mut claude_prompt_id: Option<String> = None;
501    let mut codex_meta_seen = false;
502    let mut codex_owns_events = true;
503    let mut codex_session_started_at = 0.0_f64;
504
505    for line in content.lines() {
506        let Ok(obj) = serde_json::from_str::<Value>(line) else {
507            continue;
508        };
509        let typ = obj.get("type").and_then(Value::as_str).unwrap_or("");
510        if agent == AGENT_CODEX && typ == "session_meta" {
511            if !codex_meta_seen {
512                codex_meta_seen = true;
513                let payload = obj.get("payload").unwrap_or(&Value::Null);
514                if let Some(id) = payload
515                    .get("id")
516                    .or_else(|| payload.get("session_id"))
517                    .and_then(Value::as_str)
518                {
519                    acc.session_id = id.to_string();
520                }
521                acc.conversation_id = payload
522                    .get("session_id")
523                    .and_then(Value::as_str)
524                    .map(str::to_string);
525                let parent = payload
526                    .get("parent_thread_id")
527                    .or_else(|| payload.get("forked_from_id"))
528                    .and_then(Value::as_str)
529                    .or_else(|| {
530                        payload
531                            .pointer("/source/subagent/thread_spawn/parent_thread_id")
532                            .and_then(Value::as_str)
533                    });
534                codex_owns_events = parent.is_none_or(str::is_empty);
535                codex_session_started_at = payload
536                    .get("timestamp")
537                    .or_else(|| obj.get("timestamp"))
538                    .and_then(Value::as_str)
539                    .and_then(rfc3339_seconds)
540                    .unwrap_or_default();
541                if acc.cwd.is_none() {
542                    acc.cwd = payload
543                        .get("cwd")
544                        .and_then(Value::as_str)
545                        .filter(|cwd| !cwd.is_empty())
546                        .map(str::to_string);
547                }
548            }
549            continue;
550        }
551        if agent == AGENT_CODEX && !codex_owns_events {
552            let payload = obj.get("payload").unwrap_or(&Value::Null);
553            if typ == "event_msg"
554                && payload.get("type").and_then(Value::as_str) == Some("task_started")
555            {
556                let source_start = payload
557                    .get("started_at")
558                    .and_then(Value::as_f64)
559                    .filter(|value| *value > 0.0)
560                    .or_else(|| {
561                        payload
562                            .get("turn_id")
563                            .and_then(Value::as_str)
564                            .and_then(uuid7_seconds)
565                    })
566                    .unwrap_or_default();
567                if source_start > 0.0
568                    && (codex_session_started_at == 0.0
569                        || source_start >= codex_session_started_at.floor())
570                {
571                    codex_owns_events = true;
572                }
573            }
574            continue;
575        }
576        let (session_id, conversation_id) = local_session_ids(&obj);
577        if let Some(id) = session_id {
578            acc.session_id = id;
579        }
580        if let Some(id) = conversation_id {
581            acc.conversation_id = Some(id);
582        }
583        if acc.cwd.is_none() {
584            acc.cwd = obj
585                .get("cwd")
586                .and_then(Value::as_str)
587                .or_else(|| obj.pointer("/payload/cwd").and_then(Value::as_str))
588                .filter(|s| !s.is_empty())
589                .map(ToString::to_string);
590        }
591        if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
592            acc.last_message_at = Some(ts.to_string());
593            acc.end_timestamp_ms = iso_ms(ts).or(acc.end_timestamp_ms);
594        }
595        match (agent, typ) {
596            (AGENT_CLAUDE, "result") => {
597                acc.duration_ms = json_u64(&obj, "duration_ms");
598                if let Some(model_usage) = obj.get("modelUsage").and_then(Value::as_object) {
599                    for (name, usage) in model_usage {
600                        acc.model.get_or_insert_with(|| name.clone());
601                        acc.add_usage(
602                            name,
603                            json_i64(usage, "inputTokens"),
604                            json_i64(usage, "outputTokens"),
605                            json_i64(usage, "cacheCreationInputTokens"),
606                            json_i64(usage, "cacheReadInputTokens"),
607                            0,
608                        );
609                    }
610                }
611            }
612            (AGENT_CLAUDE, "assistant") => {
613                let response_skill = active_skill.clone().unwrap_or_default();
614                if let Some(name) = obj.pointer("/message/model").and_then(Value::as_str) {
615                    acc.model.get_or_insert_with(|| name.to_string());
616                }
617                let model = obj
618                    .pointer("/message/model")
619                    .and_then(Value::as_str)
620                    .or(acc.model.as_deref())
621                    .unwrap_or(AGENT_CLAUDE)
622                    .to_string();
623                if let Some(usage) = obj.pointer("/message/usage")
624                    && claude_seen_usage.insert(claude_usage_key(&obj))
625                {
626                    let name = obj
627                        .pointer("/message/model")
628                        .and_then(Value::as_str)
629                        .unwrap_or("unknown");
630                    add_usage(
631                        &mut claude_message_models,
632                        name,
633                        json_i64(usage, "input_tokens"),
634                        json_i64(usage, "output_tokens"),
635                        json_i64(usage, "cache_creation_input_tokens"),
636                        json_i64(usage, "cache_read_input_tokens"),
637                        0,
638                    );
639                }
640                let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
641                if let Some(items) = content.as_array() {
642                    for item in items
643                        .iter()
644                        .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_use"))
645                    {
646                        let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
647                        let input = item.get("input").unwrap_or(&Value::Null);
648                        let invoked_skill = exact_claude_skill_invocation(name, input);
649                        if let Some(skill) = invoked_skill.as_ref() {
650                            active_skill = Some(skill.clone());
651                        }
652                        acc.add_tool(name);
653                        if let Some(fp) = item
654                            .pointer("/input/file_path")
655                            .and_then(Value::as_str)
656                            .filter(|s| !is_noise_path(s))
657                        {
658                            acc.add_file(fp);
659                        }
660                        let call_id = item.get("id").and_then(Value::as_str).map(str::to_string);
661                        let event = tool_event_from_input(
662                            acc.cwd.as_deref(),
663                            ts_ms_from_event(&obj),
664                            current_prompt_index,
665                            name,
666                            input,
667                            call_id.clone(),
668                            task_stack.path_for_tool(name, input),
669                        );
670                        let mut event = event;
671                        event.invoked_skill = invoked_skill.unwrap_or_default();
672                        event.skill = active_skill.clone().unwrap_or_default();
673                        if let Some(id) = call_id {
674                            call_index.insert(id, events.tools.len());
675                        }
676                        events.tools.push(event);
677                    }
678                }
679                let text = content_to_text(content);
680                let usage = obj.pointer("/message/usage").unwrap_or(&Value::Null);
681                if !text.trim().is_empty() || usage.is_object() {
682                    // Build preview: prefer text content, fall back to tool names
683                    let preview_text = if !text.trim().is_empty() {
684                        text.clone()
685                    } else if let Some(items) = content.as_array() {
686                        let tool_names: Vec<_> = items
687                            .iter()
688                            .filter_map(|item| {
689                                if item.get("type").and_then(Value::as_str) == Some("tool_use") {
690                                    item.get("name").and_then(Value::as_str)
691                                } else {
692                                    None
693                                }
694                            })
695                            .collect();
696                        if tool_names.is_empty() {
697                            String::new()
698                        } else {
699                            format!("tool: {}", tool_names.join(", "))
700                        }
701                    } else {
702                        String::new()
703                    };
704                    events.llm_responses.push(LlmResponse {
705                        ts_ms: ts_ms_from_event(&obj),
706                        prompt_index: current_prompt_index,
707                        model,
708                        source_id: claude_source_completion_id(&obj),
709                        text_hash: short_hash(&(text.clone() + &usage.to_string()), 12),
710                        preview: truncate_clean(
711                            if preview_text.is_empty() {
712                                "token report"
713                            } else {
714                                &preview_text
715                            },
716                            140,
717                        ),
718                        input_tokens: json_u64(usage, "input_tokens"),
719                        output_tokens: json_u64(usage, "output_tokens"),
720                        cache_tokens: json_u64(usage, "cache_creation_input_tokens")
721                            + json_u64(usage, "cache_read_input_tokens"),
722                        total_tokens: 0,
723                        tag: String::new(),
724                        response_phase: if obj
725                            .pointer("/message/stop_reason")
726                            .and_then(Value::as_str)
727                            == Some("end_turn")
728                            && !text.trim().is_empty()
729                        {
730                            "final_answer".to_string()
731                        } else {
732                            "assistant_message".to_string()
733                        },
734                        skill: response_skill,
735                        task_path: task_stack.path(),
736                    });
737                }
738            }
739            (AGENT_CLAUDE, "queue-operation") if acc.prompt_preview.is_none() => {
740                if obj.get("operation").and_then(Value::as_str) == Some("enqueue")
741                    && let Some(text) = obj.get("content").and_then(Value::as_str)
742                    && let Some(text) = clean_prompt_text(text)
743                {
744                    acc.prompt_preview = Some(text.clone());
745                    task_stack.observe_user(&text);
746                    current_prompt_index =
747                        events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
748                }
749            }
750            (AGENT_CLAUDE, "last-prompt") if acc.prompt_preview.is_none() => {
751                if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
752                    && let Some(text) = clean_prompt_text(text)
753                {
754                    acc.prompt_preview = Some(text.clone());
755                    task_stack.observe_user(&text);
756                    current_prompt_index =
757                        events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
758                }
759            }
760            (AGENT_CLAUDE, "user") => {
761                let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
762                if claude_is_tool_result(content) || is_claude_tool_result(&obj) {
763                    let fallback = obj
764                        .pointer("/toolUseResult/is_error")
765                        .and_then(Value::as_bool)
766                        .unwrap_or(false);
767                    for result in content.as_array().into_iter().flatten() {
768                        let Some(id) = result.get("tool_use_id").and_then(Value::as_str) else {
769                            continue;
770                        };
771                        if let Some(index) = call_index.get(id).copied()
772                            && let Some(tool) = events.tools.get_mut(index)
773                        {
774                            let failed = result
775                                .get("is_error")
776                                .and_then(Value::as_bool)
777                                .unwrap_or(fallback);
778                            tool.status = if failed { "fail" } else { "ok" }.to_string();
779                        }
780                    }
781                } else if let Some(text) = local_message_preview(content)
782                    && claude_user_starts_prompt(&obj, content, &text, claude_prompt_id.as_deref())
783                {
784                    if acc.prompt_preview.is_none() {
785                        acc.prompt_preview = Some(text.clone());
786                    }
787                    task_stack.observe_user(&text);
788                    active_skill = None;
789                    claude_prompt_id = obj
790                        .get("promptId")
791                        .and_then(Value::as_str)
792                        .filter(|value| !value.is_empty())
793                        .map(str::to_string);
794                    current_prompt_index =
795                        events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
796                }
797            }
798            (AGENT_CODEX, "turn_context") => {
799                if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
800                    codex_model = name.to_string();
801                    acc.model = Some(name.to_string());
802                }
803            }
804            (AGENT_CODEX, "event_msg") => {
805                let payload = obj.get("payload").unwrap_or(&Value::Null);
806                let ptype = payload.get("type").and_then(Value::as_str).unwrap_or("");
807                if ptype == "token_count"
808                    && let Some(usage) = payload.pointer("/info/total_token_usage")
809                {
810                    let name = if codex_model.is_empty() {
811                        "unknown"
812                    } else {
813                        &codex_model
814                    };
815                    let usage = codex_token_usage(usage);
816                    acc.set_usage(
817                        name,
818                        usage.input_tokens,
819                        usage.output_tokens,
820                        0,
821                        usage.cache_read_tokens,
822                        usage.total_tokens,
823                    );
824                }
825                if matches!(ptype, "token_count" | "token_usage") {
826                    let info = payload
827                        .get("info")
828                        .or_else(|| payload.get("usage"))
829                        .unwrap_or(payload);
830                    let token_usage = info
831                        .get("last_token_usage")
832                        .or_else(|| info.get("total_token_usage"))
833                        .unwrap_or(info);
834                    let input_tokens = json_u64(token_usage, "input_tokens");
835                    let output_tokens = json_u64(token_usage, "output_tokens");
836                    let cache_tokens = json_u64(token_usage, "cached_input_tokens");
837                    let total_tokens = json_u64(token_usage, "total_tokens")
838                        .max(json_u64(info, "total_tokens"))
839                        .max(json_u64(info, "tokens"));
840                    if total_tokens > 0
841                        && let Some(last) = events.llm_responses.last_mut()
842                        && last.total_tokens == 0
843                    {
844                        last.input_tokens = input_tokens;
845                        last.output_tokens = output_tokens;
846                        last.cache_tokens = cache_tokens;
847                        last.total_tokens = total_tokens;
848                    }
849                }
850                if ptype == "user_message" {
851                    let text = payload
852                        .get("message")
853                        .or_else(|| payload.get("content"))
854                        .and_then(Value::as_str)
855                        .unwrap_or("");
856                    if let Some(text) = clean_prompt_text(text) {
857                        acc.prompt_preview = Some(text.clone());
858                        task_stack.observe_user(&text);
859                        current_prompt_index =
860                            events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
861                    }
862                }
863                if ptype == "agent_message" {
864                    let text = payload
865                        .get("message")
866                        .or_else(|| payload.get("content"))
867                        .and_then(Value::as_str)
868                        .unwrap_or("");
869                    if let Some(text) = clean_prompt_text(text) {
870                        events.llm_responses.push(LlmResponse {
871                            ts_ms: ts_ms_from_event(&obj),
872                            prompt_index: current_prompt_index,
873                            model: if codex_model.is_empty() {
874                                AGENT_CODEX.to_string()
875                            } else {
876                                codex_model.clone()
877                            },
878                            source_id: String::new(),
879                            text_hash: short_hash(&text, 12),
880                            preview: truncate_clean(&text, 180),
881                            input_tokens: 0,
882                            output_tokens: 0,
883                            cache_tokens: 0,
884                            total_tokens: 0,
885                            tag: String::new(),
886                            response_phase: payload
887                                .get("phase")
888                                .and_then(Value::as_str)
889                                .unwrap_or("assistant_message")
890                                .to_string(),
891                            skill: String::new(),
892                            task_path: task_stack.path(),
893                        });
894                    }
895                }
896            }
897            (AGENT_CODEX, "response_item")
898                if obj.pointer("/payload/type").and_then(Value::as_str)
899                    == Some("custom_tool_call") =>
900            {
901                let payload = obj.get("payload").unwrap_or(&Value::Null);
902                let outer_name = payload
903                    .get("name")
904                    .and_then(Value::as_str)
905                    .unwrap_or("tool");
906                let raw_input = payload.get("input").and_then(Value::as_str).unwrap_or("");
907                let (name, args) = codex_custom_tool_input(outer_name, raw_input);
908                acc.add_tool(&name);
909                let call_id = payload
910                    .get("call_id")
911                    .and_then(Value::as_str)
912                    .map(str::to_string);
913                let event = tool_event_from_input(
914                    acc.cwd.as_deref(),
915                    ts_ms_from_event(&obj),
916                    current_prompt_index,
917                    &name,
918                    &args,
919                    call_id.clone(),
920                    task_stack.path_for_tool(&name, &args),
921                );
922                if name == "update_plan" {
923                    task_stack.observe_plan(&args);
924                }
925                if let Some(id) = call_id {
926                    call_index.insert(id, events.tools.len());
927                }
928                events.tools.push(event);
929            }
930            (AGENT_CODEX, "response_item")
931                if obj.pointer("/payload/type").and_then(Value::as_str)
932                    == Some("custom_tool_call_output") =>
933            {
934                if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
935                    && let Some(index) = call_index.get(call_id).copied()
936                    && let Some(tool) = events.tools.get_mut(index)
937                {
938                    let output =
939                        content_to_text(obj.pointer("/payload/output").unwrap_or(&Value::Null));
940                    tool.status = status_from_output(&output).to_string();
941                }
942            }
943            (AGENT_CODEX, "response_item")
944                if obj.pointer("/payload/type").and_then(Value::as_str)
945                    == Some("function_call") =>
946            {
947                let name = obj
948                    .pointer("/payload/name")
949                    .and_then(Value::as_str)
950                    .unwrap_or("?");
951                acc.add_tool(name);
952                let payload = obj.get("payload").unwrap_or(&Value::Null);
953                let args = parse_tool_args(payload.get("arguments").unwrap_or(&Value::Null));
954                let call_id = payload
955                    .get("call_id")
956                    .and_then(Value::as_str)
957                    .map(str::to_string);
958                let event = tool_event_from_input(
959                    acc.cwd.as_deref(),
960                    ts_ms_from_event(&obj),
961                    current_prompt_index,
962                    name,
963                    &args,
964                    call_id.clone(),
965                    task_stack.path_for_tool(name, &args),
966                );
967                if name == "update_plan" {
968                    task_stack.observe_plan(&args);
969                }
970                if let Some(id) = call_id {
971                    call_index.insert(id, events.tools.len());
972                }
973                events.tools.push(event);
974            }
975            (AGENT_CODEX, "response_item")
976                if obj.pointer("/payload/type").and_then(Value::as_str)
977                    == Some("function_call_output") =>
978            {
979                if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
980                    && let Some(index) = call_index.get(call_id).copied()
981                    && let Some(tool) = events.tools.get_mut(index)
982                {
983                    let output = obj
984                        .pointer("/payload/output")
985                        .and_then(Value::as_str)
986                        .unwrap_or("");
987                    tool.status = status_from_output(output).to_string();
988                }
989            }
990            (AGENT_CODEX, "response_item")
991                if obj.pointer("/payload/type").and_then(Value::as_str) == Some("message") =>
992            {
993                let payload = obj.get("payload").unwrap_or(&Value::Null);
994                let text = payload
995                    .get("message")
996                    .and_then(Value::as_str)
997                    .map(str::to_string)
998                    .unwrap_or_else(|| {
999                        content_to_text(payload.get("content").unwrap_or(&Value::Null))
1000                    });
1001                if let Some(text) = clean_prompt_text(&text) {
1002                    if payload.get("role").and_then(Value::as_str) == Some("user") {
1003                        acc.prompt_preview = Some(text.clone());
1004                        task_stack.observe_user(&text);
1005                        current_prompt_index =
1006                            events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
1007                        continue;
1008                    }
1009                    let role = payload.get("role").and_then(Value::as_str);
1010                    let legacy_assistant = role.is_none()
1011                        && payload
1012                            .get("content")
1013                            .and_then(Value::as_array)
1014                            .is_some_and(|items| {
1015                                items.iter().any(|item| {
1016                                    item.get("type").and_then(Value::as_str) == Some("output_text")
1017                                })
1018                            });
1019                    if role != Some("assistant") && !legacy_assistant {
1020                        continue;
1021                    }
1022                    events.llm_responses.push(LlmResponse {
1023                        ts_ms: ts_ms_from_event(&obj),
1024                        prompt_index: current_prompt_index,
1025                        model: if codex_model.is_empty() {
1026                            AGENT_CODEX.to_string()
1027                        } else {
1028                            codex_model.clone()
1029                        },
1030                        source_id: String::new(),
1031                        text_hash: short_hash(&text, 12),
1032                        preview: truncate_clean(&text, 180),
1033                        input_tokens: 0,
1034                        output_tokens: 0,
1035                        cache_tokens: 0,
1036                        total_tokens: 0,
1037                        tag: String::new(),
1038                        response_phase: payload
1039                            .get("phase")
1040                            .and_then(Value::as_str)
1041                            .unwrap_or("assistant_message")
1042                            .to_string(),
1043                        skill: String::new(),
1044                        task_path: task_stack.path(),
1045                    });
1046                }
1047            }
1048            (AGENT_CODEX, "message" | "input" | "user") => {
1049                if let Some(text) = local_message_preview(&obj) {
1050                    acc.prompt_preview = Some(text.clone());
1051                    task_stack.observe_user(&text);
1052                    current_prompt_index =
1053                        events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
1054                }
1055            }
1056            _ if acc.prompt_preview.is_none() && typ.contains("user") => {
1057                if let Some(text) = local_message_preview(&obj) {
1058                    acc.prompt_preview = Some(text.clone());
1059                    task_stack.observe_user(&text);
1060                    current_prompt_index =
1061                        events.upsert_prompt(ts_ms_from_event(&obj), &text, task_stack.path());
1062                }
1063            }
1064            _ => {}
1065        }
1066    }
1067
1068    if acc.model_usage.is_empty() {
1069        acc.model_usage = claude_message_models;
1070    }
1071    deduplicate_llm_responses(&mut events);
1072    acc.finish_with_events(events)
1073}
1074
1075fn deduplicate_llm_responses(events: &mut SessionEvents) {
1076    let mut unique: Vec<LlmResponse> = Vec::with_capacity(events.llm_responses.len());
1077    let mut by_source_id = BTreeMap::<(usize, String), usize>::new();
1078    for response in events.llm_responses.drain(..) {
1079        let source_key = (!response.source_id.is_empty())
1080            .then(|| (response.prompt_index, response.source_id.clone()));
1081        let duplicate_index = source_key
1082            .as_ref()
1083            .and_then(|key| by_source_id.get(key).copied())
1084            .or_else(|| {
1085                unique.len().checked_sub(1).filter(|index| {
1086                    let previous = &unique[*index];
1087                    response.source_id.is_empty()
1088                        && previous.source_id.is_empty()
1089                        && previous.prompt_index == response.prompt_index
1090                        && previous.text_hash == response.text_hash
1091                        && previous
1092                            .ts_ms
1093                            .zip(response.ts_ms)
1094                            .is_some_and(|(left, right)| left.abs_diff(right) <= 1_000)
1095                })
1096            });
1097        if let Some(index) = duplicate_index {
1098            merge_llm_response(&mut unique[index], response);
1099            continue;
1100        }
1101        let index = unique.len();
1102        if let Some(key) = source_key {
1103            by_source_id.insert(key, index);
1104        }
1105        unique.push(response);
1106    }
1107    events.llm_responses = unique;
1108}
1109
1110fn merge_llm_response(previous: &mut LlmResponse, response: LlmResponse) {
1111    previous.input_tokens = previous.input_tokens.max(response.input_tokens);
1112    previous.output_tokens = previous.output_tokens.max(response.output_tokens);
1113    previous.cache_tokens = previous.cache_tokens.max(response.cache_tokens);
1114    previous.total_tokens = previous.total_tokens.max(response.total_tokens);
1115    if response_phase_priority(&response.response_phase)
1116        > response_phase_priority(&previous.response_phase)
1117    {
1118        previous.response_phase = response.response_phase;
1119    }
1120    if previous.preview.starts_with("tool: ") && !response.preview.starts_with("tool: ") {
1121        previous.preview = response.preview;
1122        previous.text_hash = response.text_hash;
1123    }
1124}
1125
1126fn response_phase_priority(phase: &str) -> u8 {
1127    match phase {
1128        "final_answer" => 3,
1129        "commentary" => 2,
1130        "assistant_message" => 1,
1131        _ => 0,
1132    }
1133}
1134
1135fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
1136    let root: Value = serde_json::from_str(content).ok()?;
1137    let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
1138    let mut events = SessionEvents::default();
1139    let mut current_prompt_index = 0usize;
1140    let mut task_stack = SemanticTaskStack::default();
1141    if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
1142        acc.session_id = id.to_string();
1143        acc.conversation_id = Some(id.to_string());
1144    }
1145    acc.start_timestamp_ms = root
1146        .get("startTime")
1147        .and_then(Value::as_str)
1148        .and_then(iso_ms);
1149    acc.end_timestamp_ms = root
1150        .get("lastUpdated")
1151        .and_then(Value::as_str)
1152        .and_then(iso_ms)
1153        .or(acc.start_timestamp_ms);
1154    acc.duration_ms = acc
1155        .start_timestamp_ms
1156        .zip(acc.end_timestamp_ms)
1157        .map(|(start, end)| end.saturating_sub(start))
1158        .unwrap_or_default();
1159
1160    let Some(messages) = root.get("messages").and_then(Value::as_array) else {
1161        return acc.finish_with_events(events);
1162    };
1163    for msg in messages {
1164        if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
1165            acc.last_message_at = Some(ts.to_string());
1166        }
1167        let ts_ms = msg
1168            .get("timestamp")
1169            .and_then(Value::as_str)
1170            .and_then(parse_ts_ms);
1171        match msg.get("type").and_then(Value::as_str) {
1172            Some("user") if acc.prompt_preview.is_none() => {
1173                if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
1174                    acc.prompt_preview = Some(text.clone());
1175                    task_stack.observe_user(&text);
1176                    current_prompt_index = events.upsert_prompt(ts_ms, &text, task_stack.path());
1177                }
1178            }
1179            Some("user") => {
1180                if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
1181                    task_stack.observe_user(&text);
1182                    current_prompt_index = events.upsert_prompt(ts_ms, &text, task_stack.path());
1183                }
1184            }
1185            Some("gemini") | Some("assistant") | Some("model") => {
1186                let mut llm_model = AGENT_GEMINI.to_string();
1187                if let Some(model) = msg.get("model").and_then(Value::as_str) {
1188                    llm_model = model.to_string();
1189                    acc.model.get_or_insert_with(|| model.to_string());
1190                    if let Some(tokens) = msg.get("tokens") {
1191                        acc.add_usage(
1192                            model,
1193                            json_i64(tokens, "input"),
1194                            json_i64(tokens, "output"),
1195                            0,
1196                            json_i64(tokens, "cached"),
1197                            json_i64(tokens, "total"),
1198                        );
1199                    }
1200                }
1201                if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
1202                    for call in tool_calls {
1203                        let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
1204                        acc.add_tool(name);
1205                        if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
1206                        {
1207                            acc.add_file(path);
1208                        }
1209                        let mut event = tool_event_from_input(
1210                            acc.cwd.as_deref(),
1211                            ts_ms,
1212                            current_prompt_index,
1213                            name,
1214                            call,
1215                            call.get("id").and_then(Value::as_str).map(str::to_string),
1216                            task_stack.path_for_tool(name, call),
1217                        );
1218                        if let Some(status) = call.get("status").and_then(Value::as_str) {
1219                            let lowered = status.to_ascii_lowercase();
1220                            event.status = if matches!(
1221                                lowered.as_str(),
1222                                "error" | "failed" | "fail" | "cancelled" | "canceled"
1223                            ) {
1224                                "fail".to_string()
1225                            } else if matches!(lowered.as_str(), "success" | "ok" | "completed") {
1226                                "ok".to_string()
1227                            } else {
1228                                status.to_string()
1229                            };
1230                        }
1231                        events.tools.push(event);
1232                    }
1233                }
1234                let content = msg.get("content").unwrap_or(msg);
1235                let text = content_to_text(content);
1236                let tokens = msg.get("tokens").unwrap_or(&Value::Null);
1237                if !text.trim().is_empty() || tokens.is_object() {
1238                    events.llm_responses.push(LlmResponse {
1239                        ts_ms,
1240                        prompt_index: current_prompt_index,
1241                        model: llm_model,
1242                        source_id: String::new(),
1243                        text_hash: short_hash(&(text.clone() + &tokens.to_string()), 12),
1244                        preview: truncate_clean(
1245                            if text.trim().is_empty() {
1246                                "gemini response"
1247                            } else {
1248                                &text
1249                            },
1250                            140,
1251                        ),
1252                        input_tokens: json_u64(tokens, "input"),
1253                        output_tokens: json_u64(tokens, "output"),
1254                        cache_tokens: json_u64(tokens, "cached"),
1255                        total_tokens: json_u64(tokens, "total"),
1256                        tag: String::new(),
1257                        response_phase: if msg
1258                            .get("toolCalls")
1259                            .and_then(Value::as_array)
1260                            .is_some_and(|calls| !calls.is_empty())
1261                        {
1262                            "assistant_message".to_string()
1263                        } else {
1264                            "final_answer".to_string()
1265                        },
1266                        skill: String::new(),
1267                        task_path: task_stack.path(),
1268                    });
1269                }
1270            }
1271            _ => {}
1272        }
1273    }
1274    acc.finish_with_events(events)
1275}
1276
1277fn read_cursor_subagents(path: &Path) -> Vec<(PathBuf, String)> {
1278    let Some(dir) = path.parent().map(|parent| parent.join("subagents")) else {
1279        return Vec::new();
1280    };
1281    let Ok(entries) = fs::read_dir(&dir) else {
1282        return Vec::new();
1283    };
1284    let mut out: Vec<(PathBuf, String)> = entries
1285        .flatten()
1286        .map(|entry| entry.path())
1287        .filter(|child| child.extension().and_then(|ext| ext.to_str()) == Some("jsonl"))
1288        .filter_map(|child| {
1289            let content = fs::read_to_string(&child).ok()?;
1290            Some((child, content))
1291        })
1292        .collect();
1293    // Directory order is arbitrary; keep parses deterministic across runs.
1294    out.sort_by(|left, right| left.0.cmp(&right.0));
1295    out
1296}
1297
1298fn parse_cursor_jsonl(
1299    path: &Path,
1300    updated: SystemTime,
1301    content: &str,
1302    children: &[(PathBuf, String)],
1303) -> Option<AgentSession> {
1304    let mut acc = SessionAccumulator::new(AGENT_CURSOR, path, updated);
1305    acc.conversation_id = Some(acc.session_id.clone());
1306    let mut events = SessionEvents::default();
1307    let mut current_prompt_index = 0usize;
1308
1309    // Resolve cwd before the walk so tool events group their paths against it.
1310    acc.cwd = cursor_session_cwd(content, children);
1311
1312    let mut delegations = Vec::new();
1313    cursor_absorb_transcript(
1314        content,
1315        CursorScope::Parent,
1316        &mut acc,
1317        &mut events,
1318        &mut current_prompt_index,
1319        &mut delegations,
1320    );
1321    for (_, child_content) in children {
1322        // Attribute the child's work to the prompt that delegated it, not the last one.
1323        let mut index = cursor_delegating_prompt_index(child_content, &delegations)
1324            .unwrap_or(current_prompt_index);
1325        cursor_absorb_transcript(
1326            child_content,
1327            CursorScope::Subagent,
1328            &mut acc,
1329            &mut events,
1330            &mut index,
1331            &mut Vec::new(),
1332        );
1333    }
1334
1335    acc.finish_with_events(events)
1336}
1337
1338// A sub-agent's user records hold the generated Task prompt, not a human one.
1339#[derive(Clone, Copy, PartialEq, Eq)]
1340enum CursorScope {
1341    Parent,
1342    Subagent,
1343}
1344
1345fn cursor_absorb_transcript(
1346    content: &str,
1347    scope: CursorScope,
1348    acc: &mut SessionAccumulator,
1349    events: &mut SessionEvents,
1350    current_prompt_index: &mut usize,
1351    delegations: &mut Vec<(usize, String)>,
1352) {
1353    // Tools recorded since the last turn marker, so a failed turn can mark them.
1354    let mut turn_start = events.tools.len();
1355    // Clock carried forward from the most recent user message wrapper.
1356    let mut current_ts_ms: Option<i64> = None;
1357    for line in content.lines() {
1358        let line = line.trim();
1359        if line.is_empty() {
1360            continue;
1361        }
1362        // Cursor appends while a session runs, so a torn final line is expected
1363        // rather than exceptional. Skip it and keep the records we already have.
1364        let Ok(record) = serde_json::from_str::<Value>(line) else {
1365            continue;
1366        };
1367
1368        // Cursor writes no tool_result, so a failed turn is the only outcome signal.
1369        if record.get("type").and_then(Value::as_str) == Some("turn_ended") {
1370            let failed = record
1371                .get("status")
1372                .and_then(Value::as_str)
1373                .is_some_and(|status| {
1374                    matches!(
1375                        status,
1376                        "error" | "failed" | "fail" | "cancelled" | "canceled"
1377                    )
1378                });
1379            if failed {
1380                for tool in events.tools.iter_mut().skip(turn_start) {
1381                    tool.status = "fail".to_string();
1382                }
1383            }
1384            turn_start = events.tools.len();
1385            continue;
1386        }
1387
1388        match record.get("role").and_then(Value::as_str) {
1389            Some("user") => {
1390                let raw = cursor_text_of(&record);
1391                // The only clock a transcript has, and children carry one too.
1392                if let Some(ts) = cursor_wrapper_ts_ms(&raw) {
1393                    current_ts_ms = Some(ts);
1394                }
1395                if scope == CursorScope::Parent {
1396                    let text = cursor_user_query(&raw);
1397                    if !text.is_empty() {
1398                        *current_prompt_index =
1399                            events.upsert_prompt(current_ts_ms, &text, Vec::new());
1400                        if acc.prompt_preview.is_none() {
1401                            acc.prompt_preview = Some(truncate_clean(&text, 180));
1402                        }
1403                    }
1404                }
1405            }
1406            Some("assistant") => {
1407                for part in cursor_tool_uses(&record) {
1408                    if scope == CursorScope::Parent
1409                        && part.get("name").and_then(Value::as_str) == Some("Task")
1410                        && let Some(prompt) = part
1411                            .get("input")
1412                            .and_then(|input| input.get("prompt"))
1413                            .and_then(Value::as_str)
1414                            .filter(|prompt| !prompt.trim().is_empty())
1415                    {
1416                        delegations.push((*current_prompt_index, prompt.trim().to_string()));
1417                    }
1418                    cursor_push_tool_event(part, acc, events, *current_prompt_index, current_ts_ms);
1419                }
1420                let text = cursor_text_of(&record);
1421                if !text.is_empty() {
1422                    events.llm_responses.push(LlmResponse {
1423                        ts_ms: current_ts_ms,
1424                        prompt_index: *current_prompt_index,
1425                        // Model, tokens, and timestamps live in state.vscdb, not
1426                        // the transcript. The SQLite enrichment fills them in.
1427                        model: String::new(),
1428                        source_id: String::new(),
1429                        text_hash: short_hash(&text, 12),
1430                        preview: truncate_clean(&text, 140),
1431                        input_tokens: 0,
1432                        output_tokens: 0,
1433                        cache_tokens: 0,
1434                        total_tokens: 0,
1435                        tag: String::new(),
1436                        response_phase: String::new(),
1437                        skill: String::new(),
1438                        task_path: Vec::new(),
1439                    });
1440                }
1441            }
1442            _ => {}
1443        }
1444    }
1445}
1446
1447fn cursor_session_cwd(content: &str, children: &[(PathBuf, String)]) -> Option<String> {
1448    let mut absolute = Vec::new();
1449    for transcript in std::iter::once(content).chain(children.iter().map(|(_, body)| body.as_str()))
1450    {
1451        for line in transcript.lines() {
1452            let Ok(record) = serde_json::from_str::<Value>(line.trim()) else {
1453                continue;
1454            };
1455            for part in cursor_tool_uses(&record) {
1456                let Some(input) = part.get("input") else {
1457                    continue;
1458                };
1459                if let Some(dir) = input.get("working_directory").and_then(Value::as_str)
1460                    && is_absolute_path_text(dir)
1461                {
1462                    return Some(normalize_path_text(dir));
1463                }
1464                for key in ["path", "paths"] {
1465                    match input.get(key) {
1466                        Some(Value::String(value)) => absolute.push(value.clone()),
1467                        Some(Value::Array(values)) => absolute
1468                            .extend(values.iter().filter_map(Value::as_str).map(str::to_string)),
1469                        _ => {}
1470                    }
1471                }
1472            }
1473        }
1474    }
1475    common_parent_dir(&absolute)
1476}
1477
1478fn common_parent_dir(paths: &[String]) -> Option<String> {
1479    let mut dirs = paths
1480        .iter()
1481        .filter(|path| is_absolute_path_text(path))
1482        .map(|path| normalize_path_text(path))
1483        .map(|path| {
1484            let (root, remainder) = path_root(&path);
1485            let mut parts = remainder
1486                .split('/')
1487                .filter(|part| !part.is_empty())
1488                .map(str::to_string)
1489                .collect::<Vec<_>>();
1490            parts.pop();
1491            (root.to_string(), parts)
1492        });
1493    let (root, mut shared) = dirs.next()?;
1494    for (candidate_root, candidate) in dirs {
1495        if candidate_root != root {
1496            return None;
1497        }
1498        let keep = shared
1499            .iter()
1500            .zip(candidate.iter())
1501            .take_while(|(left, right)| left == right)
1502            .count();
1503        shared.truncate(keep);
1504    }
1505    if root == "//" && shared.len() < 2 {
1506        return None;
1507    }
1508    if shared.is_empty() {
1509        return (root != "/").then_some(root);
1510    }
1511    Some(format!("{root}{}", shared.join("/")))
1512}
1513
1514fn path_root(path: &str) -> (&str, &str) {
1515    if let Some(remainder) = path.strip_prefix("//") {
1516        ("//", remainder)
1517    } else if path.as_bytes().get(1) == Some(&b':') && path.as_bytes().get(2) == Some(&b'/') {
1518        (&path[..3], &path[3..])
1519    } else if let Some(remainder) = path.strip_prefix('/') {
1520        ("/", remainder)
1521    } else {
1522        ("", path)
1523    }
1524}
1525
1526fn cursor_delegating_prompt_index(
1527    child_content: &str,
1528    delegations: &[(usize, String)],
1529) -> Option<usize> {
1530    if delegations.is_empty() {
1531        return None;
1532    }
1533    let opening = cursor_first_user_text(child_content)?;
1534    delegations
1535        .iter()
1536        .find(|(_, prompt)| opening.contains(prompt.as_str()))
1537        .map(|(index, _)| *index)
1538}
1539
1540fn cursor_wrapper_ts_ms(text: &str) -> Option<i64> {
1541    const OPEN: &str = "<timestamp>";
1542    const CLOSE: &str = "</timestamp>";
1543    let start = text.find(OPEN)? + OPEN.len();
1544    let rest = &text[start..];
1545    let raw = rest[..rest.find(CLOSE)?].trim();
1546
1547    // Trailing "(UTC-5)" gives the offset the local time was written in.
1548    let (stamp, offset_hours) = match raw.rfind("(UTC") {
1549        Some(index) => {
1550            let hours = raw[index + 4..]
1551                .trim_end_matches(')')
1552                .trim()
1553                .parse::<i64>()
1554                .unwrap_or(0);
1555            (raw[..index].trim(), hours)
1556        }
1557        None => (raw, 0),
1558    };
1559    let naive = chrono::NaiveDateTime::parse_from_str(stamp, "%A, %b %d, %Y, %I:%M %p").ok()?;
1560    Some(naive.and_utc().timestamp_millis() - offset_hours * 3_600_000)
1561}
1562
1563fn cursor_user_query(text: &str) -> String {
1564    const OPEN: &str = "<user_query>";
1565    const CLOSE: &str = "</user_query>";
1566    let Some(start) = text.find(OPEN) else {
1567        return text.trim().to_string();
1568    };
1569    let rest = &text[start + OPEN.len()..];
1570    let inner = match rest.find(CLOSE) {
1571        Some(end) => &rest[..end],
1572        // A torn final line can cut the closing tag off.
1573        None => rest,
1574    };
1575    inner.trim().to_string()
1576}
1577
1578fn cursor_first_user_text(content: &str) -> Option<String> {
1579    content.lines().find_map(|line| {
1580        let record = serde_json::from_str::<Value>(line.trim()).ok()?;
1581        (record.get("role").and_then(Value::as_str) == Some("user"))
1582            .then(|| cursor_text_of(&record))
1583            .filter(|text| !text.is_empty())
1584    })
1585}
1586
1587fn cursor_tool_uses(record: &Value) -> Vec<&Value> {
1588    record
1589        .get("message")
1590        .and_then(|message| message.get("content"))
1591        .and_then(Value::as_array)
1592        .map(|parts| {
1593            parts
1594                .iter()
1595                .filter(|part| part.get("type").and_then(Value::as_str) == Some("tool_use"))
1596                .collect()
1597        })
1598        .unwrap_or_default()
1599}
1600
1601fn cursor_push_tool_event(
1602    part: &Value,
1603    acc: &mut SessionAccumulator,
1604    events: &mut SessionEvents,
1605    prompt_index: usize,
1606    ts_ms: Option<i64>,
1607) {
1608    let Some(name) = part
1609        .get("name")
1610        .and_then(Value::as_str)
1611        .filter(|n| !n.is_empty())
1612    else {
1613        return;
1614    };
1615    let input = part.get("input").cloned().unwrap_or(Value::Null);
1616
1617    acc.add_tool(name);
1618    let event = tool_event_from_input(
1619        acc.cwd.as_deref(),
1620        // Inherited from the turn's wrapper. Cursor records no call id.
1621        ts_ms,
1622        prompt_index,
1623        name,
1624        &input,
1625        None,
1626        Vec::new(),
1627    );
1628    for path in &event.paths {
1629        acc.add_file(&path.path);
1630    }
1631    events.tools.push(event);
1632}
1633
1634fn cursor_text_of(record: &Value) -> String {
1635    let Some(parts) = record
1636        .get("message")
1637        .and_then(|message| message.get("content"))
1638        .and_then(Value::as_array)
1639    else {
1640        return String::new();
1641    };
1642    parts
1643        .iter()
1644        .filter(|part| part.get("type").and_then(Value::as_str) == Some("text"))
1645        .filter_map(|part| part.get("text").and_then(Value::as_str))
1646        .collect::<Vec<_>>()
1647        .join("\n")
1648        .trim()
1649        .to_string()
1650}
1651
1652struct SessionAccumulator {
1653    agent_type: String,
1654    session_id: String,
1655    conversation_id: Option<String>,
1656    path: PathBuf,
1657    updated: SystemTime,
1658    start_timestamp_ms: Option<u64>,
1659    end_timestamp_ms: Option<u64>,
1660    model: Option<String>,
1661    model_usage: BTreeMap<String, TokenUsage>,
1662    tools: BTreeMap<String, usize>,
1663    files: BTreeMap<String, usize>,
1664    prompt_preview: Option<String>,
1665    duration_ms: u64,
1666    cwd: Option<String>,
1667    last_message_at: Option<String>,
1668}
1669
1670impl SessionAccumulator {
1671    fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
1672        let normalized = normalize_session_log_path(path);
1673        let session_id = path
1674            .file_stem()
1675            .and_then(|stem| stem.to_str())
1676            .unwrap_or("session")
1677            .to_string();
1678        Self {
1679            agent_type: agent.to_string(),
1680            session_id,
1681            conversation_id: None,
1682            path: normalized.clone(),
1683            updated,
1684            start_timestamp_ms: None,
1685            end_timestamp_ms: Some(system_time_ms(updated)),
1686            model: None,
1687            model_usage: BTreeMap::new(),
1688            tools: BTreeMap::new(),
1689            files: BTreeMap::new(),
1690            prompt_preview: None,
1691            duration_ms: 0,
1692            cwd: None,
1693            last_message_at: None,
1694        }
1695    }
1696
1697    fn add_usage(
1698        &mut self,
1699        model: &str,
1700        input: i64,
1701        output: i64,
1702        cache_creation: i64,
1703        cache_read: i64,
1704        total: i64,
1705    ) {
1706        add_usage(
1707            &mut self.model_usage,
1708            model,
1709            input,
1710            output,
1711            cache_creation,
1712            cache_read,
1713            total,
1714        );
1715    }
1716
1717    fn set_usage(
1718        &mut self,
1719        model: &str,
1720        input: i64,
1721        output: i64,
1722        cache_creation: i64,
1723        cache_read: i64,
1724        total: i64,
1725    ) {
1726        let mut usage = TokenUsage::default();
1727        usage.add(input, output, cache_creation, cache_read, total);
1728        self.model_usage.insert(model.to_string(), usage);
1729    }
1730
1731    fn add_tool(&mut self, name: &str) {
1732        *self.tools.entry(name.to_string()).or_default() += 1;
1733    }
1734
1735    fn add_file(&mut self, path: &str) {
1736        *self.files.entry(path.to_string()).or_default() += 1;
1737    }
1738
1739    fn finish(self) -> Option<AgentSession> {
1740        let token_usage =
1741            self.model_usage
1742                .values()
1743                .fold(TokenUsage::default(), |mut total, usage| {
1744                    total.input_tokens += usage.input_tokens;
1745                    total.output_tokens += usage.output_tokens;
1746                    total.cache_creation_tokens += usage.cache_creation_tokens;
1747                    total.cache_read_tokens += usage.cache_read_tokens;
1748                    total.total_tokens += usage.total_tokens;
1749                    total
1750                });
1751        if token_usage.total_tokens == 0
1752            && self.tools.is_empty()
1753            && self.prompt_preview.is_none()
1754            && self.model.is_none()
1755        {
1756            return None;
1757        }
1758        let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
1759        Some(AgentSession {
1760            agent_type: self.agent_type,
1761            session_id: self.session_id,
1762            conversation_id: self.conversation_id,
1763            display_id,
1764            path: self.path,
1765            updated: self.updated,
1766            start_timestamp_ms: self
1767                .start_timestamp_ms
1768                .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
1769            end_timestamp_ms: self.end_timestamp_ms,
1770            model: self.model,
1771            usage: token_usage,
1772            model_usage: self.model_usage,
1773            tools: self.tools,
1774            files: self.files,
1775            prompt_preview: self.prompt_preview,
1776            duration_ms: self.duration_ms,
1777            cwd: self.cwd,
1778            last_message_at: self.last_message_at,
1779            events: SessionEvents::default(),
1780        })
1781    }
1782
1783    fn finish_with_events(self, events: SessionEvents) -> Option<AgentSession> {
1784        self.finish().map(|mut session| {
1785            session.events = events;
1786            session
1787        })
1788    }
1789}
1790
1791// ---------------------------------------------------------------------------
1792// Helper functions
1793// ---------------------------------------------------------------------------
1794
1795fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
1796    let Ok(entries) = fs::read_dir(dir) else {
1797        return;
1798    };
1799    for entry in entries.flatten() {
1800        let path = entry.path();
1801        if path.is_dir() {
1802            walk_agent_files(agent, &path, f);
1803        } else if is_agent_file_for(agent, &path)
1804            && let Ok(meta) = path.metadata()
1805        {
1806            f(&path, &meta);
1807        }
1808    }
1809}
1810
1811fn is_agent_session_file(path: &Path) -> bool {
1812    agent_source_for_path(path).is_some()
1813}
1814
1815fn is_agent_file_for(agent: &str, path: &Path) -> bool {
1816    match agent {
1817        AGENT_CLAUDE | AGENT_CODEX => {
1818            path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
1819        }
1820        AGENT_GEMINI => {
1821            path.extension().and_then(|ext| ext.to_str()) == Some("json")
1822                && path
1823                    .file_name()
1824                    .and_then(|name| name.to_str())
1825                    .is_some_and(|name| name.starts_with("session-"))
1826                && normalize_path_text(&path.to_string_lossy()).contains("/chats/")
1827        }
1828        AGENT_CURSOR => is_cursor_parent_transcript(path),
1829        _ => false,
1830    }
1831}
1832
1833pub(crate) fn user_home_dir() -> Option<PathBuf> {
1834    std::env::var("SUDO_USER")
1835        .ok()
1836        .and_then(|user| {
1837            fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
1838                passwd
1839                    .lines()
1840                    .find(|line| line.starts_with(&format!("{user}:")))
1841                    .and_then(|line| line.split(':').nth(5))
1842                    .map(PathBuf::from)
1843            })
1844        })
1845        .or_else(|| {
1846            std::env::var_os("HOME")
1847                .map(PathBuf::from)
1848                .filter(|home| home.is_absolute())
1849        })
1850        .or_else(dirs::home_dir)
1851}
1852
1853fn add_usage(
1854    models: &mut BTreeMap<String, TokenUsage>,
1855    model: &str,
1856    input: i64,
1857    output: i64,
1858    cache_creation: i64,
1859    cache_read: i64,
1860    total: i64,
1861) {
1862    models.entry(model.to_string()).or_default().add(
1863        input,
1864        output,
1865        cache_creation,
1866        cache_read,
1867        total,
1868    );
1869}
1870
1871impl SessionEvents {
1872    fn upsert_prompt(&mut self, ts_ms: Option<i64>, text: &str, task_path: Vec<String>) -> usize {
1873        let hash = short_hash(text, 12);
1874        if let Some(existing) = self.prompts.iter().rposition(|prompt| {
1875            prompt.text_hash == hash
1876                && match (prompt.ts_ms, ts_ms) {
1877                    (Some(left), Some(right)) => left.abs_diff(right) <= 1_000,
1878                    (None, None) => self
1879                        .prompts
1880                        .last()
1881                        .is_some_and(|last| last.index == prompt.index),
1882                    _ => false,
1883                }
1884        }) {
1885            return existing;
1886        }
1887        let index = self.prompts.len();
1888        self.prompts.push(UserPrompt {
1889            index,
1890            ts_ms,
1891            text_hash: hash,
1892            preview: truncate_clean(text, 180),
1893            tag: String::new(),
1894            task_path,
1895        });
1896        index
1897    }
1898}
1899
1900fn tool_event_from_input(
1901    cwd: Option<&str>,
1902    ts_ms: Option<i64>,
1903    prompt_index: usize,
1904    name: &str,
1905    input: &Value,
1906    call_id: Option<String>,
1907    task_path: Vec<String>,
1908) -> ToolEvent {
1909    let command = command_from_tool_input(input);
1910    let category = tool_category(name, &command);
1911    let domains = extract_domains(&command);
1912    let command_name = if category == "shell" {
1913        basename_from_command(&command)
1914    } else if category == "network" && !domains.is_empty() {
1915        domains[0]
1916            .split(':')
1917            .next()
1918            .unwrap_or("network")
1919            .to_string()
1920    } else {
1921        one_word(name, "tool")
1922    };
1923    let effect = if name == "apply_patch" || command.contains("*** ") {
1924        "write".to_string()
1925    } else {
1926        command_effect(&command)
1927    };
1928    let cwd = cwd.unwrap_or("");
1929    let path_groups = extract_path_groups(Path::new(cwd), name, input, &command);
1930    let paths = extract_tool_paths(name, input, &command, &effect);
1931    let process_chain = if category == "shell" {
1932        command_process_chain(&command)
1933    } else {
1934        Vec::new()
1935    };
1936    ToolEvent {
1937        ts_ms,
1938        prompt_index,
1939        tool_name: name.to_string(),
1940        category,
1941        command,
1942        command_name,
1943        effect,
1944        process_chain,
1945        status: "observed".to_string(),
1946        path_groups,
1947        paths,
1948        domains,
1949        call_id,
1950        invoked_skill: String::new(),
1951        skill: String::new(),
1952        task_path,
1953    }
1954}
1955
1956fn extract_tool_paths(name: &str, input: &Value, command: &str, effect: &str) -> Vec<ToolPath> {
1957    let lower = name.to_ascii_lowercase();
1958    let is_shell = lower.contains("bash") || lower.contains("exec") || lower.contains("shell");
1959    let default_access = if lower.contains("read")
1960        || lower.contains("grep")
1961        || lower.contains("glob")
1962        || lower.contains("search")
1963    {
1964        "read"
1965    } else if lower.contains("write")
1966        || lower.contains("edit")
1967        || lower.contains("replace")
1968        || lower.contains("patch")
1969    {
1970        "write"
1971    } else if lower.contains("delete") {
1972        // Cursor deletes files through a dedicated Delete tool rather than a
1973        // patch or a shell rm, so without this the deletion records no path.
1974        "delete"
1975    } else if is_shell {
1976        if effect == "read" { "read" } else { "write" }
1977    } else {
1978        return Vec::new();
1979    };
1980    let mut rows = BTreeMap::<String, (String, Option<String>)>::new();
1981    if !is_shell {
1982        collect_path_fields(input, default_access, &mut rows);
1983    }
1984
1985    let embedded_patch = embedded_json_string(command, "*** Begin Patch");
1986    let patch = input
1987        .get("patch")
1988        .or_else(|| input.get("input"))
1989        .or_else(|| input.get("text"))
1990        .and_then(Value::as_str)
1991        .filter(|value| value.contains("*** Begin Patch") && value.lines().count() > 1)
1992        .or(embedded_patch.as_deref())
1993        .or_else(|| {
1994            (command.contains("*** Begin Patch") && command.lines().count() > 1).then_some(command)
1995        });
1996    let mut has_patch = false;
1997    if let Some(patch) = patch {
1998        let mut pending_update = None;
1999        for line in patch.lines() {
2000            let marker = line.trim();
2001            for (prefix, access) in [
2002                ("*** Add File: ", "create"),
2003                ("*** Update File: ", "write"),
2004                ("*** Delete File: ", "delete"),
2005                ("*** Move to: ", "rename"),
2006            ] {
2007                if let Some(path) = marker.strip_prefix(prefix) {
2008                    let path = clean_path_token(path);
2009                    if !path.is_empty() {
2010                        has_patch = true;
2011                        if access == "write" {
2012                            pending_update = Some(path.clone());
2013                        } else if access == "rename"
2014                            && let Some(source) = pending_update.take()
2015                        {
2016                            rows.remove(&source);
2017                            rows.insert(path.clone(), ("rename".to_string(), Some(source)));
2018                            continue;
2019                        }
2020                        rows.insert(path, (access.to_string(), None));
2021                    }
2022                }
2023            }
2024        }
2025    }
2026
2027    if is_shell && !has_patch {
2028        for (path, access, previous_path) in shell_file_actions(command, input, 0) {
2029            rows.insert(path, (access, previous_path));
2030        }
2031        for nested in embedded_json_objects(command, "tools.exec_command(") {
2032            let nested_command = command_from_tool_input(&nested);
2033            for (path, access, previous_path) in shell_file_actions(&nested_command, &nested, 0) {
2034                rows.insert(path, (access, previous_path));
2035            }
2036        }
2037    }
2038    rows.into_iter()
2039        .map(|(path, (access, previous_path))| ToolPath {
2040            path,
2041            access,
2042            previous_path,
2043        })
2044        .collect()
2045}
2046
2047fn embedded_json_objects(text: &str, marker: &str) -> Vec<Value> {
2048    let mut rows = Vec::new();
2049    let mut offset = 0;
2050    while let Some(found) = text[offset..].find(marker) {
2051        let start = offset + found + marker.len();
2052        let Some(open) = text[start..].find('{').map(|value| start + value) else {
2053            break;
2054        };
2055        let mut depth = 0;
2056        let mut quote = false;
2057        let mut escaped = false;
2058        let mut end = None;
2059        for (index, ch) in text[open..].char_indices() {
2060            if escaped {
2061                escaped = false;
2062            } else if ch == '\\' && quote {
2063                escaped = true;
2064            } else if ch == '"' {
2065                quote = !quote;
2066            } else if !quote && ch == '{' {
2067                depth += 1;
2068            } else if !quote && ch == '}' {
2069                depth -= 1;
2070                if depth == 0 {
2071                    end = Some(open + index + 1);
2072                    break;
2073                }
2074            }
2075        }
2076        let Some(end) = end else { break };
2077        if let Ok(value) = serde_json::from_str(&text[open..end]) {
2078            rows.push(value);
2079        }
2080        offset = end;
2081    }
2082    rows
2083}
2084
2085fn embedded_json_string(text: &str, needle: &str) -> Option<String> {
2086    let needle = text.find(needle)?;
2087    let start = text[..needle].rfind('"')?;
2088    let mut escaped = false;
2089    for (offset, ch) in text[start + 1..].char_indices() {
2090        if escaped {
2091            escaped = false;
2092        } else if ch == '\\' {
2093            escaped = true;
2094        } else if ch == '"' {
2095            return serde_json::from_str(&text[start..start + offset + 2]).ok();
2096        }
2097    }
2098    None
2099}
2100
2101fn shell_file_actions(
2102    command: &str,
2103    input: &Value,
2104    depth: usize,
2105) -> Vec<(String, String, Option<String>)> {
2106    if depth > 2 {
2107        return Vec::new();
2108    }
2109    // Cursor's Shell tool names this working_directory rather than workdir or
2110    // cwd, and it is the only cwd signal on a command that has no leading cd.
2111    let mut cwd = ["workdir", "cwd", "working_directory"]
2112        .iter()
2113        .find_map(|key| input.get(*key).and_then(Value::as_str))
2114        .map(normalize_path_text);
2115    let mut rows = Vec::new();
2116    for parts in shell_segments(command) {
2117        let Some(command_index) = shell_command_index(&parts) else {
2118            continue;
2119        };
2120        let name = process_name_from_part(&parts[command_index]).unwrap_or_default();
2121        let operands = &parts[command_index + 1..];
2122        if name == "cd" {
2123            if let Some(path) = operands.iter().find(|value| !value.starts_with('-')) {
2124                cwd = Some(if is_absolute_path_text(path) {
2125                    normalize_path_text(path)
2126                } else {
2127                    join_path_text(cwd.as_deref().unwrap_or_default(), path)
2128                });
2129            }
2130            continue;
2131        }
2132        let mut actions = shell_segment_actions(&name, operands, input, depth);
2133        for (path, _, previous_path) in &mut actions {
2134            if !path.starts_with(['~', '$'])
2135                && !is_absolute_path_text(path)
2136                && let Some(base) = &cwd
2137            {
2138                *path = join_path_text(base, path);
2139            }
2140            *path = clean_path_token(path);
2141            if let Some(previous) = previous_path {
2142                if !previous.starts_with(['~', '$'])
2143                    && !is_absolute_path_text(previous)
2144                    && let Some(base) = &cwd
2145                {
2146                    *previous = join_path_text(base, previous);
2147                }
2148                *previous = clean_path_token(previous);
2149            }
2150        }
2151        rows.extend(actions.into_iter().filter(|(path, _, _)| !path.is_empty()));
2152    }
2153    rows
2154}
2155
2156fn shell_segment_actions(
2157    name: &str,
2158    operands: &[String],
2159    input: &Value,
2160    depth: usize,
2161) -> Vec<(String, String, Option<String>)> {
2162    let mut rows = Vec::new();
2163    let mut values = Vec::new();
2164    let mut index = 0;
2165    while index < operands.len() {
2166        if is_redirection_token(&operands[index]) {
2167            if let Some(path) = operands.get(index + 1)
2168                && plausible_path_operand(path)
2169            {
2170                let access = if [">", ">>", "&>", "&>>"].contains(&operands[index].as_str()) {
2171                    "write"
2172                } else if ["<", "<>"].contains(&operands[index].as_str()) {
2173                    "read"
2174                } else {
2175                    index += 2;
2176                    continue;
2177                };
2178                rows.push((path.clone(), access.into(), None));
2179            }
2180            index += 2;
2181            continue;
2182        }
2183        values.push(operands[index].clone());
2184        index += 1;
2185    }
2186    let paths = |items: &[String]| {
2187        items
2188            .iter()
2189            .filter(|value| !value.starts_with('-') && plausible_path_operand(value))
2190            .cloned()
2191            .collect::<Vec<_>>()
2192    };
2193    match name {
2194        "bash" | "sh" | "zsh" => {
2195            for index in 0..values.len().saturating_sub(1) {
2196                if ["-c", "-lc", "-cl"].contains(&values[index].as_str()) {
2197                    rows.extend(shell_file_actions(&values[index + 1], input, depth + 1));
2198                    break;
2199                }
2200            }
2201        }
2202        "cp" => {
2203            let paths = paths(&values);
2204            if let Some((target, sources)) = paths.split_last() {
2205                for source in sources {
2206                    rows.push((source.clone(), "read".into(), None));
2207                    let destination = destination_path(target, source, sources.len() > 1);
2208                    rows.push((destination, "create".into(), None));
2209                }
2210            }
2211        }
2212        "mv" => {
2213            let paths = paths(&values);
2214            if let Some((target, sources)) = paths.split_last() {
2215                for source in sources {
2216                    rows.push((
2217                        destination_path(target, source, sources.len() > 1),
2218                        "rename".into(),
2219                        Some(source.clone()),
2220                    ));
2221                }
2222            }
2223        }
2224        "rm" => rows.extend(
2225            paths(&values)
2226                .into_iter()
2227                .map(|path| (path, "delete".into(), None)),
2228        ),
2229        "touch" | "install" => rows.extend(
2230            paths(&values)
2231                .into_iter()
2232                .map(|path| (path, "create".into(), None)),
2233        ),
2234        "tee" => rows.extend(
2235            paths(&values)
2236                .into_iter()
2237                .map(|path| (path, "write".into(), None)),
2238        ),
2239        "cat" | "head" | "tail" | "nl" | "wc" | "source" | "." => rows.extend(
2240            paths(&values)
2241                .into_iter()
2242                .map(|path| (path, "read".into(), None)),
2243        ),
2244        "sed" => {
2245            let in_place = values.iter().any(|value| {
2246                value == "-i" || value.starts_with("-i") || value.starts_with("--in-place")
2247            });
2248            let mut script_seen = false;
2249            for value in &values {
2250                if value.starts_with('-') {
2251                    continue;
2252                }
2253                if !script_seen {
2254                    script_seen = true;
2255                } else if plausible_path_operand(value) {
2256                    rows.push((
2257                        value.clone(),
2258                        if in_place { "write" } else { "read" }.into(),
2259                        None,
2260                    ));
2261                }
2262            }
2263        }
2264        "find" => rows.extend(
2265            values
2266                .iter()
2267                .take_while(|value| !value.starts_with('-') && value.as_str() != "!")
2268                .filter(|value| plausible_path_operand(value))
2269                .cloned()
2270                .map(|path| (path, "read".into(), None)),
2271        ),
2272        "rg" | "grep" | "jq" => {
2273            let mut expression_seen = values.iter().any(|value| value == "--files");
2274            for value in &values {
2275                if value.starts_with('-') {
2276                    continue;
2277                }
2278                if !expression_seen {
2279                    expression_seen = true;
2280                } else if plausible_path_operand(value) {
2281                    rows.push((value.clone(), "read".into(), None));
2282                }
2283            }
2284        }
2285        _ => {}
2286    }
2287    rows
2288}
2289
2290fn destination_path(target: &str, source: &str, multiple: bool) -> String {
2291    if multiple || target.ends_with(['/', '\\']) {
2292        join_path_text(target, path_basename(source))
2293    } else {
2294        normalize_path_text(target)
2295    }
2296}
2297
2298fn normalize_path_text(path: &str) -> String {
2299    path.replace('\\', "/")
2300}
2301
2302fn is_absolute_path_text(path: &str) -> bool {
2303    let path = normalize_path_text(path);
2304    path.starts_with('/')
2305        || path.as_bytes().get(1) == Some(&b':') && path.as_bytes().get(2) == Some(&b'/')
2306}
2307
2308fn join_path_text(base: &str, child: &str) -> String {
2309    let base = normalize_path_text(base);
2310    let child = normalize_path_text(child);
2311    if base.is_empty() || is_absolute_path_text(&child) {
2312        child
2313    } else {
2314        format!(
2315            "{}/{}",
2316            base.trim_end_matches('/'),
2317            child.trim_start_matches('/')
2318        )
2319    }
2320}
2321
2322fn path_basename(path: &str) -> &str {
2323    path.rsplit(['/', '\\']).next().unwrap_or(path)
2324}
2325
2326fn collect_path_fields(
2327    value: &Value,
2328    access: &str,
2329    out: &mut BTreeMap<String, (String, Option<String>)>,
2330) {
2331    match value {
2332        Value::Object(object) => {
2333            for (key, value) in object {
2334                let key = key.to_ascii_lowercase();
2335                if matches!(
2336                    key.as_str(),
2337                    "path" | "file_path" | "filepath" | "notebook_path" | "old_path" | "new_path"
2338                ) && let Some(path) = value.as_str()
2339                {
2340                    let path = clean_path_token(path);
2341                    if !path.is_empty() {
2342                        out.insert(path, (access.to_string(), None));
2343                    }
2344                } else if matches!(key.as_str(), "paths" | "file_paths" | "filepaths")
2345                    && let Some(items) = value.as_array()
2346                {
2347                    // Cursor's ReadLints takes a list rather than a single path.
2348                    // Generic array recursion below never reaches bare strings.
2349                    for item in items.iter().filter_map(Value::as_str) {
2350                        let path = clean_path_token(item);
2351                        if !path.is_empty() {
2352                            out.insert(path, (access.to_string(), None));
2353                        }
2354                    }
2355                } else if value.is_object() || value.is_array() {
2356                    collect_path_fields(value, access, out);
2357                }
2358            }
2359        }
2360        Value::Array(values) => {
2361            for value in values {
2362                collect_path_fields(value, access, out);
2363            }
2364        }
2365        _ => {}
2366    }
2367}
2368
2369fn clean_path_token(value: &str) -> String {
2370    value
2371        .trim()
2372        .trim_matches(['"', '\'', '`', ',', ':'])
2373        .trim_start_matches("file://")
2374        .to_string()
2375}
2376
2377fn strip_heredoc_bodies(command: &str) -> String {
2378    fn delimiters(line: &str) -> Vec<String> {
2379        let bytes = line.as_bytes();
2380        let mut output = Vec::new();
2381        let mut index = 0;
2382        while index + 1 < bytes.len() {
2383            if bytes[index] != b'<' || bytes[index + 1] != b'<' {
2384                index += 1;
2385                continue;
2386            }
2387            index += 2;
2388            if bytes.get(index) == Some(&b'<') {
2389                index += 1;
2390                continue;
2391            }
2392            if bytes.get(index) == Some(&b'-') {
2393                index += 1;
2394            }
2395            while bytes.get(index).is_some_and(u8::is_ascii_whitespace) {
2396                index += 1;
2397            }
2398            let quote = bytes
2399                .get(index)
2400                .copied()
2401                .filter(|value| *value == b'\'' || *value == b'"');
2402            if quote.is_some() {
2403                index += 1;
2404            }
2405            let start = index;
2406            while let Some(value) = bytes.get(index) {
2407                if quote.is_some_and(|quote| *value == quote)
2408                    || (quote.is_none()
2409                        && (value.is_ascii_whitespace() || b";|&><".contains(value)))
2410                {
2411                    break;
2412                }
2413                index += 1;
2414            }
2415            if start < index {
2416                output.push(line[start..index].to_string());
2417            }
2418        }
2419        output
2420    }
2421
2422    let mut pending = VecDeque::<String>::new();
2423    let mut output = Vec::new();
2424    for line in command.lines() {
2425        if let Some(delimiter) = pending.front() {
2426            if line.trim_start_matches('\t').trim_end() == delimiter {
2427                pending.pop_front();
2428            }
2429            continue;
2430        }
2431        output.push(line);
2432        pending.extend(delimiters(line));
2433    }
2434    output.join("\n")
2435}
2436
2437fn is_redirection_token(token: &str) -> bool {
2438    [">", ">>", "&>", "&>>", "<", "<<", "<<<", "<>"].contains(&token)
2439}
2440
2441fn shell_command_index(parts: &[String]) -> Option<usize> {
2442    let mut index = 0;
2443    while index < parts.len() {
2444        let part = parts[index].as_str();
2445        if ["then", "do", "else"].contains(&part)
2446            || part.split_once('=').is_some_and(|(name, _)| {
2447                !name.is_empty()
2448                    && name
2449                        .chars()
2450                        .all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
2451            })
2452        {
2453            index += 1;
2454            continue;
2455        }
2456        if ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(&part) {
2457            index += 1;
2458            while index < parts.len() && parts[index].starts_with('-') {
2459                index += 1;
2460            }
2461            continue;
2462        }
2463        return Some(index);
2464    }
2465    None
2466}
2467
2468fn shell_segments(command: &str) -> Vec<Vec<String>> {
2469    fn flush_word(tokens: &mut Vec<String>, current: &mut String) {
2470        if !current.is_empty() {
2471            tokens.push(std::mem::take(current));
2472        }
2473    }
2474    fn flush_segment(segments: &mut Vec<Vec<String>>, tokens: &mut Vec<String>) {
2475        if !tokens.is_empty() {
2476            segments.push(std::mem::take(tokens));
2477        }
2478    }
2479
2480    let command = strip_heredoc_bodies(command);
2481    let mut segments = Vec::new();
2482    let mut tokens = Vec::new();
2483    let mut current = String::new();
2484    let mut quote = None;
2485    let mut escaped = false;
2486    let mut chars = command.chars().peekable();
2487    while let Some(ch) = chars.next() {
2488        if escaped {
2489            current.push(ch);
2490            escaped = false;
2491        } else if ch == '\\' {
2492            escaped = true;
2493        } else if quote == Some(ch) {
2494            quote = None;
2495        } else if quote.is_some() {
2496            current.push(ch);
2497        } else if ch == '\'' || ch == '"' {
2498            quote = Some(ch);
2499        } else if ch == '#' && current.is_empty() {
2500            for next in chars.by_ref() {
2501                if next == '\n' {
2502                    flush_segment(&mut segments, &mut tokens);
2503                    break;
2504                }
2505            }
2506        } else if ch.is_whitespace() {
2507            flush_word(&mut tokens, &mut current);
2508            if ch == '\n' {
2509                flush_segment(&mut segments, &mut tokens);
2510            }
2511        } else if ch == '&' && chars.peek() == Some(&'>') {
2512            flush_word(&mut tokens, &mut current);
2513            chars.next();
2514            let operator = if chars.peek() == Some(&'>') {
2515                chars.next();
2516                "&>>"
2517            } else {
2518                "&>"
2519            };
2520            tokens.push(operator.into());
2521        } else if matches!(ch, ';' | '|' | '(' | ')') || ch == '&' {
2522            flush_word(&mut tokens, &mut current);
2523            if (ch == '|' || ch == '&') && chars.peek() == Some(&ch) {
2524                chars.next();
2525            }
2526            flush_segment(&mut segments, &mut tokens);
2527        } else if ch == '>' || ch == '<' {
2528            flush_word(&mut tokens, &mut current);
2529            let mut operator = ch.to_string();
2530            while chars.peek() == Some(&ch) && operator.len() < 3 {
2531                operator.push(chars.next().expect("peeked redirection"));
2532            }
2533            tokens.push(operator);
2534        } else {
2535            current.push(ch);
2536        }
2537    }
2538    flush_word(&mut tokens, &mut current);
2539    flush_segment(&mut segments, &mut tokens);
2540    segments
2541}
2542
2543fn codex_token_usage(value: &Value) -> TokenUsage {
2544    let input = json_i64(value, "input_tokens").max(0);
2545    let output = json_i64(value, "output_tokens").max(0);
2546    let cache = json_i64(value, "cached_input_tokens").max(0);
2547    let input = input.saturating_sub(cache);
2548    TokenUsage {
2549        input_tokens: input,
2550        output_tokens: output,
2551        cache_creation_tokens: 0,
2552        cache_read_tokens: cache,
2553        total_tokens: input + output + cache,
2554    }
2555}
2556
2557pub fn codex_total_token_usage(content: &str) -> Option<TokenUsage> {
2558    content.lines().rev().find_map(|line| {
2559        let obj: Value = serde_json::from_str(line).ok()?;
2560        let payload = obj.get("payload")?;
2561        if payload.get("type").and_then(Value::as_str) != Some("token_count") {
2562            return None;
2563        }
2564        payload
2565            .pointer("/info/total_token_usage")
2566            .map(codex_token_usage)
2567    })
2568}
2569
2570fn exact_claude_skill_invocation(name: &str, input: &Value) -> Option<String> {
2571    (name == "Skill")
2572        .then(|| input.get("skill").and_then(Value::as_str))
2573        .flatten()
2574        .map(str::trim)
2575        .filter(|skill| !skill.is_empty())
2576        .map(str::to_string)
2577}
2578
2579fn codex_custom_tool_input(outer_name: &str, raw: &str) -> (String, Value) {
2580    let nested_calls = codex_custom_tool_calls(raw);
2581    let nested_name = if raw.contains("Promise.all") || nested_calls.len() > 1 {
2582        "composite".to_string()
2583    } else {
2584        nested_calls
2585            .first()
2586            .cloned()
2587            .unwrap_or_else(|| outer_name.to_string())
2588    };
2589
2590    let commands = extract_js_string_fields(raw, &["command", "cmd"]);
2591    let paths = extract_js_string_fields(raw, &["file_path", "path"]);
2592    let workdirs = extract_js_string_fields(raw, &["workdir"]);
2593    let mut input = serde_json::Map::new();
2594    if !commands.is_empty() {
2595        input.insert("command".to_string(), Value::String(commands.join("\n")));
2596    } else if !raw.trim().is_empty() {
2597        input.insert("text".to_string(), Value::String(truncate_clean(raw, 600)));
2598    }
2599    if let Some(path) = paths.first() {
2600        input.insert("path".to_string(), Value::String(path.clone()));
2601    }
2602    if let Some(workdir) = workdirs.first() {
2603        input.insert("workdir".to_string(), Value::String(workdir.clone()));
2604    }
2605    for key in ["task_name", "target", "message"] {
2606        if let Some(value) = extract_js_string_fields(raw, &[key]).first() {
2607            input.insert(key.to_string(), Value::String(value.clone()));
2608        }
2609    }
2610    if nested_name == "update_plan" {
2611        let steps = extract_js_string_fields(raw, &["step"]);
2612        let statuses = extract_js_string_fields(raw, &["status"]);
2613        let plan = steps
2614            .into_iter()
2615            .enumerate()
2616            .map(|(index, step)| {
2617                serde_json::json!({
2618                    "step": step,
2619                    "status": statuses.get(index).map(String::as_str).unwrap_or("pending")
2620                })
2621            })
2622            .collect::<Vec<_>>();
2623        input.insert("plan".to_string(), Value::Array(plan));
2624    }
2625    (nested_name, Value::Object(input))
2626}
2627
2628fn codex_custom_tool_calls(raw: &str) -> Vec<String> {
2629    let mut calls = Vec::new();
2630    let mut offset = 0usize;
2631    while let Some(relative) = raw[offset..].find("tools.") {
2632        let start = offset + relative + "tools.".len();
2633        let tail = &raw[start..];
2634        let name = tail
2635            .chars()
2636            .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
2637            .collect::<String>();
2638        let name_len = name.len();
2639        let after_name = tail[name.len()..].trim_start();
2640        if !name.is_empty() && after_name.starts_with('(') {
2641            calls.push(name);
2642        }
2643        offset = if name_len > 0 {
2644            start + name_len
2645        } else {
2646            // "tools." was not followed by an identifier. Step past one
2647            // character rather than one byte, so multibyte text in the
2648            // surrounding source cannot leave offset inside a char.
2649            raw[start..]
2650                .chars()
2651                .next()
2652                .map_or(raw.len(), |ch| start + ch.len_utf8())
2653        };
2654    }
2655    calls
2656}
2657
2658fn extract_js_string_fields(raw: &str, keys: &[&str]) -> Vec<String> {
2659    let mut values = Vec::new();
2660    for key in keys {
2661        let mut offset = 0usize;
2662        while let Some(relative) = raw[offset..].find(key) {
2663            let start = offset + relative;
2664            let before = raw[..start].chars().next_back();
2665            let after = raw[start + key.len()..].chars().next();
2666            if before.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2667                || after.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2668            {
2669                offset = start + key.len();
2670                continue;
2671            }
2672            let tail = &raw[start + key.len()..];
2673            let Some(colon) = tail.find(':').filter(|index| *index <= 4) else {
2674                offset = start + key.len();
2675                continue;
2676            };
2677            let value = tail[colon + 1..].trim_start();
2678            let Some(quote) = value
2679                .chars()
2680                .next()
2681                .filter(|ch| ['\'', '"', '`'].contains(ch))
2682            else {
2683                offset = start + key.len();
2684                continue;
2685            };
2686            if let Some((decoded, consumed)) = parse_js_string(&value[quote.len_utf8()..], quote) {
2687                if !decoded.is_empty() && !values.contains(&decoded) {
2688                    values.push(decoded);
2689                }
2690                offset = start + key.len() + colon + 1 + consumed;
2691            } else {
2692                offset = start + key.len();
2693            }
2694        }
2695    }
2696    values
2697}
2698
2699fn parse_js_string(raw: &str, quote: char) -> Option<(String, usize)> {
2700    let mut decoded = String::new();
2701    let mut escaped = false;
2702    for (index, ch) in raw.char_indices() {
2703        if escaped {
2704            decoded.push(match ch {
2705                'n' => '\n',
2706                'r' => '\r',
2707                't' => '\t',
2708                other => other,
2709            });
2710            escaped = false;
2711        } else if ch == '\\' {
2712            escaped = true;
2713        } else if ch == quote {
2714            return Some((decoded, index + ch.len_utf8() + quote.len_utf8()));
2715        } else {
2716            decoded.push(ch);
2717        }
2718    }
2719    None
2720}
2721
2722fn command_from_tool_input(input: &Value) -> String {
2723    for key in ["cmd", "command", "pattern", "file_path", "path", "text"] {
2724        if let Some(value) = input.get(key).and_then(Value::as_str)
2725            && !value.is_empty()
2726        {
2727            return if key == "pattern" {
2728                format!("search {value}")
2729            } else {
2730                value.to_string()
2731            };
2732        }
2733    }
2734    if input.is_null() {
2735        String::new()
2736    } else {
2737        truncate_clean(&input.to_string(), 300)
2738    }
2739}
2740
2741fn parse_tool_args(value: &Value) -> Value {
2742    if let Some(text) = value.as_str() {
2743        serde_json::from_str(text).unwrap_or_else(|_| serde_json::json!({ "text": text }))
2744    } else {
2745        value.clone()
2746    }
2747}
2748
2749fn status_from_output(output: &str) -> &'static str {
2750    let lowered = output.to_ascii_lowercase();
2751    let exit_codes = explicit_exit_codes(&lowered);
2752    if exit_codes.iter().any(|code| *code != 0) {
2753        return "fail";
2754    }
2755    if !exit_codes.is_empty() {
2756        return "ok";
2757    }
2758    if lowered.contains("\"is_error\":false") || lowered.contains("\"success\":true") {
2759        return "ok";
2760    }
2761    if lowered.contains("\"is_error\":true") || lowered.contains("\"success\":false") {
2762        return "fail";
2763    }
2764    if lowered.lines().any(|line| line.trim() == "script failed") {
2765        return "fail";
2766    }
2767    if lowered
2768        .lines()
2769        .any(|line| line.trim() == "script completed")
2770    {
2771        return "ok";
2772    }
2773    "observed"
2774}
2775
2776fn explicit_exit_codes(output: &str) -> Vec<i32> {
2777    output
2778        .lines()
2779        .filter_map(|line| {
2780            let line = line.trim();
2781            let value = if let Some(rest) = line.strip_prefix("exit code:") {
2782                rest
2783            } else if let Some((_, rest)) = line.split_once("process exited with code") {
2784                rest.strip_prefix(':').unwrap_or(rest)
2785            } else {
2786                return None;
2787            };
2788            let digits = value
2789                .trim_start()
2790                .chars()
2791                .take_while(|ch| ch.is_ascii_digit() || *ch == '-')
2792                .collect::<String>();
2793            digits.parse().ok()
2794        })
2795        .collect()
2796}
2797
2798pub fn tool_category(name: &str, command: &str) -> String {
2799    let n = name.to_ascii_lowercase();
2800    if n.ends_with("exec_command") || n.ends_with("shell_command") || n == "bash" || n == "shell" {
2801        "shell"
2802    } else if [
2803        "apply_patch",
2804        "edit",
2805        "write",
2806        "multiedit",
2807        "notebookedit",
2808        "strreplace",
2809        "delete",
2810    ]
2811    .contains(&n.as_str())
2812    {
2813        "edit"
2814    } else if ["read", "grep", "glob", "ls", "readlints"].contains(&n.as_str()) {
2815        "read"
2816    } else if n.contains("web")
2817        || n.contains("browser")
2818        || n.contains("search")
2819        || command.contains("http")
2820    {
2821        "network"
2822    } else if n.contains("plan") || n.contains("todo") {
2823        "plan"
2824    } else if n.contains("task") || n.contains("agent") {
2825        "subagent"
2826    } else {
2827        "tool"
2828    }
2829    .to_string()
2830}
2831
2832fn command_effect(command: &str) -> String {
2833    let cmd = basename_from_command(command);
2834    let text = command.to_ascii_lowercase();
2835    if ["cargo", "pytest", "npm", "pnpm", "yarn", "go", "make"].contains(&cmd.as_str())
2836        && any_word(&text, &["test", "check", "build", "clippy"])
2837    {
2838        "test"
2839    } else if cmd == "git"
2840        && any_word(
2841            &text,
2842            &["commit", "push", "add", "checkout", "merge", "rebase"],
2843        )
2844    {
2845        "repo"
2846    } else if ["curl", "wget", "ssh", "scp", "git"].contains(&cmd.as_str())
2847        && (any_word(
2848            &text,
2849            &["clone", "fetch", "pull", "push", "curl", "wget", "ssh"],
2850        ) || text.contains("http://")
2851            || text.contains("https://"))
2852    {
2853        "network"
2854    } else if [
2855        "tee", "cp", "mv", "rm", "mkdir", "touch", "python", "python3", "node", "npm",
2856    ]
2857    .contains(&cmd.as_str())
2858        && (text.contains('>')
2859            || text.contains("--write")
2860            || text.contains(" rm ")
2861            || text.contains(" mkdir ")
2862            || text.contains(" touch ")
2863            || text.contains(" cp ")
2864            || text.contains(" mv "))
2865    {
2866        "write"
2867    } else if [
2868        "rg", "grep", "sed", "cat", "head", "tail", "find", "ls", "nl", "wc", "jq", "git",
2869    ]
2870    .contains(&cmd.as_str())
2871    {
2872        "read"
2873    } else if text.contains("http://")
2874        || text.contains("https://")
2875        || text.contains("crates.io")
2876        || text.contains("github.com")
2877    {
2878        "network"
2879    } else {
2880        "process"
2881    }
2882    .to_string()
2883}
2884
2885fn any_word(text: &str, words: &[&str]) -> bool {
2886    text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2887        .any(|part| words.contains(&part))
2888}
2889
2890fn basename_from_command(command: &str) -> String {
2891    let parts = split_shell(command);
2892    let mut idx = 0;
2893    while idx < parts.len()
2894        && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
2895            &Path::new(&parts[idx])
2896                .file_name()
2897                .and_then(|v| v.to_str())
2898                .unwrap_or(""),
2899        )
2900    {
2901        idx += 1;
2902        if idx < parts.len() && parts[idx].starts_with('-') {
2903            idx += 1;
2904        }
2905    }
2906    parts
2907        .get(idx)
2908        .and_then(|part| process_name_from_part(part))
2909        .unwrap_or_else(|| "none".to_string())
2910}
2911
2912pub fn command_process_chain(command: &str) -> Vec<String> {
2913    process_chain_from_parts(&split_shell(command))
2914}
2915
2916fn process_chain_from_parts(parts: &[String]) -> Vec<String> {
2917    if parts.is_empty() {
2918        return Vec::new();
2919    }
2920    let mut idx = 0;
2921    while idx < parts.len()
2922        && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
2923            &Path::new(&parts[idx])
2924                .file_name()
2925                .and_then(|v| v.to_str())
2926                .unwrap_or(""),
2927        )
2928    {
2929        idx += 1;
2930        if idx < parts.len() && parts[idx].starts_with('-') {
2931            idx += 1;
2932        }
2933    }
2934    let Some(proc_name) = parts.get(idx).and_then(|part| process_name_from_part(part)) else {
2935        return Vec::new();
2936    };
2937    let mut chain = vec![proc_name.clone()];
2938    if ["bash", "sh", "zsh"].contains(&proc_name.as_str()) {
2939        for flag_idx in idx + 1..parts.len().saturating_sub(1) {
2940            if ["-c", "-lc", "-cl"].contains(&parts[flag_idx].as_str()) {
2941                chain.extend(command_process_chain(&parts[flag_idx + 1]));
2942                break;
2943            }
2944        }
2945    }
2946    chain
2947}
2948
2949fn process_name_from_part(part: &str) -> Option<String> {
2950    let raw = part.trim_matches(['"', '\'']);
2951    if raw.is_empty() {
2952        return None;
2953    }
2954    let path = Path::new(raw);
2955    let file_name = path.file_name().and_then(|v| v.to_str()).unwrap_or(raw);
2956    let parts = path_component_strings(path);
2957    if looks_like_home_directory(&parts) && parts.len() <= 2 {
2958        return Some("external".to_string());
2959    }
2960    if contains_private_marker(file_name) {
2961        return Some("external".to_string());
2962    }
2963    Some(file_name.to_string())
2964}
2965
2966fn split_shell(command: &str) -> Vec<String> {
2967    let mut parts = Vec::new();
2968    let mut current = String::new();
2969    let mut quote = None;
2970    let mut escaped = false;
2971    for ch in command.chars() {
2972        if escaped {
2973            current.push(ch);
2974            escaped = false;
2975        } else if ch == '\\' {
2976            escaped = true;
2977        } else if quote == Some(ch) {
2978            quote = None;
2979        } else if quote.is_some() {
2980            current.push(ch);
2981        } else if ch == '\'' || ch == '"' {
2982            quote = Some(ch);
2983        } else if ch.is_whitespace() {
2984            if !current.is_empty() {
2985                parts.push(std::mem::take(&mut current));
2986            }
2987        } else {
2988            current.push(ch);
2989        }
2990    }
2991    if !current.is_empty() {
2992        parts.push(current);
2993    }
2994    parts
2995}
2996
2997fn extract_domains(text: &str) -> Vec<String> {
2998    let mut domains = BTreeSet::new();
2999    for part in text.split(|c: char| c.is_whitespace() || ['"', '\'', ')', '('].contains(&c)) {
3000        let stripped = part
3001            .strip_prefix("https://")
3002            .or_else(|| part.strip_prefix("http://"));
3003        if let Some(rest) = stripped
3004            && let Some(domain) = rest.split('/').next()
3005            && !domain.is_empty()
3006        {
3007            domains.insert(domain.to_ascii_lowercase());
3008        }
3009        for known in [
3010            "github.com",
3011            "crates.io",
3012            "huggingface.co",
3013            "hf.co",
3014            "openai.com",
3015            "anthropic.com",
3016        ] {
3017            if part.contains(known) {
3018                domains.insert(known.to_string());
3019            }
3020        }
3021    }
3022    domains.into_iter().collect()
3023}
3024
3025fn extract_path_groups(
3026    project_root: &Path,
3027    name: &str,
3028    input: &Value,
3029    command: &str,
3030) -> Vec<String> {
3031    let mut groups = BTreeSet::new();
3032    if ["write", "edit", "multiedit", "notebookedit", "read"]
3033        .contains(&name.to_ascii_lowercase().as_str())
3034    {
3035        for key in ["file_path", "path"] {
3036            if let Some(path) = input.get(key).and_then(Value::as_str) {
3037                groups.insert(path_group(path, project_root));
3038            }
3039        }
3040    }
3041    for part in split_shell(command) {
3042        if plausible_path_token(&part) {
3043            groups.insert(path_group(&part, project_root));
3044        }
3045    }
3046    groups.into_iter().filter(|v| v != "none").collect()
3047}
3048
3049fn plausible_path_operand(part: &str) -> bool {
3050    let part = part.trim_matches(['"', '\'']);
3051    // A bare number here is a file descriptor: `cat x 2>&1` splits out a lone `2`.
3052    !part.is_empty() && !part.chars().all(|c| c.is_ascii_digit()) && !definitely_not_a_path(part)
3053}
3054
3055fn plausible_path_token(part: &str) -> bool {
3056    let part = part.trim_matches(['"', '\'']);
3057    if definitely_not_a_path(part) {
3058        return false;
3059    }
3060    let suffix = Path::new(part)
3061        .extension()
3062        .and_then(|value| value.to_str())
3063        .unwrap_or("");
3064    part.contains('/')
3065        || [
3066            "rs", "py", "md", "json", "ts", "tsx", "toml", "lock", "js", "c", "h", "svg", "html",
3067            "css",
3068        ]
3069        .contains(&suffix)
3070}
3071
3072fn definitely_not_a_path(part: &str) -> bool {
3073    let part = part.trim_matches(['"', '\'']);
3074    let lower = part.to_ascii_lowercase();
3075    let components = part.split('/').collect::<Vec<_>>();
3076    let looks_like_sed_expression = part.starts_with("s/")
3077        && part.rsplit('/').next().is_some_and(|flags| {
3078            flags.is_empty() || flags.chars().all(|flag| "gimpe".contains(flag))
3079        });
3080    let looks_like_slash_separated_phrase = components.len() >= 3
3081        && components.iter().all(|component| {
3082            component.chars().all(char::is_alphabetic)
3083                && component.chars().next().is_some_and(char::is_uppercase)
3084        });
3085    if part.is_empty()
3086        || part.starts_with('-')
3087        || part.starts_with('$')
3088        || part.starts_with('~')
3089        || part.starts_with("http://")
3090        || part.starts_with("https://")
3091        || lower.starts_with("origin/")
3092        || lower.starts_with("refs/")
3093        || lower.starts_with("repos/")
3094        || part == "HEAD"
3095        || part.starts_with("HEAD.")
3096        || part.contains("...")
3097        || looks_like_slash_separated_phrase
3098        || looks_like_sed_expression
3099        || part.len() > 140
3100        || part.chars().any(char::is_whitespace)
3101        || part.chars().any(|c| "{}()=;<>|`*?[]\"#$,:@^!".contains(c))
3102    {
3103        return true;
3104    }
3105    false
3106}
3107
3108pub fn path_group(path: &str, project_root: &Path) -> String {
3109    let path = path.trim_matches(['"', '\'']);
3110    if path.is_empty() {
3111        return "none".to_string();
3112    }
3113    let p = Path::new(path);
3114    let parts = if p.is_absolute() {
3115        if let Ok(rel) = p.strip_prefix(project_root) {
3116            path_component_strings(rel)
3117        } else {
3118            return external_path_group(path, &path_component_strings(p));
3119        }
3120    } else {
3121        let parts = path_component_strings(p);
3122        if let Some(group) = sensitive_relative_path_group(path, &parts) {
3123            return group;
3124        }
3125        parts
3126    };
3127    collapse_project_path(parts)
3128}
3129
3130pub fn path_component_strings(path: &Path) -> Vec<String> {
3131    path.components()
3132        .filter_map(|c| {
3133            let part = c.as_os_str().to_string_lossy();
3134            let part = part.as_ref();
3135            if part == "." || part == "/" || part.is_empty() {
3136                None
3137            } else {
3138                Some(part.to_string())
3139            }
3140        })
3141        .collect()
3142}
3143
3144pub fn collapse_project_path(parts: Vec<String>) -> String {
3145    let parts = parts
3146        .into_iter()
3147        .filter(|part| part != "." && !part.is_empty())
3148        .map(|part| truncate_path_component(&part))
3149        .collect::<Vec<_>>();
3150    if parts.is_empty() {
3151        "repo".to_string()
3152    } else if [
3153        "collector",
3154        "frontend",
3155        "docs",
3156        "bpf",
3157        "agentpprof",
3158        "agent-session",
3159    ]
3160    .contains(&parts[0].as_str())
3161    {
3162        parts.into_iter().take(3).collect::<Vec<_>>().join("/")
3163    } else {
3164        parts.into_iter().take(2).collect::<Vec<_>>().join("/")
3165    }
3166}
3167
3168fn truncate_path_component(part: &str) -> String {
3169    if part.chars().count() > 48 {
3170        format!("{}...", part.chars().take(45).collect::<String>())
3171    } else {
3172        part.to_string()
3173    }
3174}
3175
3176fn external_path_group(raw: &str, parts: &[String]) -> String {
3177    sensitive_relative_path_group(raw, parts).unwrap_or_else(|| "external/path".to_string())
3178}
3179
3180fn sensitive_relative_path_group(raw: &str, parts: &[String]) -> Option<String> {
3181    let lowered = raw.to_ascii_lowercase();
3182    let lower_parts = parts
3183        .iter()
3184        .map(|part| part.to_ascii_lowercase())
3185        .collect::<Vec<_>>();
3186    if lower_parts.iter().any(|part| part == ".codex") {
3187        Some("external/codex".to_string())
3188    } else if lower_parts.iter().any(|part| part == ".claude") {
3189        Some("external/claude".to_string())
3190    } else if lower_parts.first().is_some_and(|part| part == "tmp")
3191        || lowered.contains("/tmp")
3192        || lowered.contains("_/tmp")
3193        || lower_parts
3194            .windows(2)
3195            .any(|window| window[0] == "var" && window[1] == "tmp")
3196    {
3197        Some("external/tmp".to_string())
3198    } else if lowered.starts_with("~/")
3199        || lowered == "~"
3200        || lowered.contains("/home")
3201        || lowered.contains("_/home")
3202        || lowered.contains("-home-")
3203        || lowered.contains("/users")
3204        || lowered.contains("_/users")
3205        || looks_like_home_directory(&lower_parts)
3206        || contains_private_marker(&lowered)
3207    {
3208        Some("external/home".to_string())
3209    } else {
3210        None
3211    }
3212}
3213
3214pub fn looks_like_home_directory(parts: &[String]) -> bool {
3215    parts
3216        .first()
3217        .is_some_and(|part| part == "home" || part == "users")
3218}
3219
3220fn current_username() -> Option<String> {
3221    dirs::home_dir()
3222        .and_then(|home| {
3223            home.file_name()
3224                .map(|part| part.to_string_lossy().to_string())
3225        })
3226        .filter(|name| !name.is_empty())
3227}
3228
3229pub fn contains_private_marker(text: &str) -> bool {
3230    let lowered = text.to_ascii_lowercase();
3231    current_username()
3232        .map(|name| lowered.contains(&name.to_ascii_lowercase()))
3233        .unwrap_or(false)
3234}
3235
3236fn content_to_text(value: &Value) -> String {
3237    match value {
3238        Value::String(s) => s.clone(),
3239        Value::Array(items) => items
3240            .iter()
3241            .filter_map(|item| {
3242                if let Some(text) = item.as_str() {
3243                    return Some(text.to_string());
3244                }
3245                let typ = item.get("type").and_then(Value::as_str).unwrap_or("");
3246                if typ == "tool_result" || typ == "tool_use" || typ == "function_call" {
3247                    return None;
3248                }
3249                // For thinking blocks, extract the thinking field
3250                if typ == "thinking" {
3251                    return item
3252                        .get("thinking")
3253                        .and_then(Value::as_str)
3254                        .filter(|s| !s.is_empty())
3255                        .map(str::to_string);
3256                }
3257                item.get("text")
3258                    .or_else(|| item.get("content"))
3259                    .and_then(Value::as_str)
3260                    .map(str::to_string)
3261            })
3262            .collect::<Vec<_>>()
3263            .join("\n"),
3264        Value::Object(_) => value
3265            .get("text")
3266            .or_else(|| value.get("content"))
3267            .and_then(Value::as_str)
3268            .unwrap_or("")
3269            .to_string(),
3270        _ => String::new(),
3271    }
3272}
3273
3274fn claude_is_tool_result(content: &Value) -> bool {
3275    content.as_array().is_some_and(|items| {
3276        !items.is_empty()
3277            && items
3278                .iter()
3279                .all(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
3280    })
3281}
3282
3283fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
3284    let session_id = first_json_string(
3285        obj,
3286        &["sessionId", "session_id"],
3287        &["/payload/session_id", "/payload/sessionId"],
3288    );
3289    let conversation_id = first_json_string(
3290        obj,
3291        &["conversation_id", "conversationId", "thread_id", "threadId"],
3292        &[
3293            "/payload/conversation_id",
3294            "/payload/conversationId",
3295            "/payload/thread_id",
3296            "/payload/threadId",
3297        ],
3298    )
3299    .or_else(|| session_id.clone());
3300    (
3301        session_id.or_else(|| conversation_id.clone()),
3302        conversation_id,
3303    )
3304}
3305
3306fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
3307    keys.iter()
3308        .filter_map(|key| obj.get(*key).and_then(Value::as_str))
3309        .chain(
3310            pointers
3311                .iter()
3312                .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
3313        )
3314        .find(|value| !value.is_empty())
3315        .map(str::to_string)
3316}
3317
3318fn claude_usage_key(obj: &Value) -> String {
3319    obj.get("requestId")
3320        .or_else(|| obj.pointer("/message/id"))
3321        .or_else(|| obj.get("uuid"))
3322        .and_then(Value::as_str)
3323        .unwrap_or("usage")
3324        .to_string()
3325}
3326
3327fn claude_source_completion_id(obj: &Value) -> String {
3328    obj.pointer("/message/id")
3329        .or_else(|| obj.get("requestId"))
3330        .or_else(|| obj.get("uuid"))
3331        .and_then(Value::as_str)
3332        .unwrap_or("")
3333        .to_string()
3334}
3335
3336fn claude_user_starts_prompt(
3337    obj: &Value,
3338    content: &Value,
3339    text: &str,
3340    active_prompt_id: Option<&str>,
3341) -> bool {
3342    if obj.get("isMeta").and_then(Value::as_bool) == Some(true)
3343        || obj.get("sourceToolUseID").is_some()
3344        || obj.get("sourceToolAssistantUUID").is_some()
3345        || ["attachment", "attachments", "image", "images"]
3346            .iter()
3347            .any(|key| obj.get(*key).is_some())
3348        || content.as_array().is_some_and(|items| {
3349            !items.is_empty()
3350                && items.iter().all(|item| {
3351                    matches!(
3352                        item.get("type").and_then(Value::as_str),
3353                        Some("attachment" | "document" | "file" | "image")
3354                    )
3355                })
3356        })
3357        || [
3358            "<local-command-caveat>",
3359            "<local-command-stdout>",
3360            "<system-reminder>",
3361            "<ide_opened_file>",
3362            "<ide_selection>",
3363        ]
3364        .iter()
3365        .any(|prefix| text.starts_with(prefix))
3366    {
3367        return false;
3368    }
3369    match obj
3370        .get("promptId")
3371        .and_then(Value::as_str)
3372        .filter(|value| !value.is_empty())
3373    {
3374        Some(prompt_id) => active_prompt_id != Some(prompt_id),
3375        None => active_prompt_id.is_none(),
3376    }
3377}
3378
3379fn local_message_preview(value: &Value) -> Option<String> {
3380    let mut parts = Vec::new();
3381    collect_local_text(value, &mut parts);
3382    clean_prompt_text(&parts.join(" "))
3383}
3384
3385fn collect_local_text(value: &Value, out: &mut Vec<String>) {
3386    match value {
3387        Value::String(text) => out.push(text.clone()),
3388        Value::Array(items) => {
3389            for item in items {
3390                collect_local_text(item, out);
3391            }
3392        }
3393        Value::Object(obj) => {
3394            if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
3395                typ == "tool_use" || typ == "function_call" || typ == "tool_result"
3396            }) {
3397                return;
3398            }
3399            for key in ["text", "content", "message", "input", "prompt"] {
3400                if let Some(value) = obj.get(key) {
3401                    collect_local_text(value, out);
3402                }
3403            }
3404        }
3405        _ => {}
3406    }
3407}
3408
3409fn is_claude_tool_result(obj: &Value) -> bool {
3410    obj.get("toolUseResult").is_some()
3411        || obj.get("tool_use_result").is_some()
3412        || obj
3413            .pointer("/message/content")
3414            .and_then(Value::as_array)
3415            .is_some_and(|items| {
3416                items
3417                    .iter()
3418                    .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
3419            })
3420}
3421
3422fn find_file_arg(value: &Value) -> Option<&str> {
3423    match value {
3424        Value::Object(obj) => {
3425            for key in ["file_path", "path", "filepath"] {
3426                if let Some(path) = obj.get(key).and_then(Value::as_str) {
3427                    return Some(path);
3428                }
3429            }
3430            obj.values().find_map(find_file_arg)
3431        }
3432        Value::Array(items) => items.iter().find_map(find_file_arg),
3433        _ => None,
3434    }
3435}
3436
3437fn is_noise_path(path: &str) -> bool {
3438    const NOISE: &[&str] = &[
3439        "/.claude/",
3440        "/.codex/",
3441        "/.gemini/",
3442        "/.git/",
3443        "/node_modules/",
3444        "/.npm/",
3445        "/.cache/",
3446        "CLAUDE.md",
3447        "AGENTS.md",
3448    ];
3449    NOISE.iter().any(|pat| path.contains(pat))
3450}
3451
3452fn clean_prompt_text(text: &str) -> Option<String> {
3453    let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
3454    let text = text
3455        .strip_prefix("<session>")
3456        .and_then(|text| text.strip_suffix("</session>"))
3457        .unwrap_or(&text)
3458        .trim();
3459    (!text.is_empty()).then(|| text.to_string())
3460}
3461
3462pub fn short_hash(text: &str, n: usize) -> String {
3463    let digest = Sha256::digest(text.as_bytes());
3464    hex::encode(digest).chars().take(n).collect()
3465}
3466
3467pub fn truncate_clean(text: &str, limit: usize) -> String {
3468    let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
3469    if text.chars().count() <= limit {
3470        return text;
3471    }
3472    text.chars()
3473        .take(limit.saturating_sub(1))
3474        .collect::<String>()
3475        + "."
3476}
3477
3478pub fn one_word(text: &str, default: &str) -> String {
3479    let mut cur = String::new();
3480    for ch in text.to_ascii_lowercase().chars() {
3481        if ch.is_ascii_alphanumeric() {
3482            cur.push(ch);
3483        } else if cur.len() >= 2 {
3484            break;
3485        } else {
3486            cur.clear();
3487        }
3488    }
3489    if cur.len() >= 2 {
3490        cur
3491    } else {
3492        default.to_string()
3493    }
3494}
3495
3496fn short_session_id(id: &str) -> String {
3497    let id = id.trim();
3498    if id.is_empty() {
3499        return "session".to_string();
3500    }
3501    let compact = id
3502        .rsplit(['/', '\\'])
3503        .next()
3504        .unwrap_or(id)
3505        .trim_end_matches(".jsonl");
3506    const MAX_SESSION_ID_CHARS: usize = 12;
3507    if compact.chars().count() <= MAX_SESSION_ID_CHARS {
3508        return compact.to_string();
3509    }
3510    let head = compact.chars().take(6).collect::<String>();
3511    let tail = compact
3512        .chars()
3513        .rev()
3514        .take(5)
3515        .collect::<Vec<_>>()
3516        .into_iter()
3517        .rev()
3518        .collect::<String>();
3519    format!("{head}.{tail}")
3520}
3521
3522fn json_i64(value: &Value, key: &str) -> i64 {
3523    value.get(key).and_then(Value::as_i64).unwrap_or(0)
3524}
3525
3526fn json_u64(value: &Value, key: &str) -> u64 {
3527    value.get(key).and_then(Value::as_u64).unwrap_or(0)
3528}
3529
3530fn ts_ms_from_event(value: &Value) -> Option<i64> {
3531    value
3532        .get("timestamp")
3533        .and_then(Value::as_str)
3534        .and_then(parse_ts_ms)
3535}
3536
3537fn parse_ts_ms(value: &str) -> Option<i64> {
3538    chrono::DateTime::parse_from_rfc3339(value)
3539        .ok()
3540        .map(|ts| ts.timestamp_millis())
3541}
3542
3543fn rfc3339_seconds(value: &str) -> Option<f64> {
3544    chrono::DateTime::parse_from_rfc3339(value)
3545        .ok()
3546        .map(|ts| ts.timestamp_millis() as f64 / 1000.0)
3547}
3548
3549fn uuid7_seconds(value: &str) -> Option<f64> {
3550    let mut parts = value.split('-');
3551    let high = parts.next()?;
3552    let low = parts.next()?;
3553    let version = parts.next()?;
3554    if !version.starts_with('7') {
3555        return None;
3556    }
3557    u64::from_str_radix(&format!("{high}{low}"), 16)
3558        .ok()
3559        .map(|milliseconds| milliseconds as f64 / 1000.0)
3560}
3561
3562fn iso_ms(value: &str) -> Option<u64> {
3563    chrono::DateTime::parse_from_rfc3339(value)
3564        .ok()
3565        .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
3566}
3567
3568fn system_time_ms(value: SystemTime) -> u64 {
3569    value
3570        .duration_since(UNIX_EPOCH)
3571        .unwrap_or_default()
3572        .as_millis() as u64
3573}
3574
3575#[cfg(test)]
3576mod tests {
3577    use super::*;
3578    use serde_json::json;
3579    use std::time::UNIX_EPOCH;
3580
3581    // A parent transcript that both delegates and works directly, since Cursor mixes both.
3582    fn cursor_parent_fixture() -> String {
3583        [
3584            // Cursor wraps every user message, parent and child alike.
3585            r#"{"role":"user","message":{"content":[{"type":"text","text":"<timestamp>Friday, Aug 7, 2026, 10:12 PM (UTC-5)</timestamp>\n<user_query>\ncreate hello.py\n</user_query>"}]}}"#,
3586            r#"{"role":"assistant","message":{"content":[{"type":"text","text":"delegating"},{"type":"tool_use","name":"Task","input":{"description":"Create hello.py","prompt":"make it","subagent_type":"generalPurpose"}}]}}"#,
3587            r#"{"type":"turn_ended","status":"success"}"#,
3588            r#"{"role":"user","message":{"content":[{"type":"text","text":"now delete it"}]}}"#,
3589            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Delete","input":{"path":"/repo/hello.py"}},{"type":"text","text":"deleted"}]}}"#,
3590            r#"{"type":"turn_ended","status":"success"}"#,
3591        ]
3592        .join("\n")
3593    }
3594
3595    // The child spawned by the Task call above; all of that turn's real work is here.
3596    fn cursor_subagent_fixture() -> String {
3597        [
3598            // The Task prompt in the child's first message is the only link to the parent.
3599            r#"{"role":"user","message":{"content":[{"type":"text","text":"<timestamp>Friday, Aug 7, 2026, 10:12 PM (UTC-5)</timestamp>\n<user_query>\nmake it\n</user_query>"}]}}"#,
3600            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/hello.py","contents":"print(1)\n"}},{"type":"text","text":"written"}]}}"#,
3601            r#"{"type":"turn_ended","status":"success"}"#,
3602        ]
3603        .join("\n")
3604    }
3605
3606    #[test]
3607    fn cursor_transcript_counts_prompts_and_responses() {
3608        let session = parse_session_content(
3609            AGENT_CURSOR,
3610            &PathBuf::from("/tmp/session.jsonl"),
3611            UNIX_EPOCH,
3612            &cursor_parent_fixture(),
3613        )
3614        .expect("session");
3615
3616        assert_eq!(session.agent_type, AGENT_CURSOR);
3617        assert_eq!(session.events.prompts.len(), 2);
3618        assert_eq!(session.events.llm_responses.len(), 2);
3619        // The <timestamp>/<user_query> wrapper is stripped, so previews show
3620        // what the person typed rather than Cursor's header.
3621        assert_eq!(session.events.prompts[0].preview, "create hello.py");
3622        assert_eq!(session.events.prompts[1].preview, "now delete it");
3623        assert_eq!(session.events.prompts[1].index, 1);
3624        assert_eq!(session.events.llm_responses[1].prompt_index, 1);
3625        assert_eq!(session.prompt_preview.as_deref(), Some("create hello.py"));
3626    }
3627
3628    #[test]
3629    fn cursor_tool_uses_become_events_and_unknown_names_are_kept() {
3630        let content = [
3631            r#"{"role":"user","message":{"content":[{"type":"text","text":"do work"}]}}"#,
3632            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cargo test","description":"run tests"}}]}}"#,
3633            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"StrReplace","input":{"path":"/repo/a.rs","old_string":"x","new_string":"y"}}]}}"#,
3634            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ReadLints","input":{"paths":["/repo/a.rs"]}}]}}"#,
3635            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"SomeToolWeHaveNeverSeen","input":{"whatever":1}}]}}"#,
3636        ]
3637        .join("\n");
3638
3639        let session = parse_session_content(
3640            AGENT_CURSOR,
3641            &PathBuf::from("/tmp/session.jsonl"),
3642            UNIX_EPOCH,
3643            &content,
3644        )
3645        .expect("session");
3646
3647        let named = |name: &str| {
3648            session
3649                .events
3650                .tools
3651                .iter()
3652                .find(|tool| tool.tool_name == name)
3653                .unwrap_or_else(|| panic!("{name} missing"))
3654        };
3655        assert_eq!(session.events.tools.len(), 4);
3656        assert_eq!(named("Shell").category, "shell");
3657        assert_eq!(named("Shell").command, "cargo test");
3658        assert_eq!(named("Shell").command_name, "cargo");
3659        assert_eq!(named("StrReplace").category, "edit");
3660        assert_eq!(named("ReadLints").category, "read");
3661        // An unfamiliar name still produces an event, in the catch-all category.
3662        assert_eq!(named("SomeToolWeHaveNeverSeen").category, "tool");
3663        // Cursor has no tool call ids, and no tool_result records to upgrade a
3664        // call's status, so every Cursor tool event stays "observed".
3665        assert!(named("Shell").call_id.is_none());
3666        assert_eq!(named("Shell").status, "observed");
3667        assert_eq!(session.tools.get("Shell"), Some(&1));
3668    }
3669
3670    #[test]
3671    fn cursor_file_tools_map_to_access_kinds() {
3672        let content = [
3673            r#"{"role":"user","message":{"content":[{"type":"text","text":"work"}]}}"#,
3674            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/a.rs"}}]}}"#,
3675            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/b.rs","contents":"fn main() {}"}}]}}"#,
3676            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"StrReplace","input":{"path":"/repo/c.rs","old_string":"x","new_string":"y"}}]}}"#,
3677            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Delete","input":{"path":"/repo/d.rs"}}]}}"#,
3678            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ReadLints","input":{"paths":["/repo/e.rs","/repo/f.rs"]}}]}}"#,
3679            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{"pattern":"timeout","path":"/repo","-i":true}}]}}"#,
3680        ]
3681        .join("\n");
3682
3683        let session = parse_session_content(
3684            AGENT_CURSOR,
3685            &PathBuf::from("/tmp/session.jsonl"),
3686            UNIX_EPOCH,
3687            &content,
3688        )
3689        .expect("session");
3690
3691        let access_of = |path: &str| {
3692            session
3693                .events
3694                .tools
3695                .iter()
3696                .flat_map(|tool| tool.paths.iter())
3697                .find(|candidate| candidate.path == path)
3698                .unwrap_or_else(|| panic!("{path} missing"))
3699                .access
3700                .clone()
3701        };
3702        assert_eq!(access_of("/repo/a.rs"), "read");
3703        assert_eq!(access_of("/repo/b.rs"), "write");
3704        assert_eq!(access_of("/repo/c.rs"), "write");
3705        assert_eq!(access_of("/repo/d.rs"), "delete");
3706        // ReadLints takes a list, not a single path.
3707        assert_eq!(access_of("/repo/e.rs"), "read");
3708        assert_eq!(access_of("/repo/f.rs"), "read");
3709        // Flag-shaped keys such as "-i" are not paths.
3710        assert_eq!(access_of("/repo"), "read");
3711        assert_eq!(session.files.get("/repo/d.rs"), Some(&1));
3712    }
3713
3714    #[test]
3715    fn cursor_shell_mv_yields_rename_with_previous_path() {
3716        // Rename never arrives as a dedicated Cursor tool. It only ever comes
3717        // through Shell, and Cursor emits it as a compound command with a cd.
3718        let content = [
3719            r#"{"role":"user","message":{"content":[{"type":"text","text":"tidy up"}]}}"#,
3720            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cd /repo && mv hello.py greet.py","description":"rename it"}}]}}"#,
3721            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"rm /repo/stale.txt","description":"drop it"}}]}}"#,
3722        ]
3723        .join("\n");
3724
3725        let session = parse_session_content(
3726            AGENT_CURSOR,
3727            &PathBuf::from("/tmp/session.jsonl"),
3728            UNIX_EPOCH,
3729            &content,
3730        )
3731        .expect("session");
3732
3733        let all: Vec<&ToolPath> = session
3734            .events
3735            .tools
3736            .iter()
3737            .flat_map(|tool| tool.paths.iter())
3738            .collect();
3739        let renamed = all
3740            .iter()
3741            .find(|path| path.access == "rename")
3742            .expect("rename");
3743        assert_eq!(renamed.path, "/repo/greet.py");
3744        assert_eq!(renamed.previous_path.as_deref(), Some("/repo/hello.py"));
3745        assert!(
3746            all.iter()
3747                .any(|path| path.access == "delete" && path.path == "/repo/stale.txt")
3748        );
3749        assert_eq!(session.events.tools[0].category, "shell");
3750        assert_eq!(
3751            session.events.tools[0].command,
3752            "cd /repo && mv hello.py greet.py"
3753        );
3754        // command_name is the first token of a compound command, so `cd` wins here.
3755        assert_eq!(session.events.tools[0].command_name, "cd");
3756        assert!(
3757            !session.events.tools[0].process_chain.is_empty(),
3758            "Shell events must carry a process chain"
3759        );
3760    }
3761
3762    #[test]
3763    fn cursor_shell_working_directory_resolves_relative_paths() {
3764        // Cursor names this working_directory where Claude and Codex use workdir.
3765        let content = [
3766            r#"{"role":"user","message":{"content":[{"type":"text","text":"move it"}]}}"#,
3767            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"mv hello.py archive/greet.py","working_directory":"/repo","description":"move"}}]}}"#,
3768        ]
3769        .join("\n");
3770
3771        let session = parse_session_content(
3772            AGENT_CURSOR,
3773            &PathBuf::from("/tmp/session.jsonl"),
3774            UNIX_EPOCH,
3775            &content,
3776        )
3777        .expect("session");
3778
3779        let renamed = session.events.tools[0]
3780            .paths
3781            .iter()
3782            .find(|path| path.access == "rename")
3783            .expect("rename");
3784        assert_eq!(renamed.path, "/repo/archive/greet.py");
3785        assert_eq!(renamed.previous_path.as_deref(), Some("/repo/hello.py"));
3786    }
3787
3788    #[test]
3789    fn cursor_subagent_work_folds_into_the_delegating_prompt() {
3790        let children = vec![(
3791            PathBuf::from("/tmp/subagents/child.jsonl"),
3792            cursor_subagent_fixture(),
3793        )];
3794        let session = parse_cursor_jsonl(
3795            &PathBuf::from("/tmp/session.jsonl"),
3796            UNIX_EPOCH,
3797            &cursor_parent_fixture(),
3798            &children,
3799        )
3800        .expect("session");
3801
3802        // Counts span parent and children. Reading the parent alone would miss
3803        // the Write entirely, since that turn only issued a Task.
3804        assert_eq!(session.tools.get("Task"), Some(&1));
3805        assert_eq!(session.tools.get("Delete"), Some(&1));
3806        assert_eq!(session.tools.get("Write"), Some(&1));
3807        // Exactly once: two calls in the parent, one in the child, no more.
3808        assert_eq!(session.events.tools.len(), 3);
3809        assert_eq!(session.tools.values().sum::<usize>(), 3);
3810        assert_eq!(session.files.get("/repo/hello.py"), Some(&2));
3811
3812        let tool_at = |name: &str| {
3813            session
3814                .events
3815                .tools
3816                .iter()
3817                .find(|tool| tool.tool_name == name)
3818                .unwrap_or_else(|| panic!("{name} missing"))
3819                .prompt_index
3820        };
3821        // The child ran under prompt 0, which delegated. Without the Task
3822        // prompt match its work would land on prompt 1, the last one seen.
3823        assert_eq!(tool_at("Write"), 0);
3824        assert_eq!(tool_at("Delete"), 1);
3825    }
3826
3827    #[test]
3828    fn cursor_cwd_prefers_working_directory_then_common_path_prefix() {
3829        let with_dir = [
3830            r#"{"role":"user","message":{"content":[{"type":"text","text":"go"}]}}"#,
3831            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"ls","working_directory":"/repo/app"}}]}}"#,
3832        ]
3833        .join("\n");
3834        let session = parse_session_content(
3835            AGENT_CURSOR,
3836            &PathBuf::from("/tmp/session.jsonl"),
3837            UNIX_EPOCH,
3838            &with_dir,
3839        )
3840        .expect("session");
3841        assert_eq!(session.cwd.as_deref(), Some("/repo/app"));
3842
3843        // With no working_directory anywhere, fall back to the directory that
3844        // contains every absolute path the tools touched.
3845        let paths_only = [
3846            r#"{"role":"user","message":{"content":[{"type":"text","text":"go"}]}}"#,
3847            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/app/src/main.rs","contents":"x"}}]}}"#,
3848            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/app/README.md"}}]}}"#,
3849        ]
3850        .join("\n");
3851        let session = parse_session_content(
3852            AGENT_CURSOR,
3853            &PathBuf::from("/tmp/session.jsonl"),
3854            UNIX_EPOCH,
3855            &paths_only,
3856        )
3857        .expect("session");
3858        assert_eq!(session.cwd.as_deref(), Some("/repo/app"));
3859
3860        // Nothing to go on leaves cwd unset rather than guessed. The project
3861        // directory name is a lossy encoding and inverting it invents paths.
3862        let bare = [
3863            r#"{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}"#,
3864            r#"{"role":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}"#,
3865        ]
3866        .join("\n");
3867        let session = parse_session_content(
3868            AGENT_CURSOR,
3869            &PathBuf::from("/tmp/projects/Users-user-cursor-test/agent-transcripts/a/a.jsonl"),
3870            UNIX_EPOCH,
3871            &bare,
3872        )
3873        .expect("session");
3874        assert_eq!(session.cwd, None);
3875    }
3876
3877    #[test]
3878    fn cursor_cwd_preserves_windows_drive_and_unc_roots() {
3879        assert_eq!(
3880            common_parent_dir(&[r"C:\file.rs".to_string()]).as_deref(),
3881            Some("C:/")
3882        );
3883        assert_eq!(
3884            common_parent_dir(&[r"\\server\share\file.rs".to_string()]).as_deref(),
3885            Some("//server/share")
3886        );
3887        assert_eq!(
3888            common_parent_dir(&[
3889                r"C:\repo\src\main.rs".to_string(),
3890                r"C:\repo\README.md".to_string(),
3891            ])
3892            .as_deref(),
3893            Some("C:/repo")
3894        );
3895        assert_eq!(
3896            common_parent_dir(&[
3897                r"\\server\share-a\file.rs".to_string(),
3898                r"\\server\share-b\file.rs".to_string(),
3899            ]),
3900            None
3901        );
3902    }
3903
3904    #[test]
3905    fn cursor_truncated_and_empty_transcripts_degrade_without_error() {
3906        // Cursor appends while a session runs, so the last line can be torn.
3907        // Everything before it must still parse.
3908        let torn = concat!(
3909            r#"{"role":"user","message":{"content":[{"type":"text","text":"start"}]}}"#,
3910            "\n",
3911            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/a.rs"}}]}}"#,
3912            "\n",
3913            r#"{"role":"assistant","message":{"content":[{"type":"tool_"#,
3914        );
3915        let session = parse_session_content(
3916            AGENT_CURSOR,
3917            &PathBuf::from("/tmp/session.jsonl"),
3918            UNIX_EPOCH,
3919            torn,
3920        )
3921        .expect("session");
3922        assert_eq!(session.events.prompts.len(), 1);
3923        assert_eq!(session.tools.get("Read"), Some(&1));
3924
3925        // A single record with no tool calls is still a real session and comes
3926        // back valid, just with nothing in it.
3927        let one_prompt =
3928            r#"{"role":"user","message":{"content":[{"type":"text","text":"just asking"}]}}"#;
3929        let session = parse_session_content(
3930            AGENT_CURSOR,
3931            &PathBuf::from("/tmp/session.jsonl"),
3932            UNIX_EPOCH,
3933            one_prompt,
3934        )
3935        .expect("a lone prompt is still a session");
3936        assert!(session.events.tools.is_empty());
3937        assert_eq!(session.prompt_preview.as_deref(), Some("just asking"));
3938
3939        // A fragment with no user message at all is a different thing: nothing
3940        // was asked, so there is no session to report. Same treatment every
3941        // other agent gets, and it keeps blank rows out of `top`.
3942        for empty in [
3943            "",
3944            "\n\n",
3945            r#"{"type":"turn_ended","status":"error","error":"aborted"}"#,
3946            "not json at all",
3947        ] {
3948            assert!(
3949                parse_session_content(
3950                    AGENT_CURSOR,
3951                    &PathBuf::from("/tmp/session.jsonl"),
3952                    UNIX_EPOCH,
3953                    empty,
3954                )
3955                .is_none(),
3956                "expected no session for {empty:?}"
3957            );
3958        }
3959
3960        // A child whose Task prompt no longer matches still contributes its
3961        // work, attributed to the last prompt rather than dropped.
3962        let orphan = vec![(
3963            PathBuf::from("/tmp/subagents/orphan.jsonl"),
3964            [
3965                r#"{"role":"user","message":{"content":[{"type":"text","text":"unrelated wording"}]}}"#,
3966                r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"/repo/z.rs","contents":"x"}}]}}"#,
3967            ]
3968            .join("\n"),
3969        )];
3970        let session = parse_cursor_jsonl(
3971            &PathBuf::from("/tmp/session.jsonl"),
3972            UNIX_EPOCH,
3973            &cursor_parent_fixture(),
3974            &orphan,
3975        )
3976        .expect("session");
3977        assert_eq!(session.tools.get("Write"), Some(&1));
3978    }
3979
3980    #[test]
3981    fn cursor_failed_turn_marks_its_tool_calls() {
3982        let content = [
3983            r#"{"role":"user","message":{"content":[{"type":"text","text":"first"}]}}"#,
3984            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/ok.rs"}}]}}"#,
3985            r#"{"type":"turn_ended","status":"success"}"#,
3986            r#"{"role":"user","message":{"content":[{"type":"text","text":"second"}]}}"#,
3987            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"cat /repo/missing.rs"}}]}}"#,
3988            r#"{"type":"turn_ended","status":"error","error":"command failed"}"#,
3989        ]
3990        .join("\n");
3991
3992        let session = parse_session_content(
3993            AGENT_CURSOR,
3994            &PathBuf::from("/tmp/session.jsonl"),
3995            UNIX_EPOCH,
3996            &content,
3997        )
3998        .expect("session");
3999
4000        let status_of = |name: &str| {
4001            session
4002                .events
4003                .tools
4004                .iter()
4005                .find(|tool| tool.tool_name == name)
4006                .unwrap_or_else(|| panic!("{name} missing"))
4007                .status
4008                .clone()
4009        };
4010        // Cursor has no per-tool results, so a failed turn is the only outcome
4011        // signal there is, and it applies to that turn's calls only.
4012        assert_eq!(status_of("Read"), "observed");
4013        assert_eq!(status_of("Shell"), "fail");
4014    }
4015
4016    #[test]
4017    fn cursor_wrapper_timestamp_becomes_the_event_clock() {
4018        // Cursor writes no timestamp fields anywhere. The wrapper on each user
4019        // message is the only clock, and without it every consumer that
4020        // requires ts_ms drops all Cursor events. agentvis is one of those.
4021        let content = [
4022            r#"{"role":"user","message":{"content":[{"type":"text","text":"<timestamp>Friday, Aug 7, 2026, 10:12 PM (UTC-5)</timestamp>\n<user_query>\ngo\n</user_query>"}]}}"#,
4023            r#"{"role":"assistant","message":{"content":[{"type":"text","text":"reading it"},{"type":"tool_use","name":"Read","input":{"path":"/repo/a.rs"}}]}}"#,
4024        ]
4025        .join("\n");
4026
4027        let session = parse_session_content(
4028            AGENT_CURSOR,
4029            &PathBuf::from("/tmp/session.jsonl"),
4030            UNIX_EPOCH,
4031            &content,
4032        )
4033        .expect("session");
4034
4035        // 10:12 PM at UTC-5 is 03:12 UTC the next day. Cross-checked against
4036        // state.vscdb, where the matching bubble records 03:11:58.512Z, so the
4037        // value is correct and simply rounded to the minute.
4038        const EXPECTED_MS: i64 = 1_786_158_720_000;
4039        assert_eq!(session.events.prompts[0].ts_ms, Some(EXPECTED_MS));
4040        assert_eq!(session.events.tools[0].ts_ms, Some(EXPECTED_MS));
4041        assert_eq!(session.events.llm_responses[0].ts_ms, Some(EXPECTED_MS));
4042
4043        // A message with no wrapper leaves the clock unset rather than guessing.
4044        let bare = [
4045            r#"{"role":"user","message":{"content":[{"type":"text","text":"no wrapper here"}]}}"#,
4046            r#"{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"path":"/repo/b.rs"}}]}}"#,
4047        ]
4048        .join("\n");
4049        let session = parse_session_content(
4050            AGENT_CURSOR,
4051            &PathBuf::from("/tmp/session.jsonl"),
4052            UNIX_EPOCH,
4053            &bare,
4054        )
4055        .expect("session");
4056        assert_eq!(session.events.tools[0].ts_ms, None);
4057    }
4058
4059    #[test]
4060    fn cursor_subagent_prompts_are_not_user_prompts() {
4061        let children = vec![(
4062            PathBuf::from("/tmp/subagents/child.jsonl"),
4063            cursor_subagent_fixture(),
4064        )];
4065        let session = parse_cursor_jsonl(
4066            &PathBuf::from("/tmp/session.jsonl"),
4067            UNIX_EPOCH,
4068            &cursor_parent_fixture(),
4069            &children,
4070        )
4071        .expect("session");
4072
4073        // The child's own "user" record holds the Task prompt Cursor generated,
4074        // so folding it in must not invent a third human prompt.
4075        assert_eq!(session.events.prompts.len(), 2);
4076        assert_eq!(session.events.llm_responses.len(), 3);
4077    }
4078
4079    #[test]
4080    fn cursor_paths_classify_but_only_parents_discover() {
4081        let home = PathBuf::from("/home/dev");
4082        let parent = home.join(".cursor/projects/repo/agent-transcripts/abc/abc.jsonl");
4083        let subagent = home.join(".cursor/projects/repo/agent-transcripts/abc/subagents/def.jsonl");
4084        let vendored = home.join(".cursor/projects/repo/canvases/node_modules/pkg/data.jsonl");
4085
4086        // Classification accepts any transcript path: process matching hands
4087        // it arbitrary fd paths, children included.
4088        assert_eq!(agent_source_for_path(&parent), Some(AGENT_CURSOR));
4089        assert_eq!(agent_source_for_path(&subagent), Some(AGENT_CURSOR));
4090        assert_eq!(agent_source_for_path(&vendored), None);
4091
4092        // Discovery emits parents only: stem must equal the directory name.
4093        assert!(is_agent_file_for(AGENT_CURSOR, &parent));
4094        assert!(!is_agent_file_for(AGENT_CURSOR, &subagent));
4095        assert!(!is_agent_file_for(AGENT_CURSOR, &vendored));
4096        assert!(!is_agent_file_for(
4097            AGENT_CURSOR,
4098            &home.join(".cursor/projects/repo/agent-transcripts/abc/other.jsonl")
4099        ));
4100
4101        assert!(cursor_is_empty_window(&home.join(
4102            ".cursor/projects/empty-window/agent-transcripts/abc/abc.jsonl"
4103        )));
4104        assert!(!cursor_is_empty_window(&parent));
4105
4106        let fixture = fixture_session_path(AGENT_CURSOR, &home).expect("fixture");
4107        assert!(is_agent_file_for(AGENT_CURSOR, &fixture));
4108    }
4109
4110    #[test]
4111    fn native_windows_session_paths_classify() {
4112        assert_eq!(
4113            agent_source_for_path(Path::new(
4114                r"C:\Users\dev\.codex\sessions\2026\08\12\session.jsonl"
4115            )),
4116            Some(AGENT_CODEX)
4117        );
4118        assert_eq!(
4119            agent_source_for_path(Path::new(
4120                r"C:\Users\dev\.claude\projects\repo\session.jsonl"
4121            )),
4122            Some(AGENT_CLAUDE)
4123        );
4124        assert_eq!(
4125            agent_source_for_path(Path::new(
4126                r"C:\Users\dev\.cursor\projects\repo\agent-transcripts\id\id.jsonl"
4127            )),
4128            Some(AGENT_CURSOR)
4129        );
4130        let gemini =
4131            Path::new(r"C:\Users\dev\.gemini\tmp\repo\chats\session-2026-08-12T00-00-id.json");
4132        assert_eq!(agent_source_for_path(gemini), Some(AGENT_GEMINI));
4133        assert!(is_agent_file_for(AGENT_GEMINI, gemini));
4134    }
4135
4136    #[test]
4137    fn local_session_ids_keep_distinct_conversation_id() {
4138        assert_eq!(
4139            local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
4140            (Some("run".to_string()), Some("conv".to_string()))
4141        );
4142        assert_eq!(
4143            local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
4144            (Some("thread".to_string()), Some("thread".to_string()))
4145        );
4146        assert_eq!(
4147            local_session_ids(&json!({"payload": {"model": "gpt"}})),
4148            (None, None)
4149        );
4150    }
4151
4152    #[test]
4153    fn agent_jsonl_events_share_one_ir() {
4154        let codex = concat!(
4155            r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
4156            "\n",
4157            r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
4158            "\n",
4159            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4160            "\n",
4161            r#"{"type":"event_msg","payload":{"type":"agent_message","message":"tests passed"}}"#,
4162            "\n",
4163            r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}}"#,
4164        );
4165        let claude = concat!(
4166            r#"{"type":"user","message":{"content":"check build"}}"#,
4167            "\n",
4168            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}}}"#,
4169        );
4170
4171        for (agent, content, tool, model, tokens) in [
4172            (AGENT_CODEX, codex, "exec_command", "gpt-5", 15),
4173            (AGENT_CLAUDE, claude, "Bash", "claude-opus", 12),
4174        ] {
4175            let session = parse_session_content(
4176                agent,
4177                &PathBuf::from("/tmp/session.jsonl"),
4178                UNIX_EPOCH,
4179                content,
4180            )
4181            .expect("session");
4182            assert_eq!(session.events.tools[0].tool_name, tool);
4183            assert_eq!(session.events.tools[0].category, "shell");
4184            assert_eq!(session.events.llm_responses[0].model, model);
4185            let usage = &session.events.llm_responses[0];
4186            let total = usage
4187                .total_tokens
4188                .max(usage.input_tokens + usage.output_tokens + usage.cache_tokens);
4189            assert_eq!(total, tokens);
4190        }
4191    }
4192
4193    #[test]
4194    fn claude_exact_skill_calls_create_prompt_bounded_latest_wins_scopes() {
4195        let claude = [
4196            r#"{"type":"system","skill_listing":["availability only"]}"#,
4197            r#"{"type":"user","message":{"content":"review the paper"}}"#,
4198            r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"s1","name":"Skill","input":{"skill":"check-paper-citations","args":""}}],"usage":{"input_tokens":10,"output_tokens":1}}}"#,
4199            r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"cmd":"rg citation paper.tex"}}],"usage":{"input_tokens":20,"output_tokens":2}}}"#,
4200            r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"s2","name":"Skill","input":{"skill":"iter-refine-writing","args":""}}],"usage":{"input_tokens":30,"output_tokens":3}}}"#,
4201            r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"paper.tex"}}],"usage":{"input_tokens":40,"output_tokens":4}}}"#,
4202            r#"{"type":"user","message":{"content":"now summarize"}}"#,
4203            r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"text","text":"summary"}],"usage":{"input_tokens":50,"output_tokens":5}}}"#,
4204        ]
4205        .join("\n");
4206
4207        let session = parse_session_content(
4208            AGENT_CLAUDE,
4209            &PathBuf::from("/tmp/session.jsonl"),
4210            UNIX_EPOCH,
4211            &claude,
4212        )
4213        .expect("session");
4214
4215        assert_eq!(
4216            session
4217                .events
4218                .tools
4219                .iter()
4220                .map(|tool| (tool.tool_name.as_str(), tool.skill.as_str()))
4221                .collect::<Vec<_>>(),
4222            [
4223                ("Skill", "check-paper-citations"),
4224                ("Bash", "check-paper-citations"),
4225                ("Skill", "iter-refine-writing"),
4226                ("Read", "iter-refine-writing"),
4227            ]
4228        );
4229        assert_eq!(
4230            session
4231                .events
4232                .tools
4233                .iter()
4234                .map(|tool| tool.invoked_skill.as_str())
4235                .collect::<Vec<_>>(),
4236            ["check-paper-citations", "", "iter-refine-writing", ""]
4237        );
4238        assert_eq!(
4239            session
4240                .events
4241                .llm_responses
4242                .iter()
4243                .map(|response| response.skill.as_str())
4244                .collect::<Vec<_>>(),
4245            [
4246                "",
4247                "check-paper-citations",
4248                "check-paper-citations",
4249                "iter-refine-writing",
4250                "",
4251            ]
4252        );
4253    }
4254
4255    #[test]
4256    fn codex_source_controls_build_sparse_semantic_task_paths() {
4257        let codex = concat!(
4258            r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"write a paper"}]}}"#,
4259            "\n",
4260            r#"{"type":"response_item","payload":{"type":"function_call","name":"update_plan","call_id":"p1","arguments":"{\"plan\":[{\"step\":\"write abstract\",\"status\":\"in_progress\"}]}"}}"#,
4261            "\n",
4262            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"sed -n 1,80p paper.tex\"}"}}"#,
4263            "\n",
4264            r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"Process exited with code 0\n0 tests failed"}}"#,
4265            "\n",
4266            r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"继续"}]}}"#,
4267            "\n",
4268            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c2","arguments":"{\"cmd\":\"rg error paper.tex\"}"}}"#,
4269            "\n",
4270            r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c2","output":"review error handling documentation"}}"#,
4271        );
4272
4273        let session = parse_session_content(
4274            AGENT_CODEX,
4275            &PathBuf::from("/tmp/session.jsonl"),
4276            UNIX_EPOCH,
4277            codex,
4278        )
4279        .expect("session");
4280
4281        assert_eq!(session.events.prompts.len(), 2);
4282        assert!(session.events.llm_responses.is_empty());
4283        assert_eq!(session.events.tools[0].task_path, vec!["write a paper"]);
4284        assert_eq!(
4285            session.events.tools[1].task_path,
4286            vec!["write a paper", "write abstract"]
4287        );
4288        assert_eq!(
4289            session.events.tools[2].task_path,
4290            session.events.tools[1].task_path
4291        );
4292        assert_eq!(session.events.tools[1].status, "ok");
4293        assert_eq!(session.events.tools[2].status, "observed");
4294    }
4295
4296    #[test]
4297    fn codex_custom_exec_is_a_real_source_tool_event() {
4298        let codex = [
4299            json!({
4300                "timestamp": "2026-07-21T00:00:00.000Z",
4301                "type": "response_item",
4302                "payload": {
4303                    "type": "message",
4304                    "role": "user",
4305                    "content": [{"type": "input_text", "text": "test the parser"}]
4306                }
4307            }),
4308            json!({
4309                "timestamp": "2026-07-21T00:00:01.000Z",
4310                "type": "response_item",
4311                "payload": {
4312                    "type": "custom_tool_call",
4313                    "name": "exec",
4314                    "call_id": "custom-1",
4315                    "input": "const r = await tools.shell_command({command:\"cargo test\",workdir:\"/repo\"}); text(r);"
4316                }
4317            }),
4318            json!({
4319                "timestamp": "2026-07-21T00:00:02.000Z",
4320                "type": "response_item",
4321                "payload": {
4322                    "type": "custom_tool_call_output",
4323                    "call_id": "custom-1",
4324                    "output": [{"type": "input_text", "text": "Script completed\nExit code: 0\nOutput:\nall tests passed"}]
4325                }
4326            }),
4327        ]
4328        .into_iter()
4329        .map(|line| line.to_string())
4330        .collect::<Vec<_>>()
4331        .join("\n");
4332
4333        let session = parse_session_content(
4334            AGENT_CODEX,
4335            &PathBuf::from("/tmp/session.jsonl"),
4336            UNIX_EPOCH,
4337            &codex,
4338        )
4339        .expect("session");
4340
4341        assert_eq!(session.events.tools.len(), 1);
4342        let event = &session.events.tools[0];
4343        assert_eq!(event.tool_name, "shell_command");
4344        assert_eq!(event.category, "shell");
4345        assert_eq!(event.effect, "test");
4346        assert_eq!(event.command, "cargo test");
4347        assert_eq!(event.status, "ok");
4348        assert_eq!(event.task_path, vec!["test the parser"]);
4349    }
4350
4351    #[test]
4352    fn custom_update_plan_changes_only_later_operation_paths() {
4353        let codex = [
4354            json!({
4355                "timestamp": "2026-07-21T00:00:00.000Z",
4356                "type": "response_item",
4357                "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "write a paper"}]}
4358            }),
4359            json!({
4360                "timestamp": "2026-07-21T00:00:01.000Z",
4361                "type": "response_item",
4362                "payload": {
4363                    "type": "custom_tool_call",
4364                    "name": "exec",
4365                    "call_id": "plan-1",
4366                    "input": "const r = await tools.update_plan({plan:[{step:\"write abstract\",status:\"in_progress\"},{step:\"write evaluation\",status:\"pending\"}]}); text(r);"
4367                }
4368            }),
4369            json!({
4370                "timestamp": "2026-07-21T00:00:02.000Z",
4371                "type": "response_item",
4372                "payload": {
4373                    "type": "custom_tool_call",
4374                    "name": "exec",
4375                    "call_id": "shell-1",
4376                    "input": "const r = await tools.shell_command({command:\"sed -n 1,80p paper.tex\",workdir:\"/repo\"}); text(r);"
4377                }
4378            }),
4379        ]
4380        .into_iter()
4381        .map(|line| line.to_string())
4382        .collect::<Vec<_>>()
4383        .join("\n");
4384        let session = parse_session_content(
4385            AGENT_CODEX,
4386            &PathBuf::from("/tmp/session.jsonl"),
4387            UNIX_EPOCH,
4388            &codex,
4389        )
4390        .expect("session");
4391
4392        assert_eq!(session.events.tools.len(), 2);
4393        assert_eq!(session.events.tools[0].tool_name, "update_plan");
4394        assert_eq!(session.events.tools[0].task_path, vec!["write a paper"]);
4395        assert_eq!(
4396            session.events.tools[1].task_path,
4397            vec!["write a paper", "write abstract"]
4398        );
4399    }
4400
4401    #[test]
4402    fn prompt_dedup_is_local_and_continuations_keep_the_current_task() {
4403        let codex = [
4404            ("2026-07-21T00:00:00.000Z", "write a paper"),
4405            ("2026-07-21T00:00:00.500Z", "write a paper"),
4406            ("2026-07-21T00:00:03.000Z", "write a paper"),
4407            ("2026-07-21T00:00:06.000Z", "继续"),
4408        ]
4409        .into_iter()
4410        .map(|(timestamp, text)| {
4411            json!({
4412                "timestamp": timestamp,
4413                "type": "response_item",
4414                "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": text}]}
4415            })
4416            .to_string()
4417        })
4418        .collect::<Vec<_>>()
4419        .join("\n");
4420        let session = parse_session_content(
4421            AGENT_CODEX,
4422            &PathBuf::from("/tmp/session.jsonl"),
4423            UNIX_EPOCH,
4424            &codex,
4425        )
4426        .expect("session");
4427
4428        assert_eq!(session.events.prompts.len(), 3);
4429        assert_eq!(session.events.prompts[2].preview, "继续");
4430        assert_eq!(session.events.prompts[2].task_path, vec!["write a paper"]);
4431    }
4432
4433    #[test]
4434    fn developer_messages_are_not_agent_responses() {
4435        let codex = concat!(
4436            r#"{"timestamp":"2026-07-21T00:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review"}]}}"#,
4437            "\n",
4438            r#"{"timestamp":"2026-07-21T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"internal instruction"}]}}"#,
4439            "\n",
4440            r#"{"timestamp":"2026-07-21T00:00:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"review complete"}]}}"#,
4441        );
4442        let session = parse_session_content(
4443            AGENT_CODEX,
4444            &PathBuf::from("/tmp/session.jsonl"),
4445            UNIX_EPOCH,
4446            codex,
4447        )
4448        .expect("session");
4449        assert_eq!(session.events.llm_responses.len(), 1);
4450        assert_eq!(session.events.llm_responses[0].preview, "review complete");
4451    }
4452
4453    #[test]
4454    fn mixed_batch_exit_codes_fail_if_any_command_failed() {
4455        assert_eq!(
4456            status_from_output("Script completed\nExit code: 0\nExit code: 7"),
4457            "fail"
4458        );
4459        assert_eq!(
4460            status_from_output(
4461                "Process exited with code 0\nProcess exited with code 0\n0 tests failed"
4462            ),
4463            "ok"
4464        );
4465    }
4466
4467    #[test]
4468    fn codex_preserves_commentary_and_final_response_phases() {
4469        let codex = concat!(
4470            r#"{"timestamp":"2026-07-21T00:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review the code"}]}}"#,
4471            "\n",
4472            r#"{"timestamp":"2026-07-21T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"I am checking it"}]}}"#,
4473            "\n",
4474            r#"{"timestamp":"2026-07-21T00:00:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"The code is correct"}]}}"#,
4475        );
4476        let session = parse_session_content(
4477            AGENT_CODEX,
4478            &PathBuf::from("/tmp/session.jsonl"),
4479            UNIX_EPOCH,
4480            codex,
4481        )
4482        .expect("session");
4483
4484        assert_eq!(session.events.llm_responses.len(), 2);
4485        assert_eq!(session.events.llm_responses[0].response_phase, "commentary");
4486        assert_eq!(
4487            session.events.llm_responses[1].response_phase,
4488            "final_answer"
4489        );
4490    }
4491
4492    #[test]
4493    fn semantic_task_label_prefers_explicit_goal_payload() {
4494        let raw = "prefix <objective>write a paper and evaluate it</objective> suffix";
4495        assert_eq!(semantic_task_label(raw), "write a paper and evaluate it");
4496    }
4497
4498    #[test]
4499    fn codex_fork_excludes_copied_parent_history_before_ownership_boundary() {
4500        let codex = concat!(
4501            r#"{"timestamp":"1970-01-01T00:00:01Z","type":"session_meta","payload":{"id":"child","session_id":"parent","parent_thread_id":"parent","timestamp":"1970-01-01T00:00:01Z","cwd":"/repo"}}"#,
4502            "\n",
4503            r#"{"type":"event_msg","payload":{"type":"user_message","message":"copied parent task"}}"#,
4504            "\n",
4505            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"copied","arguments":"{\"cmd\":\"false\"}"}}"#,
4506            "\n",
4507            r#"{"type":"event_msg","payload":{"type":"task_started","started_at":2.0}}"#,
4508            "\n",
4509            r#"{"type":"event_msg","payload":{"type":"user_message","message":"review child result"}}"#,
4510            "\n",
4511            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"owned","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4512        );
4513
4514        let session = parse_session_content(
4515            AGENT_CODEX,
4516            &PathBuf::from("/tmp/child.jsonl"),
4517            UNIX_EPOCH,
4518            codex,
4519        )
4520        .expect("child session");
4521
4522        assert_eq!(session.session_id, "child");
4523        assert_eq!(session.conversation_id.as_deref(), Some("parent"));
4524        assert_eq!(session.events.prompts.len(), 1);
4525        assert_eq!(session.events.prompts[0].preview, "review child result");
4526        assert_eq!(session.events.tools.len(), 1);
4527        assert_eq!(session.events.tools[0].call_id.as_deref(), Some("owned"));
4528    }
4529
4530    #[test]
4531    fn file_actions_ignore_patch_and_heredoc_bodies() {
4532        let patch = tool_event_from_input(
4533            Some("/repo"),
4534            Some(1),
4535            0,
4536            "exec",
4537            &json!({"text": r#"const patch = "*** Begin Patch\n*** Update File: src/lib.rs\n+#!/bin/sh\n+docs/not-a-file.md\n*** End Patch"; tools.apply_patch(patch)"#}),
4538            None,
4539            Vec::new(),
4540        );
4541        assert_eq!(
4542            patch.paths,
4543            vec![ToolPath {
4544                path: "src/lib.rs".into(),
4545                access: "write".into(),
4546                previous_path: None,
4547            }]
4548        );
4549
4550        let heredoc = tool_event_from_input(
4551            Some("/repo"),
4552            Some(1),
4553            0,
4554            "exec_command",
4555            &json!({"cmd": "cat <<'EOF'\n#!/bin/sh\nsrc/not-a-file.rs\nEOF\ncat src/real.rs"}),
4556            None,
4557            Vec::new(),
4558        );
4559        assert_eq!(heredoc.paths.len(), 1);
4560        assert_eq!(heredoc.paths[0].path, "src/real.rs");
4561    }
4562
4563    #[test]
4564    fn shell_path_operands_are_not_limited_to_known_extensions() {
4565        let paths_of = |command: &str| {
4566            shell_file_actions(command, &json!({"cwd": "/repo"}), 0)
4567                .into_iter()
4568                .map(|(path, access, _)| (path, access))
4569                .collect::<Vec<_>>()
4570        };
4571
4572        // A path operand is a path whatever it is called. Before this, only the
4573        // fourteen extensions the list happened to name were recorded, so a Go,
4574        // shell or SQL project got no file activity from its shell commands.
4575        for (command, expected, access) in [
4576            ("rm build.sh", "/repo/build.sh", "delete"),
4577            ("rm main.go", "/repo/main.go", "delete"),
4578            ("rm Dockerfile", "/repo/Dockerfile", "delete"),
4579            ("mv notes.txt archive.txt", "/repo/archive.txt", "rename"),
4580            (
4581                "mv conf.yaml conf.bak.yaml",
4582                "/repo/conf.bak.yaml",
4583                "rename",
4584            ),
4585            ("touch schema.sql", "/repo/schema.sql", "create"),
4586        ] {
4587            assert!(
4588                paths_of(command)
4589                    .iter()
4590                    .any(|(path, kind)| path == expected && kind == access),
4591                "{command} should record {expected} as {access}, got {:?}",
4592                paths_of(command)
4593            );
4594        }
4595
4596        // The shared rejections still hold, so refs, ranges, globs, URLs and
4597        // sed expressions do not become files.
4598        for command in [
4599            "rm origin/main",
4600            "rm HEAD",
4601            "rm *.log",
4602            "rm https://example.com/x",
4603            "rm s/foo/bar/g",
4604            "rm $TARGET",
4605            "rm -rf",
4606        ] {
4607            assert!(
4608                paths_of(command).is_empty(),
4609                "{command} should record nothing, got {:?}",
4610                paths_of(command)
4611            );
4612        }
4613
4614        // A redirected command splits into a bare file descriptor. Without the
4615        // numeric guard, every `2>&1` recorded a read of a file called "2".
4616        let redirected = paths_of("cat notes.txt 2>&1");
4617        assert!(
4618            redirected.iter().any(|(path, _)| path == "/repo/notes.txt"),
4619            "the real file should still be recorded, got {redirected:?}"
4620        );
4621        assert!(
4622            !redirected.iter().any(|(path, _)| path.ends_with("/2")),
4623            "a file descriptor is not a file, got {redirected:?}"
4624        );
4625
4626        for command in ["rm 2", "cat 1"] {
4627            assert!(
4628                paths_of(command).is_empty(),
4629                "{command} should record nothing, got {:?}",
4630                paths_of(command)
4631            );
4632        }
4633    }
4634
4635    #[test]
4636    fn scanned_command_tokens_still_need_evidence_of_being_a_path() {
4637        // extract_path_groups walks every token of a command rather than a known
4638        // path position, so there the extension check still earns its place. A
4639        // bare hostname or version must not become a file.
4640        let event = tool_event_from_input(
4641            Some("/repo"),
4642            Some(1),
4643            0,
4644            "exec_command",
4645            &json!({"cmd": "curl example.com && echo 1.2.3"}),
4646            None,
4647            Vec::new(),
4648        );
4649        assert!(
4650            event.path_groups.is_empty(),
4651            "hostname and version should not become path groups, got {:?}",
4652            event.path_groups
4653        );
4654    }
4655
4656    #[test]
4657    fn file_actions_are_conservative_for_unknown_and_write_tools() {
4658        let unknown = tool_event_from_input(
4659            Some("/repo"),
4660            Some(1),
4661            0,
4662            "mcp_resource",
4663            &json!({"path": "src/not-a-file.rs"}),
4664            None,
4665            Vec::new(),
4666        );
4667        assert!(unknown.paths.is_empty());
4668
4669        let write = tool_event_from_input(
4670            Some("/repo"),
4671            Some(1),
4672            0,
4673            "Write",
4674            &json!({"file_path": "src/existing.rs", "content": "changed"}),
4675            None,
4676            Vec::new(),
4677        );
4678        assert_eq!(write.paths[0].access, "write");
4679    }
4680
4681    #[test]
4682    fn patch_move_keeps_the_immediately_preceding_source() {
4683        let event = tool_event_from_input(
4684            Some("/repo"),
4685            Some(1),
4686            0,
4687            "apply_patch",
4688            &json!({"patch": "*** Begin Patch\n*** Update File: src/a.rs\n*** Move to: src/b.rs\n*** Update File: src/c.rs\n*** End Patch"}),
4689            None,
4690            Vec::new(),
4691        );
4692        assert!(event.paths.contains(&ToolPath {
4693            path: "src/b.rs".into(),
4694            access: "rename".into(),
4695            previous_path: Some("src/a.rs".into()),
4696        }));
4697        assert!(event.paths.contains(&ToolPath {
4698            path: "src/c.rs".into(),
4699            access: "write".into(),
4700            previous_path: None,
4701        }));
4702
4703        let event = tool_event_from_input(
4704            Some("/repo"),
4705            Some(1),
4706            0,
4707            "apply_patch",
4708            &json!({"patch": "*** Begin Patch\n*** Update File: a.rs\n*** Move to: x.rs\n*** Update File: b.rs\n*** Move to: y.rs\n*** End Patch"}),
4709            None,
4710            Vec::new(),
4711        );
4712        assert_eq!(
4713            event
4714                .paths
4715                .iter()
4716                .map(|row| (row.path.as_str(), row.previous_path.as_deref()))
4717                .collect::<Vec<_>>(),
4718            vec![("x.rs", Some("a.rs")), ("y.rs", Some("b.rs"))]
4719        );
4720    }
4721
4722    #[test]
4723    fn tool_outputs_mark_failed_file_actions() {
4724        let content = concat!(
4725            r#"{"type":"turn_context","payload":{"cwd":"/repo"}}"#,
4726            "\n",
4727            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"rm src/lib.rs\"}"}}"#,
4728            "\n",
4729            r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"Process exited with code 1"}}"#,
4730        );
4731        let session = parse_session_content(
4732            AGENT_CODEX,
4733            Path::new("/tmp/session.jsonl"),
4734            UNIX_EPOCH,
4735            content,
4736        )
4737        .expect("session");
4738        assert_eq!(session.events.tools[0].status, "fail");
4739        assert_eq!(session.events.tools[0].paths[0].access, "delete");
4740
4741        let claude = concat!(
4742            r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t0","name":"Read","input":{"file_path":"src/main.rs"}},{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"src/lib.rs"}}]}}"#,
4743            "\n",
4744            r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t0","is_error":false,"content":"ok"},{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"failed"}]}}"#,
4745        );
4746        let gemini = r#"{"messages":[{"type":"gemini","timestamp":"2026-01-01T00:00:00Z","toolCalls":[{"id":"t1","name":"write_file","args":{"file_path":"src/lib.rs"},"status":"error"}]}]}"#;
4747        for (agent, content, expected) in [
4748            (AGENT_CLAUDE, claude, &["ok", "fail"][..]),
4749            (AGENT_GEMINI, gemini, &["fail"][..]),
4750        ] {
4751            let session =
4752                parse_session_content(agent, Path::new("/tmp/session.jsonl"), UNIX_EPOCH, content)
4753                    .unwrap();
4754            let statuses = session
4755                .events
4756                .tools
4757                .iter()
4758                .map(|row| row.status.as_str())
4759                .collect::<Vec<_>>();
4760            assert_eq!(statuses, expected);
4761        }
4762    }
4763
4764    #[test]
4765    fn codex_exec_prompt_handles_latest_cli_options() {
4766        let command = concat!(
4767            "/tmp/tools/bin/codex exec --skip-git-repo-check --ignore-user-config ",
4768            "-c model_provider=\"agentsight-mock\" ",
4769            "-c model_providers.agentsight-mock.name=\"AgentSight Mock\" ",
4770            "--sandbox read-only --model gpt-agentsight-mock ",
4771            "agentsight mock prompt collect this exact text"
4772        );
4773
4774        assert_eq!(
4775            codex_exec_prompt(command).as_deref(),
4776            Some("agentsight mock prompt collect this exact text")
4777        );
4778    }
4779
4780    #[test]
4781    fn codex_cumulative_usage_separates_cached_input() {
4782        let content = concat!(
4783            r#"{"type":"turn_context","payload":{"model":"gpt-5.6-sol"}}"#,
4784            "\n",
4785            r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":19184,"cached_input_tokens":9984,"output_tokens":11,"total_tokens":19195}}}}"#,
4786        );
4787
4788        let session = parse_session_content(
4789            AGENT_CODEX,
4790            &PathBuf::from("/tmp/session.jsonl"),
4791            UNIX_EPOCH,
4792            content,
4793        )
4794        .expect("session");
4795
4796        assert_eq!(session.usage.input_tokens, 9_200);
4797        assert_eq!(session.usage.cache_read_tokens, 9_984);
4798        assert_eq!(session.usage.output_tokens, 11);
4799        assert_eq!(session.usage.total_tokens, 19_195);
4800    }
4801
4802    #[test]
4803    fn codex_exec_wrapper_projects_nested_shell_actions() {
4804        let event = tool_event_from_input(
4805            Some("/repo"),
4806            Some(1),
4807            0,
4808            "exec",
4809            &json!({"text": r#"const r = await tools.exec_command({"cmd":"cat src/lib.rs && sed -i 's/a/b/' src/main.rs","workdir":"/repo"});"#}),
4810            None,
4811            Vec::new(),
4812        );
4813        assert_eq!(
4814            event
4815                .paths
4816                .iter()
4817                .map(|path| (path.path.as_str(), path.access.as_str()))
4818                .collect::<Vec<_>>(),
4819            vec![("/repo/src/lib.rs", "read"), ("/repo/src/main.rs", "write")]
4820        );
4821    }
4822
4823    #[test]
4824    fn claude_uuid_only_fragments_share_one_completion_identity() {
4825        let claude = [
4826            r#"{"type":"user","promptId":"p1","message":{"content":"review the paper"}}"#,
4827            r#"{"type":"assistant","uuid":"completion-1","message":{"model":"claude-opus","content":[{"type":"text","text":"I will use a skill."}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4828            r#"{"type":"system","subtype":"internal-marker"}"#,
4829            r#"{"type":"assistant","uuid":"completion-1","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"s1","name":"Skill","input":{"skill":"paper-writing-style","args":""}}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4830            r#"{"type":"assistant","uuid":"completion-1","message":{"model":"claude-opus","content":[{"type":"text","text":"later fragment"}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4831        ]
4832        .join("\n");
4833
4834        let session = parse_session_content(
4835            AGENT_CLAUDE,
4836            &PathBuf::from("/tmp/session.jsonl"),
4837            UNIX_EPOCH,
4838            &claude,
4839        )
4840        .expect("session");
4841
4842        assert_eq!(session.events.llm_responses.len(), 1);
4843        assert_eq!(session.events.llm_responses[0].source_id, "completion-1");
4844        assert_eq!(session.events.llm_responses[0].skill, "");
4845        assert_eq!(
4846            session.events.llm_responses[0]
4847                .token_components()
4848                .into_iter()
4849                .map(|(_, value)| value)
4850                .sum::<u64>(),
4851            113
4852        );
4853        assert_eq!(session.events.tools[0].skill, "paper-writing-style");
4854        assert_eq!(session.events.tools[0].invoked_skill, "paper-writing-style");
4855    }
4856
4857    #[test]
4858    fn claude_skill_scope_ignores_metadata_and_deduplicates_split_completion() {
4859        let claude = [
4860            r#"{"type":"system","skill_listing":["availability only"]}"#,
4861            r#"{"type":"user","promptId":"p1","message":{"content":"review the paper"}}"#,
4862            r#"{"type":"assistant","requestId":"req-1","message":{"id":"msg-1","model":"claude-opus","content":[{"type":"text","text":"I will apply the citation skill."}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4863            r#"{"type":"assistant","requestId":"req-1","message":{"id":"msg-1","model":"claude-opus","content":[{"type":"tool_use","id":"s1","name":"Skill","input":{"skill":"check-paper-citations","args":""}}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4864            r#"{"type":"assistant","requestId":"req-1","message":{"id":"msg-1","model":"claude-opus","content":[{"type":"text","text":"same completion after emitting Skill"}],"usage":{"input_tokens":1,"cache_read_input_tokens":100,"output_tokens":12}}}"#,
4865            r#"{"type":"user","promptId":"p1","isMeta":true,"sourceToolUseID":"s1","message":{"content":[{"type":"text","text":"skill payload"}]}}"#,
4866            r#"{"type":"last-prompt","lastPrompt":"review the paper"}"#,
4867            r#"{"type":"user","message":{"content":"<local-command-stdout>metadata</local-command-stdout>"}}"#,
4868            r#"{"type":"user","promptId":"attachment-only","attachments":[{"file_name":"paper.pdf"}],"message":{"content":"attached context"}}"#,
4869            r#"{"type":"assistant","requestId":"req-2","message":{"id":"msg-2","model":"claude-opus","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"cmd":"rg citation paper.tex"}}],"usage":{"input_tokens":2,"cache_read_input_tokens":200,"output_tokens":20}}}"#,
4870            r#"{"type":"user","promptId":"p1","sourceToolAssistantUUID":"assistant-2","message":{"content":[{"type":"tool_result","tool_use_id":"b1","content":"ok"}]}}"#,
4871            r#"{"type":"assistant","requestId":"req-3","message":{"id":"msg-3","model":"claude-opus","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"paper.tex"}}],"usage":{"input_tokens":3,"cache_read_input_tokens":300,"output_tokens":30}}}"#,
4872            r#"{"type":"user","promptId":"p2","message":{"content":"now summarize"}}"#,
4873            r#"{"type":"assistant","requestId":"req-4","message":{"id":"msg-4","model":"claude-opus","content":[{"type":"text","text":"summary"}],"usage":{"input_tokens":4,"cache_read_input_tokens":400,"output_tokens":40}}}"#,
4874        ]
4875        .join("\n");
4876
4877        let session = parse_session_content(
4878            AGENT_CLAUDE,
4879            &PathBuf::from("/tmp/session.jsonl"),
4880            UNIX_EPOCH,
4881            &claude,
4882        )
4883        .expect("session");
4884
4885        assert_eq!(session.events.prompts.len(), 2);
4886        assert_eq!(session.events.llm_responses.len(), 4);
4887        assert_eq!(session.events.llm_responses[0].source_id, "msg-1");
4888        assert_eq!(
4889            session.events.llm_responses[0]
4890                .token_components()
4891                .into_iter()
4892                .map(|(_, value)| value)
4893                .sum::<u64>(),
4894            113
4895        );
4896        assert_eq!(
4897            session
4898                .events
4899                .tools
4900                .iter()
4901                .map(|tool| (tool.tool_name.as_str(), tool.skill.as_str()))
4902                .collect::<Vec<_>>(),
4903            [
4904                ("Skill", "check-paper-citations"),
4905                ("Bash", "check-paper-citations"),
4906                ("Read", "check-paper-citations"),
4907            ]
4908        );
4909        assert_eq!(
4910            session
4911                .events
4912                .llm_responses
4913                .iter()
4914                .map(|response| response.skill.as_str())
4915                .collect::<Vec<_>>(),
4916            ["", "check-paper-citations", "check-paper-citations", ""]
4917        );
4918    }
4919}