Skip to main content

parsentry_claude/
lib.rs

1//! Claude Code session reader
2//!
3//! Reads session JSONL files from `~/.claude/` and extracts events
4//! such as tool calls, text responses, and completion markers.
5
6use anyhow::{Context, Result};
7use serde::Deserialize;
8use std::fs;
9use std::io::{BufRead, BufReader, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
11
12/// Active Claude Code session
13#[derive(Debug, Clone)]
14pub struct Session {
15    pub pid: u32,
16    pub session_id: String,
17    pub cwd: PathBuf,
18    pub started_at: u64,
19}
20
21/// Event extracted from session JSONL
22#[derive(Debug, Clone)]
23pub enum SessionEvent {
24    /// Tool invocation (Read, Write, Bash, etc.)
25    ToolUse {
26        name: String,
27        summary: String,
28        timestamp: String,
29    },
30    /// Text output from assistant
31    Text { content: String, timestamp: String },
32    /// Session completed (last-prompt marker)
33    Complete,
34}
35
36/// Subagent metadata
37#[derive(Debug, Clone)]
38pub struct SubagentMeta {
39    pub agent_type: String,
40    pub description: String,
41    pub jsonl_path: PathBuf,
42}
43
44// --- Internal deserialization types ---
45
46#[derive(Deserialize)]
47struct SessionFile {
48    pid: u32,
49    #[serde(rename = "sessionId")]
50    session_id: String,
51    cwd: String,
52    #[serde(rename = "startedAt")]
53    started_at: u64,
54}
55
56#[derive(Deserialize)]
57struct JournalEntry {
58    #[serde(rename = "type")]
59    entry_type: String,
60    timestamp: Option<String>,
61    message: Option<MessageBody>,
62    content: Option<String>,
63}
64
65#[derive(Deserialize)]
66struct MessageBody {
67    content: Option<serde_json::Value>,
68}
69
70#[derive(Deserialize)]
71struct SubagentMetaFile {
72    #[serde(rename = "agentType")]
73    agent_type: String,
74    description: String,
75}
76
77// --- Public API ---
78
79/// List active Claude Code sessions by reading `~/.claude/sessions/`.
80/// Only returns sessions whose PID is still alive.
81pub fn list_active_sessions() -> Result<Vec<Session>> {
82    let sessions_dir = claude_home()?.join("sessions");
83    if !sessions_dir.exists() {
84        return Ok(vec![]);
85    }
86
87    let mut sessions = Vec::new();
88    for entry in fs::read_dir(&sessions_dir)? {
89        let entry = entry?;
90        let path = entry.path();
91        if path.extension().and_then(|e| e.to_str()) != Some("json") {
92            continue;
93        }
94
95        let data =
96            fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
97        let sf: SessionFile = match serde_json::from_str(&data) {
98            Ok(s) => s,
99            Err(_) => continue,
100        };
101
102        // Check if PID is alive via kill(pid, 0)
103        if !is_pid_alive(sf.pid) {
104            continue;
105        }
106
107        sessions.push(Session {
108            pid: sf.pid,
109            session_id: sf.session_id,
110            cwd: PathBuf::from(sf.cwd),
111            started_at: sf.started_at,
112        });
113    }
114
115    Ok(sessions)
116}
117
118/// Resolve the project sessions directory for a given working directory.
119///
120/// Claude Code stores project data under `~/.claude/projects/{encoded-path}/`.
121/// The path encoding replaces `/` with `-`.
122pub fn project_sessions_dir(cwd: &Path) -> Result<PathBuf> {
123    let encoded = encode_project_path(cwd);
124    Ok(claude_home()?.join("projects").join(encoded))
125}
126
127/// Find session IDs in a project directory that are currently active.
128pub fn find_active_project_sessions(project_dir: &Path) -> Result<Vec<String>> {
129    let active = list_active_sessions()?;
130    let active_ids: std::collections::HashSet<String> =
131        active.iter().map(|s| s.session_id.clone()).collect();
132
133    let mut result = Vec::new();
134    if !project_dir.exists() {
135        return Ok(result);
136    }
137
138    for entry in fs::read_dir(project_dir)? {
139        let entry = entry?;
140        let path = entry.path();
141        if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
142            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
143                if active_ids.contains(stem) {
144                    result.push(stem.to_string());
145                }
146            }
147        }
148    }
149
150    Ok(result)
151}
152
153/// List subagents for a given session.
154pub fn list_subagents(project_dir: &Path, session_id: &str) -> Result<Vec<SubagentMeta>> {
155    let subagents_dir = project_dir.join(session_id).join("subagents");
156    if !subagents_dir.exists() {
157        return Ok(vec![]);
158    }
159
160    let mut agents = Vec::new();
161    for entry in fs::read_dir(&subagents_dir)? {
162        let entry = entry?;
163        let path = entry.path();
164        if path.extension().and_then(|e| e.to_str()) == Some("json")
165            && path
166                .file_name()
167                .and_then(|n| n.to_str())
168                .is_some_and(|n| n.ends_with(".meta.json"))
169        {
170            let data = fs::read_to_string(&path)?;
171            let meta: SubagentMetaFile = match serde_json::from_str(&data) {
172                Ok(m) => m,
173                Err(_) => continue,
174            };
175
176            // Derive JSONL path from meta path: agent-xxx.meta.json → agent-xxx.jsonl
177            let jsonl_name = path
178                .file_name()
179                .unwrap()
180                .to_str()
181                .unwrap()
182                .replace(".meta.json", ".jsonl");
183            let jsonl_path = subagents_dir.join(jsonl_name);
184
185            agents.push(SubagentMeta {
186                agent_type: meta.agent_type,
187                description: meta.description,
188                jsonl_path,
189            });
190        }
191    }
192
193    Ok(agents)
194}
195
196/// Read new events from a JSONL file starting at `offset` bytes.
197/// Returns the events and the new offset for the next read.
198pub fn read_events_from(path: &Path, offset: u64) -> Result<(Vec<SessionEvent>, u64)> {
199    let file = fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
200    let file_len = file.metadata()?.len();
201
202    if offset >= file_len {
203        return Ok((vec![], offset));
204    }
205
206    let mut reader = BufReader::new(file);
207    reader.seek(SeekFrom::Start(offset))?;
208
209    let mut events = Vec::new();
210    let mut line = String::new();
211
212    loop {
213        line.clear();
214        let bytes_read = reader.read_line(&mut line)?;
215        if bytes_read == 0 {
216            break;
217        }
218
219        let trimmed = line.trim();
220        if trimmed.is_empty() {
221            continue;
222        }
223
224        let entry: JournalEntry = match serde_json::from_str(trimmed) {
225            Ok(e) => e,
226            Err(_) => continue,
227        };
228
229        let timestamp = entry.timestamp.clone().unwrap_or_default();
230
231        match entry.entry_type.as_str() {
232            "assistant" => {
233                if let Some(msg) = &entry.message {
234                    if let Some(content) = &msg.content {
235                        extract_events_from_content(content, &timestamp, &mut events);
236                    }
237                }
238            }
239            "last-prompt" => {
240                events.push(SessionEvent::Complete);
241            }
242            _ => {}
243        }
244    }
245
246    let new_offset = reader.stream_position()?;
247    Ok((events, new_offset))
248}
249
250/// Extract the initial prompt content from a session JSONL (queue-operation).
251/// Useful for identifying which SURFACE-XXX a session is analyzing.
252pub fn extract_surface_id(path: &Path) -> Option<String> {
253    let file = fs::File::open(path).ok()?;
254    let reader = BufReader::new(file);
255
256    for line in reader.lines() {
257        let line = line.ok()?;
258        let entry: JournalEntry = match serde_json::from_str(&line) {
259            Ok(e) => e,
260            Err(_) => continue,
261        };
262
263        if entry.entry_type == "queue-operation" || entry.entry_type == "user" {
264            if let Some(content) = &entry.content {
265                return extract_surface_from_text(content);
266            }
267            if let Some(msg) = &entry.message {
268                if let Some(serde_json::Value::Array(blocks)) = &msg.content {
269                    for block in blocks {
270                        if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
271                            if let Some(id) = extract_surface_from_text(text) {
272                                return Some(id);
273                            }
274                        }
275                    }
276                }
277            }
278        }
279    }
280    None
281}
282
283// --- Internal helpers ---
284
285fn claude_home() -> Result<PathBuf> {
286    let home = dirs_next::home_dir().context("cannot determine home directory")?;
287    Ok(home.join(".claude"))
288}
289
290fn encode_project_path(path: &Path) -> String {
291    let abs = path.to_string_lossy();
292    // Claude Code encodes paths by replacing both '/' and '.' with '-'
293    abs.replace('/', "-").replace('.', "-")
294}
295
296#[cfg(unix)]
297fn is_pid_alive(pid: u32) -> bool {
298    // SAFETY: kill with signal 0 only checks process existence
299    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
300}
301
302#[cfg(windows)]
303fn is_pid_alive(pid: u32) -> bool {
304    use std::process::Command;
305    Command::new("tasklist")
306        .args(["/FI", &format!("PID eq {pid}"), "/NH"])
307        .output()
308        .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()))
309        .unwrap_or(false)
310}
311
312fn extract_events_from_content(
313    content: &serde_json::Value,
314    timestamp: &str,
315    events: &mut Vec<SessionEvent>,
316) {
317    if let serde_json::Value::Array(blocks) = content {
318        for block in blocks {
319            match block.get("type").and_then(|t| t.as_str()) {
320                Some("tool_use") => {
321                    let name = block
322                        .get("name")
323                        .and_then(|n| n.as_str())
324                        .unwrap_or("unknown")
325                        .to_string();
326                    let summary = summarize_tool_input(
327                        &name,
328                        block.get("input").unwrap_or(&serde_json::Value::Null),
329                    );
330                    events.push(SessionEvent::ToolUse {
331                        name,
332                        summary,
333                        timestamp: timestamp.to_string(),
334                    });
335                }
336                Some("text") => {
337                    let text = block
338                        .get("text")
339                        .and_then(|t| t.as_str())
340                        .unwrap_or("")
341                        .to_string();
342                    if !text.is_empty() {
343                        let first_line = text.lines().next().unwrap_or("");
344                        let truncated = truncate_chars(first_line, 120);
345                        events.push(SessionEvent::Text {
346                            content: truncated,
347                            timestamp: timestamp.to_string(),
348                        });
349                    }
350                }
351                _ => {}
352            }
353        }
354    }
355}
356
357fn summarize_tool_input(tool_name: &str, input: &serde_json::Value) -> String {
358    match tool_name {
359        "Read" => {
360            let path = input
361                .get("file_path")
362                .and_then(|p| p.as_str())
363                .unwrap_or("?");
364            let short = short_path(path);
365            if let Some(limit) = input.get("limit").and_then(|l| l.as_u64()) {
366                format!("{} (limit={})", short, limit)
367            } else {
368                short.to_string()
369            }
370        }
371        "Write" => {
372            let path = input
373                .get("file_path")
374                .and_then(|p| p.as_str())
375                .unwrap_or("?");
376            short_path(path).to_string()
377        }
378        "Edit" => {
379            let path = input
380                .get("file_path")
381                .and_then(|p| p.as_str())
382                .unwrap_or("?");
383            short_path(path).to_string()
384        }
385        "Bash" => {
386            let cmd = input.get("command").and_then(|c| c.as_str()).unwrap_or("?");
387            truncate_chars(cmd, 60)
388        }
389        "Grep" => {
390            let pattern = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("?");
391            format!("/{}/", pattern)
392        }
393        "Glob" => {
394            let pattern = input.get("pattern").and_then(|p| p.as_str()).unwrap_or("?");
395            pattern.to_string()
396        }
397        "Agent" => {
398            let desc = input
399                .get("description")
400                .and_then(|d| d.as_str())
401                .unwrap_or("?");
402            desc.to_string()
403        }
404        _ => {
405            if let serde_json::Value::Object(map) = input {
406                for (k, v) in map.iter().take(1) {
407                    if let Some(s) = v.as_str() {
408                        return format!("{}={}", k, truncate_chars(s, 40));
409                    }
410                }
411            }
412            String::new()
413        }
414    }
415}
416
417/// Truncate a string to at most `max` characters (not bytes), appending "..." if truncated.
418fn truncate_chars(s: &str, max: usize) -> String {
419    if s.chars().count() <= max {
420        return s.to_string();
421    }
422    let end = s
423        .char_indices()
424        .nth(max.saturating_sub(3))
425        .map(|(i, _)| i)
426        .unwrap_or(s.len());
427    format!("{}...", &s[..end])
428}
429
430fn short_path(path: &str) -> &str {
431    // Return the last 2 path components
432    let parts: Vec<&str> = path.rsplit('/').take(2).collect();
433    if parts.len() == 2 {
434        let start = path.len() - parts[0].len() - parts[1].len() - 1;
435        &path[start..]
436    } else {
437        path
438    }
439}
440
441fn extract_surface_from_text(text: &str) -> Option<String> {
442    // Look for "SURFACE-NNN" pattern
443    let idx = text.find("SURFACE-")?;
444    let rest = &text[idx..];
445    let end = rest
446        .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
447        .unwrap_or(rest.len());
448    let surface_id = &rest[..end];
449    if surface_id.len() > 8 {
450        Some(surface_id.to_string())
451    } else {
452        None
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn test_encode_project_path() {
462        let path = Path::new("/Users/hikae/ghq/github.com/HikaruEgashira/parsentry");
463        assert_eq!(
464            encode_project_path(path),
465            "-Users-hikae-ghq-github-com-HikaruEgashira-parsentry"
466        );
467    }
468
469    #[test]
470    fn test_short_path() {
471        assert_eq!(short_path("/a/b/c/d.rs"), "c/d.rs");
472        assert_eq!(short_path("file.rs"), "file.rs");
473    }
474
475    #[test]
476    fn test_extract_surface_from_text() {
477        assert_eq!(
478            extract_surface_from_text("analyzing SURFACE-001 for vulnerabilities"),
479            Some("SURFACE-001".to_string())
480        );
481        assert_eq!(extract_surface_from_text("no surface here"), None);
482    }
483
484    #[test]
485    fn test_summarize_tool_input() {
486        let input = serde_json::json!({"file_path": "/Users/test/src/main.rs", "limit": 100});
487        assert_eq!(
488            summarize_tool_input("Read", &input),
489            "src/main.rs (limit=100)"
490        );
491
492        let input = serde_json::json!({"command": "cargo test"});
493        assert_eq!(summarize_tool_input("Bash", &input), "cargo test");
494    }
495}