Skip to main content

git_worktree_manager/operations/
claude_session.rs

1//! Hard-tier in-use signal: detects active Claude Code sessions in a
2//! worktree by inspecting `~/.claude/projects/<encoded>/*.jsonl` event tails.
3//!
4//! Encoding rule mirrors Claude Code's own: replace `/` and `.` with `-`,
5//! drop trailing slash. Verified empirically against `~/.claude/projects/`
6//! contents during design.
7
8use std::fs::File;
9use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
11
12use chrono::{DateTime, Utc};
13
14/// Encode an absolute filesystem path to the directory name Claude Code
15/// uses under `~/.claude/projects/`. `/` and `.` become `-`. Trailing
16/// path separators are trimmed.
17pub fn encode_project_dir(path: &Path) -> String {
18    let s = path.to_string_lossy();
19    let trimmed = s.trim_end_matches('/');
20    trimmed.replace(['/', '.'], "-")
21}
22
23/// Read up to ~64 KiB from the end of `path`, find the newest line that
24/// parses as JSON with a `timestamp` field, and return both the timestamp and
25/// the optional `cwd` field from that same event (parsed in a single pass).
26///
27/// Returns `None` for empty files, files containing only metadata events
28/// without `timestamp`, or unreadable / unparseable files.
29pub fn newest_event(path: &Path) -> Option<(DateTime<Utc>, Option<PathBuf>)> {
30    const TAIL_BYTES: u64 = 64 * 1024;
31
32    let mut f = File::open(path).ok()?;
33    let len = f.metadata().ok()?.len();
34    let start = len.saturating_sub(TAIL_BYTES);
35    f.seek(SeekFrom::Start(start)).ok()?;
36
37    let mut buf = Vec::new();
38    f.read_to_end(&mut buf).ok()?;
39
40    // Drop the first (possibly partial) line if we did not start at byte 0.
41    let mut slice = buf.as_slice();
42    if start != 0 {
43        if let Some(nl) = slice.iter().position(|&b| b == b'\n') {
44            slice = &slice[nl + 1..];
45        }
46    }
47
48    let reader = BufReader::new(slice);
49    let mut latest: Option<(DateTime<Utc>, Option<PathBuf>)> = None;
50    for line in reader.lines().map_while(Result::ok) {
51        let trimmed = line.trim();
52        if trimmed.is_empty() {
53            continue;
54        }
55        let v: serde_json::Value = match serde_json::from_str(trimmed) {
56            Ok(v) => v,
57            Err(_) => continue,
58        };
59        let ts_str = match v.get("timestamp").and_then(|x| x.as_str()) {
60            Some(s) => s,
61            None => continue,
62        };
63        let ts = match DateTime::parse_from_rfc3339(ts_str) {
64            Ok(t) => t.with_timezone(&Utc),
65            Err(_) => continue,
66        };
67        let cwd = v.get("cwd").and_then(|x| x.as_str()).map(PathBuf::from);
68        match latest {
69            Some((prev, _)) if prev >= ts => {}
70            _ => latest = Some((ts, cwd)),
71        }
72    }
73    latest
74}
75
76/// Read up to ~64 KiB from the end of `path`, find the newest line that
77/// parses as JSON with a `timestamp` field, and return that timestamp.
78/// Returns `None` for empty files, files containing only metadata events
79/// without `timestamp`, or unreadable / unparseable files.
80#[inline]
81pub fn newest_event_timestamp(path: &Path) -> Option<DateTime<Utc>> {
82    newest_event(path).map(|(ts, _)| ts)
83}
84
85/// Information about one active Claude Code session in a worktree.
86#[derive(Debug, Clone)]
87pub struct ActiveSession {
88    /// jsonl filename without extension (matches Claude session UUID).
89    pub session_id: String,
90    /// Wall-clock time of the most recent event with a `timestamp` field.
91    pub last_activity: DateTime<Utc>,
92}
93
94/// Return all sessions in `project_dir` whose newest event timestamp is
95/// within `threshold` of now AND whose newest event `cwd` (if present)
96/// matches `worktree`. Missing/unreadable directories return an empty vec
97/// — the caller treats this as "Claude not in use here."
98pub fn find_active_sessions(
99    project_dir: &Path,
100    worktree: &Path,
101    threshold: chrono::Duration,
102) -> Vec<ActiveSession> {
103    let entries = match std::fs::read_dir(project_dir) {
104        Ok(e) => e,
105        Err(_) => return Vec::new(),
106    };
107    let now = Utc::now();
108    let wt_canon = worktree
109        .canonicalize()
110        .unwrap_or_else(|_| worktree.to_path_buf());
111    let mut out = Vec::new();
112    for entry in entries.flatten() {
113        let path = entry.path();
114        if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
115            continue;
116        }
117        // Parse the file once: get both the timestamp and cwd together.
118        let Some((ts, maybe_cwd)) = newest_event(&path) else {
119            continue;
120        };
121        if (now - ts) > threshold {
122            continue;
123        }
124        // Require explicit cwd confirmation to guard against encode_project_dir
125        // collisions (e.g. `/foo/bar` and `/foo-bar` produce the same encoded
126        // directory name). Sessions with no cwd field are treated as non-matching.
127        let reported_cwd = match maybe_cwd {
128            Some(p) => p,
129            None => continue,
130        };
131        let reported_canon = reported_cwd.canonicalize().unwrap_or(reported_cwd);
132        if reported_canon != wt_canon {
133            continue;
134        }
135        let id = path
136            .file_stem()
137            .and_then(|s| s.to_str())
138            .unwrap_or("")
139            .to_string();
140        out.push(ActiveSession {
141            session_id: id,
142            last_activity: ts,
143        });
144    }
145    out
146}
147
148/// Resolve the per-worktree Claude projects directory, e.g.
149/// `~/.claude/projects/-Users-dave-Projects-foo`. Returns `None` if
150/// `$HOME` is not set / cannot be resolved.
151pub fn project_dir_for(worktree: &Path) -> Option<PathBuf> {
152    let home = crate::constants::home_dir_or_fallback();
153    let canon = worktree
154        .canonicalize()
155        .unwrap_or_else(|_| worktree.to_path_buf());
156    let encoded = encode_project_dir(&canon);
157    Some(home.join(".claude").join("projects").join(encoded))
158}
159
160/// Extract the `cwd` field from the newest event in the jsonl file.
161/// Returns `None` if the file has no events with both `timestamp` and `cwd`.
162/// This is a thin wrapper around [`newest_event`] to avoid parsing the file twice.
163#[inline]
164pub fn newest_event_cwd(path: &Path) -> Option<PathBuf> {
165    newest_event(path).and_then(|(_, cwd)| cwd)
166}