Skip to main content

claude_code_sdk_rust/internal/
sessions_fs.rs

1//! Session filesystem operations for Claude's on-disk JSONL transcripts.
2
3use crate::error::{ClaudeSDKError, Result};
4use crate::session_store::project_key_for_directory;
5use crate::sessions::{ListSessionsOptions, SessionInfo, SessionMessage};
6use chrono::{DateTime, Utc};
7use std::path::{Path, PathBuf};
8
9/// List all sessions from Claude's project transcript directories.
10pub async fn list_sessions(opts: &ListSessionsOptions) -> Result<Vec<SessionInfo>> {
11    let mut sessions = Vec::new();
12    for project_dir in project_dirs(opts.directory.as_deref()) {
13        let Ok(files) = std::fs::read_dir(project_dir) else {
14            continue;
15        };
16        for path in files.flatten().map(|file| file.path()) {
17            if session_id_from_jsonl_path(&path).is_some() {
18                if let Some(info) = session_info_from_file(&path).await? {
19                    sessions.push(info);
20                }
21            }
22        }
23    }
24
25    sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
26    if let Some(offset) = opts.offset {
27        sessions = sessions.into_iter().skip(offset).collect();
28    }
29    if let Some(limit) = opts.limit {
30        sessions.truncate(limit);
31    }
32    Ok(sessions)
33}
34
35/// Get information about a specific session.
36pub async fn get_session_info(session_id: &str, directory: Option<&str>) -> Result<SessionInfo> {
37    validate_session_id(session_id)?;
38    let path = resolve_session_file(session_id, directory)
39        .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))?;
40    session_info_from_file(&path)
41        .await?
42        .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))
43}
44
45/// Get all visible user/assistant messages for a specific session.
46pub async fn get_session_messages(
47    session_id: &str,
48    directory: Option<&str>,
49    limit: Option<usize>,
50    offset: usize,
51) -> Result<Vec<SessionMessage>> {
52    validate_session_id(session_id)?;
53    let Some(path) = resolve_session_file(session_id, directory) else {
54        return Ok(Vec::new());
55    };
56    let entries = read_jsonl_entries(&path).await?;
57    Ok(apply_limit_offset(
58        entries
59            .into_iter()
60            .filter_map(session_message_from_entry)
61            .collect(),
62        limit,
63        offset,
64    ))
65}
66
67/// List subagent IDs for a specific session.
68pub async fn list_subagents(session_id: &str, directory: Option<&str>) -> Result<Vec<String>> {
69    validate_session_id(session_id)?;
70    let Some(path) = resolve_session_file(session_id, directory) else {
71        return Ok(Vec::new());
72    };
73    let subagents_dir = path.with_extension("").join("subagents");
74    Ok(collect_agent_files(&subagents_dir)
75        .into_iter()
76        .map(|(agent_id, _)| agent_id)
77        .collect())
78}
79
80/// Get visible user/assistant messages for a specific subagent transcript.
81pub async fn get_subagent_messages(
82    session_id: &str,
83    agent_id: &str,
84    directory: Option<&str>,
85    limit: Option<usize>,
86    offset: usize,
87) -> Result<Vec<SessionMessage>> {
88    validate_session_id(session_id)?;
89    if agent_id.is_empty() {
90        return Ok(Vec::new());
91    }
92    let Some(path) = resolve_session_file(session_id, directory) else {
93        return Ok(Vec::new());
94    };
95    let subagents_dir = path.with_extension("").join("subagents");
96    let Some((_, agent_file)) = collect_agent_files(&subagents_dir)
97        .into_iter()
98        .find(|(found_id, _)| found_id == agent_id)
99    else {
100        return Ok(Vec::new());
101    };
102    let entries = read_jsonl_entries(&agent_file).await?;
103    Ok(apply_limit_offset(
104        subagent_chain(entries)
105            .into_iter()
106            .filter_map(session_message_from_entry)
107            .collect(),
108        limit,
109        offset,
110    ))
111}
112
113/// Rename a session by appending the same custom-title metadata line the CLI uses.
114pub async fn rename_session(session_id: &str, title: &str, directory: Option<&str>) -> Result<()> {
115    validate_session_id(session_id)?;
116    let title = title.trim();
117    if title.is_empty() {
118        return Err(ClaudeSDKError::Session(
119            "title must be non-empty".to_string(),
120        ));
121    }
122    let path = resolve_session_file(session_id, directory)
123        .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))?;
124    let entry = serde_json::json!({
125        "type": "summary",
126        "customTitle": title,
127        "timestamp": Utc::now().to_rfc3339(),
128    });
129    append_jsonl_entry(&path, &entry).await
130}
131
132/// Tag a session by appending a tag metadata line. `None` clears the tag.
133pub async fn tag_session(
134    session_id: &str,
135    tag: Option<&str>,
136    directory: Option<&str>,
137) -> Result<()> {
138    validate_session_id(session_id)?;
139    let tag = tag.map(sanitize_tag).transpose()?.unwrap_or_default();
140    let path = resolve_session_file(session_id, directory)
141        .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))?;
142    let entry = serde_json::json!({
143        "type": "tag",
144        "tag": tag,
145        "sessionId": session_id,
146    });
147    append_jsonl_entry(&path, &entry).await
148}
149
150/// Delete a session transcript and its sidecar subagent directory if present.
151pub async fn delete_session(session_id: &str, directory: Option<&str>) -> Result<()> {
152    validate_session_id(session_id)?;
153    let Some(path) = resolve_session_file(session_id, directory) else {
154        return Ok(());
155    };
156    tokio::fs::remove_file(&path).await?;
157    let sidecar_dir = path.with_extension("");
158    if tokio::fs::metadata(&sidecar_dir)
159        .await
160        .is_ok_and(|meta| meta.is_dir())
161    {
162        tokio::fs::remove_dir_all(sidecar_dir).await?;
163    }
164    Ok(())
165}
166
167fn projects_dir() -> PathBuf {
168    std::env::var_os("CLAUDE_CONFIG_DIR")
169        .map(PathBuf::from)
170        .or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
171        .unwrap_or_else(|| PathBuf::from(".claude"))
172        .join("projects")
173}
174
175fn project_dirs(directory: Option<&str>) -> Vec<PathBuf> {
176    if let Some(directory) = directory {
177        let dir = projects_dir().join(project_key_for_directory(Some(Path::new(directory))));
178        return if dir.is_dir() { vec![dir] } else { Vec::new() };
179    }
180    let Ok(projects) = std::fs::read_dir(projects_dir()) else {
181        return Vec::new();
182    };
183    projects
184        .flatten()
185        .filter(|project| project.file_type().ok().is_some_and(|ty| ty.is_dir()))
186        .map(|project| project.path())
187        .collect()
188}
189
190fn resolve_session_file(session_id: &str, directory: Option<&str>) -> Option<PathBuf> {
191    let file_name = format!("{session_id}.jsonl");
192    for project in project_dirs(directory) {
193        let candidate = project.join(&file_name);
194        if candidate.is_file() {
195            return Some(candidate);
196        }
197    }
198    None
199}
200
201fn apply_limit_offset(
202    messages: Vec<SessionMessage>,
203    limit: Option<usize>,
204    offset: usize,
205) -> Vec<SessionMessage> {
206    let messages = if offset > 0 {
207        messages.into_iter().skip(offset).collect()
208    } else {
209        messages
210    };
211    if let Some(limit) = limit.filter(|limit| *limit > 0) {
212        messages.into_iter().take(limit).collect()
213    } else {
214        messages
215    }
216}
217
218fn collect_agent_files(base_dir: &Path) -> Vec<(String, PathBuf)> {
219    let mut output = Vec::new();
220    collect_agent_files_inner(base_dir, &mut output);
221    output
222}
223
224fn collect_agent_files_inner(base_dir: &Path, output: &mut Vec<(String, PathBuf)>) {
225    let Ok(entries) = std::fs::read_dir(base_dir) else {
226        return;
227    };
228    let mut entries = entries.flatten().collect::<Vec<_>>();
229    entries.sort_by_key(|entry| entry.file_name());
230    for entry in entries {
231        let path = entry.path();
232        if path.is_dir() {
233            collect_agent_files_inner(&path, output);
234            continue;
235        }
236        let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
237            continue;
238        };
239        let Some(agent_id) = file_name
240            .strip_prefix("agent-")
241            .and_then(|name| name.strip_suffix(".jsonl"))
242        else {
243            continue;
244        };
245        output.push((agent_id.to_string(), path));
246    }
247}
248
249async fn session_info_from_file(path: &Path) -> Result<Option<SessionInfo>> {
250    let Some(session_id) = session_id_from_jsonl_path(path) else {
251        return Ok(None);
252    };
253    let entries = read_jsonl_entries(path).await?;
254    if entries.is_empty() {
255        return Ok(None);
256    }
257    let metadata = tokio::fs::metadata(path).await?;
258    let updated_at = metadata
259        .modified()
260        .ok()
261        .map(DateTime::<Utc>::from)
262        .unwrap_or_else(Utc::now)
263        .to_rfc3339();
264    let created_at = metadata
265        .created()
266        .ok()
267        .map(DateTime::<Utc>::from)
268        .unwrap_or_else(Utc::now)
269        .to_rfc3339();
270    let message_count = entries
271        .iter()
272        .filter(|entry| is_visible_message(entry))
273        .count();
274    let title = extract_title(&entries).unwrap_or_else(|| session_id.clone());
275
276    Ok(Some(SessionInfo {
277        id: session_id,
278        title,
279        created_at,
280        updated_at,
281        message_count,
282    }))
283}
284
285async fn read_jsonl_entries(
286    path: &Path,
287) -> Result<Vec<serde_json::Map<String, serde_json::Value>>> {
288    let content = tokio::fs::read_to_string(path).await?;
289    let entries = content
290        .lines()
291        .filter_map(|line| {
292            let line = line.trim();
293            if line.is_empty() {
294                return None;
295            }
296            serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(line).ok()
297        })
298        .collect();
299    Ok(entries)
300}
301
302async fn append_jsonl_entry(path: &Path, entry: &serde_json::Value) -> Result<()> {
303    use tokio::io::AsyncWriteExt;
304
305    let mut file = tokio::fs::OpenOptions::new()
306        .append(true)
307        .open(path)
308        .await?;
309    file.write_all(serde_json::to_string(entry)?.as_bytes())
310        .await?;
311    file.write_all(b"\n").await?;
312    Ok(())
313}
314
315fn session_message_from_entry(
316    entry: serde_json::Map<String, serde_json::Value>,
317) -> Option<SessionMessage> {
318    if !is_visible_message(&entry) {
319        return None;
320    }
321    let role = entry.get("type")?.as_str()?.to_string();
322    let id = entry
323        .get("uuid")
324        .and_then(|value| value.as_str())
325        .unwrap_or_default()
326        .to_string();
327    let content = entry
328        .get("message")
329        .and_then(|message| message.get("content"))
330        .map(content_to_string)
331        .unwrap_or_default();
332    let timestamp = entry
333        .get("timestamp")
334        .and_then(|value| value.as_str())
335        .unwrap_or_default()
336        .to_string();
337    Some(SessionMessage {
338        id,
339        role,
340        content,
341        timestamp,
342    })
343}
344
345fn subagent_chain(
346    entries: Vec<serde_json::Map<String, serde_json::Value>>,
347) -> Vec<serde_json::Map<String, serde_json::Value>> {
348    let Some(leaf_uuid) = entries
349        .iter()
350        .rev()
351        .find(|entry| matches!(entry_type(entry), Some("user" | "assistant")))
352        .and_then(|entry| entry.get("uuid"))
353        .and_then(|value| value.as_str())
354        .map(ToString::to_string)
355    else {
356        return Vec::new();
357    };
358    let by_uuid = entries
359        .iter()
360        .filter_map(|entry| {
361            entry
362                .get("uuid")
363                .and_then(|value| value.as_str())
364                .map(|uuid| (uuid.to_string(), entry.clone()))
365        })
366        .collect::<std::collections::HashMap<_, _>>();
367    let mut chain = Vec::new();
368    let mut seen = std::collections::HashSet::new();
369    let mut current = Some(leaf_uuid);
370    while let Some(uuid) = current {
371        if !seen.insert(uuid.clone()) {
372            break;
373        }
374        let Some(entry) = by_uuid.get(&uuid).cloned() else {
375            break;
376        };
377        current = entry
378            .get("parentUuid")
379            .and_then(|value| value.as_str())
380            .map(ToString::to_string);
381        chain.push(entry);
382    }
383    chain.reverse();
384    chain
385}
386
387fn extract_title(entries: &[serde_json::Map<String, serde_json::Value>]) -> Option<String> {
388    entries
389        .iter()
390        .rev()
391        .find_map(|entry| string_field(entry, "customTitle"))
392        .or_else(|| {
393            entries
394                .iter()
395                .rev()
396                .find_map(|entry| string_field(entry, "summary"))
397        })
398        .or_else(|| {
399            entries
400                .iter()
401                .find(|entry| is_visible_message(entry) && entry_type(entry) == Some("user"))
402                .and_then(|entry| entry.get("message"))
403                .and_then(|message| message.get("content"))
404                .map(content_to_string)
405                .map(truncate_title)
406        })
407}
408
409fn is_visible_message(entry: &serde_json::Map<String, serde_json::Value>) -> bool {
410    matches!(entry_type(entry), Some("user" | "assistant"))
411        && !bool_field(entry, "isMeta")
412        && !bool_field(entry, "isSidechain")
413        && !entry.contains_key("teamName")
414}
415
416fn entry_type(entry: &serde_json::Map<String, serde_json::Value>) -> Option<&str> {
417    entry.get("type").and_then(|value| value.as_str())
418}
419
420fn string_field(entry: &serde_json::Map<String, serde_json::Value>, field: &str) -> Option<String> {
421    entry
422        .get(field)
423        .and_then(|value| value.as_str())
424        .map(ToString::to_string)
425        .filter(|value| !value.is_empty())
426}
427
428fn bool_field(entry: &serde_json::Map<String, serde_json::Value>, field: &str) -> bool {
429    entry
430        .get(field)
431        .and_then(|value| value.as_bool())
432        .unwrap_or(false)
433}
434
435fn sanitize_tag(tag: &str) -> Result<String> {
436    let sanitized = tag
437        .chars()
438        .filter(|ch| !ch.is_control())
439        .collect::<String>()
440        .trim()
441        .to_string();
442    if sanitized.is_empty() {
443        Err(ClaudeSDKError::Session(
444            "tag must be non-empty (use None to clear)".to_string(),
445        ))
446    } else {
447        Ok(sanitized)
448    }
449}
450
451fn content_to_string(value: &serde_json::Value) -> String {
452    match value {
453        serde_json::Value::String(text) => text.clone(),
454        serde_json::Value::Array(blocks) => blocks
455            .iter()
456            .filter_map(|block| {
457                if block.get("type").and_then(|value| value.as_str()) == Some("text") {
458                    block
459                        .get("text")
460                        .and_then(|value| value.as_str())
461                        .map(ToString::to_string)
462                } else {
463                    None
464                }
465            })
466            .collect::<Vec<_>>()
467            .join("\n"),
468        other => other.to_string(),
469    }
470}
471
472fn truncate_title(value: String) -> String {
473    let normalized = value.replace('\n', " ").trim().to_string();
474    if normalized.chars().count() <= 200 {
475        normalized
476    } else {
477        format!("{}...", normalized.chars().take(200).collect::<String>())
478    }
479}
480
481fn session_id_from_jsonl_path(path: &Path) -> Option<String> {
482    if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
483        return None;
484    }
485    let session_id = path.file_stem()?.to_str()?;
486    uuid::Uuid::parse_str(session_id).ok()?;
487    Some(session_id.to_string())
488}
489
490fn validate_session_id(session_id: &str) -> Result<()> {
491    uuid::Uuid::parse_str(session_id)
492        .map(|_| ())
493        .map_err(|_| ClaudeSDKError::Session(format!("Invalid session_id: {session_id}")))
494}