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