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