1use anyhow::{Context, Result};
7use serde::Deserialize;
8use std::fs;
9use std::io::{BufRead, BufReader, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
11
12#[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#[derive(Debug, Clone)]
23pub enum SessionEvent {
24 ToolUse {
26 name: String,
27 summary: String,
28 timestamp: String,
29 },
30 Text { content: String, timestamp: String },
32 Complete,
34}
35
36#[derive(Debug, Clone)]
38pub struct SubagentMeta {
39 pub agent_type: String,
40 pub description: String,
41 pub jsonl_path: PathBuf,
42}
43
44#[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
77pub 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 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
118pub 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
127pub 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
153pub 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 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
196pub 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, ×tamp, &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
250pub 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
283fn 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 abs.replace('/', "-").replace('.', "-")
294}
295
296#[cfg(unix)]
297fn is_pid_alive(pid: u32) -> bool {
298 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
417fn 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 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 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}