Skip to main content

ytil_agents/agent/session_loader/
claude.rs

1use std::collections::HashSet;
2use std::path::Path;
3use std::path::PathBuf;
4
5use rootcause::prelude::ResultExt;
6
7use crate::agent::Agent;
8use crate::agent::session::Session;
9use crate::agent::session::SessionKey;
10
11/// Load Claude sessions from the local Claude project store.
12///
13/// # Errors
14/// Returns an error when the Claude sessions directory cannot be read or a
15/// session file cannot be parsed.
16pub fn load_sessions() -> rootcause::Result<Vec<Session>> {
17    let root = ytil_sys::dir::build_home_path(Agent::Claude.sessions_root_path())?;
18    let session_paths =
19        crate::agent::session_loader::find_session_paths(&root, |entry| claude_session_path(&entry.path()), |_| false)?;
20
21    load_sessions_from_paths(session_paths, |_| true)
22}
23
24/// Load only requested Claude sessions from the local Claude project store.
25///
26/// # Errors
27/// Returns an error when a matching Claude session file cannot be read or parsed.
28pub fn load_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
29    let root = ytil_sys::dir::build_home_path(Agent::Claude.sessions_root_path())?;
30    load_sessions_from_root_by_key(&root, keys)
31}
32
33fn load_sessions_from_root_by_key(root: &Path, keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
34    let requested_ids = crate::agent::session_loader::requested_ids(keys, Agent::Claude);
35    if requested_ids.is_empty() {
36        return Ok(Vec::new());
37    }
38    let session_paths = crate::agent::session_loader::find_session_paths(
39        root,
40        |entry| claude_session_path_matches_requested_id(&entry.path(), &requested_ids),
41        |_| false,
42    )?;
43
44    load_sessions_from_paths(session_paths, |session| requested_ids.contains(session.id.as_str()))
45}
46
47fn load_sessions_from_paths(
48    session_paths: Vec<PathBuf>,
49    keep_session: impl Fn(&Session) -> bool,
50) -> rootcause::Result<Vec<Session>> {
51    let mut sessions = Vec::new();
52    for session_path in session_paths {
53        let content = std::fs::read_to_string(&session_path)
54            .context("failed to read Claude session file")
55            .attach_with(|| format!("path={}", session_path.display()))?;
56        let claude_session = crate::agent::session_parser::claude::parse(&content)
57            .attach_with(|| format!("path={}", session_path.display()))?;
58        let session = claude_session.into_session(session_path);
59        if session.workspace.is_dir() && keep_session(&session) {
60            sessions.push(session);
61        }
62    }
63
64    Ok(sessions)
65}
66
67fn claude_session_path(path: &Path) -> bool {
68    path.extension().is_some_and(|ext| ext == "jsonl")
69        && path
70            .file_name()
71            .and_then(|name| name.to_str())
72            .is_some_and(|name| !matches!(name, "sessions-index.json" | "session.json"))
73}
74
75fn claude_session_path_matches_requested_id(path: &Path, requested_ids: &HashSet<&str>) -> bool {
76    claude_session_path(path)
77        && path
78            .file_stem()
79            .and_then(|name| name.to_str())
80            .is_some_and(|stem| requested_ids.contains(stem))
81}
82
83#[cfg(test)]
84mod tests {
85    use tempfile::tempdir;
86
87    use super::*;
88
89    #[test]
90    fn test_load_sessions_from_root_by_key_only_parses_matching_claude_files() {
91        let dir = tempdir().expect("tempdir should be created");
92        let root = dir.path().join("projects");
93        let workspace = dir.path().join("workspace");
94        std::fs::create_dir_all(&root).expect("session root should be created");
95        std::fs::create_dir_all(&workspace).expect("workspace should be created");
96        std::fs::write(root.join("target.jsonl"), claude_content("target", &workspace))
97            .expect("target session should be written");
98        std::fs::write(root.join("other.jsonl"), "not json\n").expect("nonmatching session should be written");
99
100        assert2::assert!(
101            let Ok(sessions) = load_sessions_from_root_by_key(&root, &[SessionKey::new(Agent::Claude, "target")])
102        );
103
104        pretty_assertions::assert_eq!(sessions.len(), 1);
105        pretty_assertions::assert_eq!(sessions[0].id, "target");
106    }
107
108    fn claude_content(id: &str, workspace: &Path) -> String {
109        format!(
110            "{{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"{}\",\"sessionId\":\"{id}\"}}\n",
111            workspace.display()
112        )
113    }
114}