Skip to main content

claude_wrapper/
sessions.rs

1//! Read-side access to Claude Code's **live session registry**.
2//!
3//! Each running Claude Code process registers itself as
4//! `~/.claude/sessions/<pid>.json`:
5//!
6//! ```json
7//! {
8//!   "pid": 31546,
9//!   "sessionId": "a2000338-8786-49e7-be7b-3ffff9ce15e4",
10//!   "cwd": "/path/to/project",
11//!   "startedAt": 1785430795867,
12//!   "version": "2.1.219",
13//!   "kind": "interactive",
14//!   "entrypoint": "claude-desktop",
15//!   "name": "claude-wrapper-85"
16//! }
17//! ```
18//!
19//! The `sessionId` joins a running process to its transcript in
20//! [`crate::history`]. This module is read-only on purpose, like
21//! the other introspection modules. The layout is undocumented
22//! Claude Code internal state (observed against CLI 2.1.219) and
23//! can change across CLI versions, so parsing is defensive: every
24//! typed field is `Option`-shaped, mistyped values stay in
25//! [`LiveSession::rest`], and unknown fields land there too.
26//!
27//! # Staleness
28//!
29//! Registry files can outlive their process (a crash skips
30//! cleanup), so an entry is evidence a session *was* running, not
31//! proof it still is. Liveness is deliberately left to the caller:
32//! check [`LiveSession::pid`] with a platform-appropriate probe if
33//! it matters.
34//!
35//! # Example
36//!
37//! ```no_run
38//! use claude_wrapper::sessions::SessionsRoot;
39//!
40//! # fn example() -> claude_wrapper::Result<()> {
41//! let root = SessionsRoot::home()?;
42//! for s in root.list()? {
43//!     println!(
44//!         "{} {} {}",
45//!         s.pid.unwrap_or(0),
46//!         s.name.as_deref().unwrap_or("?"),
47//!         s.cwd.as_deref().unwrap_or("?"),
48//!     );
49//! }
50//! # Ok(()) }
51//! ```
52
53use std::fs;
54use std::path::{Path, PathBuf};
55
56use serde::Serialize;
57use serde_json::Value;
58
59use crate::error::{Error, Result};
60
61/// Root directory of Claude Code's live session registry. Defaults
62/// to `~/.claude/sessions`; override with [`SessionsRoot::at`] for
63/// tests or non-default installs.
64#[derive(Debug, Clone)]
65pub struct SessionsRoot {
66    path: PathBuf,
67}
68
69impl SessionsRoot {
70    /// Resolve the default `~/.claude/sessions`. Errors if `$HOME`
71    /// (or the platform-specific user home) cannot be determined.
72    pub fn home() -> Result<Self> {
73        let home = home_dir().ok_or_else(|| Error::Artifacts {
74            message: "could not determine user home directory".to_string(),
75        })?;
76        Ok(Self {
77            path: home.join(".claude").join("sessions"),
78        })
79    }
80
81    /// Use a specific path as the sessions root. Useful for tests
82    /// (point at a tempdir) and for non-default installs.
83    pub fn at(path: impl Into<PathBuf>) -> Self {
84        Self { path: path.into() }
85    }
86
87    /// The configured root directory.
88    pub fn path(&self) -> &Path {
89        &self.path
90    }
91
92    /// List every registry entry, newest first (by `startedAt`,
93    /// ties broken by pid). A missing root returns an empty vec;
94    /// malformed files are skipped with a tracing warning. Entries
95    /// may be stale -- see the module docs.
96    pub fn list(&self) -> Result<Vec<LiveSession>> {
97        let entries = match fs::read_dir(&self.path) {
98            Ok(it) => it,
99            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
100            Err(e) => return Err(e.into()),
101        };
102        let mut out = Vec::new();
103        for entry in entries.flatten() {
104            let path = entry.path();
105            if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("json") {
106                continue;
107            }
108            match parse_session_file(&path) {
109                Ok(session) => out.push(session),
110                Err(e) => tracing::warn!(?path, "skipping session registry file: {e}"),
111            }
112        }
113        out.sort_by(|a, b| {
114            b.started_at_ms
115                .cmp(&a.started_at_ms)
116                .then_with(|| a.pid.cmp(&b.pid))
117        });
118        Ok(out)
119    }
120}
121
122/// One entry from the live session registry.
123#[derive(Debug, Clone, Serialize)]
124pub struct LiveSession {
125    /// Process id, when present. May refer to an exited process --
126    /// see the module docs on staleness.
127    pub pid: Option<u64>,
128    /// The session id; joins to [`crate::history`] transcripts.
129    pub session_id: Option<String>,
130    /// Working directory of the session.
131    pub cwd: Option<String>,
132    /// Epoch milliseconds when the session started.
133    pub started_at_ms: Option<u64>,
134    /// Claude Code version string.
135    pub version: Option<String>,
136    /// Session kind (e.g. `interactive`), when present.
137    pub kind: Option<String>,
138    /// The surface the session was started from (e.g. `cli`,
139    /// `claude-desktop`), when present.
140    pub entrypoint: Option<String>,
141    /// Display name (e.g. `claude-wrapper-85`), when present.
142    pub name: Option<String>,
143    /// Absolute path to the source `.json`.
144    pub file_path: PathBuf,
145    /// Any additional fields, keyed as Claude Code wrote them. A
146    /// typed field with an unexpected JSON type also lands here.
147    pub rest: serde_json::Map<String, Value>,
148}
149
150fn parse_session_file(path: &Path) -> Result<LiveSession> {
151    let content = fs::read_to_string(path)?;
152    let value: Value = serde_json::from_str(&content).map_err(|e| Error::Artifacts {
153        message: format!("session registry {} is not valid JSON: {e}", path.display()),
154    })?;
155    let mut rest = match value {
156        Value::Object(map) => map,
157        _ => {
158            return Err(Error::Artifacts {
159                message: format!("session registry {} is not a JSON object", path.display()),
160            });
161        }
162    };
163    Ok(LiveSession {
164        pid: take_u64(&mut rest, "pid"),
165        session_id: take_string(&mut rest, "sessionId"),
166        cwd: take_string(&mut rest, "cwd"),
167        started_at_ms: take_u64(&mut rest, "startedAt"),
168        version: take_string(&mut rest, "version"),
169        kind: take_string(&mut rest, "kind"),
170        entrypoint: take_string(&mut rest, "entrypoint"),
171        name: take_string(&mut rest, "name"),
172        file_path: path.to_path_buf(),
173        rest,
174    })
175}
176
177/// Remove `key` when it holds a string; any other type stays in the
178/// map so it surfaces through `rest`.
179fn take_string(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
180    match map.remove(key) {
181        Some(Value::String(s)) => Some(s),
182        Some(other) => {
183            map.insert(key.to_string(), other);
184            None
185        }
186        None => None,
187    }
188}
189
190/// Remove `key` when it holds an unsigned integer; any other type
191/// stays in the map so it surfaces through `rest`.
192fn take_u64(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<u64> {
193    match map.remove(key) {
194        Some(v) => {
195            let n = v.as_u64();
196            if n.is_none() {
197                map.insert(key.to_string(), v);
198            }
199            n
200        }
201        None => None,
202    }
203}
204
205fn home_dir() -> Option<PathBuf> {
206    if let Ok(h) = std::env::var("HOME")
207        && !h.is_empty()
208    {
209        return Some(PathBuf::from(h));
210    }
211    if let Ok(h) = std::env::var("USERPROFILE")
212        && !h.is_empty()
213    {
214        return Some(PathBuf::from(h));
215    }
216    None
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn write_entry(root: &Path, stem: &str, contents: &str) {
224        fs::create_dir_all(root).unwrap();
225        fs::write(root.join(format!("{stem}.json")), contents).unwrap();
226    }
227
228    fn fixture_root() -> tempfile::TempDir {
229        let tmp = tempfile::tempdir().expect("tempdir");
230        write_entry(
231            tmp.path(),
232            "100",
233            r#"{"pid":100,"sessionId":"s-old","cwd":"/a","startedAt":1000,"version":"2.1.0","kind":"interactive","entrypoint":"cli","name":"old-1","peerProtocol":1}"#,
234        );
235        write_entry(
236            tmp.path(),
237            "200",
238            r#"{"pid":200,"sessionId":"s-new","cwd":"/b","startedAt":2000,"entrypoint":"claude-desktop"}"#,
239        );
240        write_entry(tmp.path(), "bad", r#"[1,2,3]"#);
241        tmp
242    }
243
244    #[test]
245    fn list_sorts_newest_first_and_parses_fields() {
246        let tmp = fixture_root();
247        let root = SessionsRoot::at(tmp.path());
248        let sessions = root.list().expect("list");
249        assert_eq!(sessions.len(), 2);
250        assert_eq!(sessions[0].session_id.as_deref(), Some("s-new"));
251        let old = &sessions[1];
252        assert_eq!(old.pid, Some(100));
253        assert_eq!(old.cwd.as_deref(), Some("/a"));
254        assert_eq!(old.started_at_ms, Some(1000));
255        assert_eq!(old.kind.as_deref(), Some("interactive"));
256        assert_eq!(old.entrypoint.as_deref(), Some("cli"));
257        assert_eq!(old.name.as_deref(), Some("old-1"));
258        assert_eq!(old.rest["peerProtocol"], 1);
259    }
260
261    #[test]
262    fn non_object_entries_are_skipped() {
263        let tmp = fixture_root();
264        let root = SessionsRoot::at(tmp.path());
265        // The "bad" entry is an array; only the two objects survive.
266        assert_eq!(root.list().expect("list").len(), 2);
267    }
268
269    #[test]
270    fn missing_root_reads_empty() {
271        let tmp = tempfile::tempdir().unwrap();
272        let root = SessionsRoot::at(tmp.path().join("does-not-exist"));
273        assert!(root.list().expect("ok").is_empty());
274    }
275
276    #[test]
277    fn mistyped_pid_stays_in_rest() {
278        let tmp = tempfile::tempdir().unwrap();
279        write_entry(tmp.path(), "1", r#"{"pid":"not-a-number","sessionId":"s"}"#);
280        let root = SessionsRoot::at(tmp.path());
281        let sessions = root.list().expect("list");
282        assert_eq!(sessions[0].pid, None);
283        assert_eq!(sessions[0].rest["pid"], "not-a-number");
284    }
285}