Skip to main content

omni_dev/sessions/
watcher.rs

1//! The transcript watcher (Feed 2): an engine-owned background task that scans
2//! `~/.claude/projects/**/*.jsonl` for new and growing session transcripts and
3//! feeds the [`SessionsRegistry`].
4//!
5//! It exists to cover the two gaps a purely hook-driven feed leaves:
6//!
7//! 1. **Discovery** — a session that started before the daemon (or before hooks
8//!    were installed) fires no `SessionStart` the daemon can see; its transcript
9//!    file still appears, so the watcher discovers it.
10//! 2. **The thinking window** — between `UserPromptSubmit` and the first
11//!    `PreToolUse` (~5–15s) no hook fires, but the transcript keeps growing, so
12//!    the watcher marks the session `working` through the gap.
13//!
14//! Per ADR-0052 the watcher parses **only file presence and growth (size/mtime)**
15//! — never the per-line transcript schema, which is explicitly internal and
16//! version-unstable. The `session_id` comes from the filename stem; `cwd` is left
17//! unknown (the encoded directory name is a lossy `/`→`-` transform that cannot be
18//! reliably reversed — the hook feed supplies the real `cwd`, and
19//! [`SessionsRegistry::observe`] never clobbers a known `cwd` with `None`).
20//!
21//! Only transcripts touched within [`RECENT_ACTIVITY_WINDOW`] are surfaced, so a
22//! fresh daemon does not flood the registry with hundreds of long-dead historical
23//! sessions on its first scan — ancient files are recorded (for later
24//! growth comparison) but never announced.
25
26use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use std::time::{Duration, SystemTime};
30
31use tokio::task::JoinHandle;
32use tokio_util::sync::CancellationToken;
33
34use super::{ObserveRequest, SessionEvent, SessionsRegistry};
35
36/// Environment override for the Claude config directory, mirroring Claude Code's
37/// own `CLAUDE_CONFIG_DIR`. When unset the watcher uses `~/.claude`.
38const CLAUDE_CONFIG_DIR_ENV: &str = "CLAUDE_CONFIG_DIR";
39
40/// Direct override for the transcripts root, used by tests (and as an escape
41/// hatch) to point the watcher at an arbitrary directory.
42const PROJECTS_DIR_ENV: &str = "OMNI_DEV_CLAUDE_PROJECTS_DIR";
43
44/// How often the watcher rescans the transcripts tree. Short enough to catch the
45/// thinking window (~5–15s) without stat-ing the tree wastefully.
46const WATCH_INTERVAL: Duration = Duration::from_secs(5);
47
48/// A transcript must have been modified within this window to be surfaced as a
49/// discovery/growth sighting. Matches the registry's session TTL: a file touched
50/// this recently corresponds to a session the registry would still hold live.
51/// Older files are recorded silently so a later resume still registers as growth.
52const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
53
54/// The last observed size of a transcript file, keyed by path — the watcher's
55/// only persistent state across scans, used to detect growth.
56type ScanState = HashMap<PathBuf, u64>;
57
58/// A single sighting produced by a scan: which session, its transcript path, and
59/// whether it was newly discovered or seen to grow.
60#[derive(Debug, Clone, PartialEq, Eq)]
61struct Sighting {
62    session_id: String,
63    transcript_path: PathBuf,
64    event: SessionEvent,
65}
66
67impl Sighting {
68    /// Converts the sighting into the registry ingest request. `cwd`/`repo`/
69    /// `model` are unknown to the watcher and left for the hook feed to fill.
70    fn into_observe(self) -> ObserveRequest {
71        ObserveRequest {
72            session_id: self.session_id,
73            cwd: None,
74            transcript_path: Some(self.transcript_path),
75            event: self.event,
76            repo: None,
77            model: None,
78        }
79    }
80}
81
82/// The transcripts root: `$OMNI_DEV_CLAUDE_PROJECTS_DIR` (test/escape override),
83/// else `$CLAUDE_CONFIG_DIR/projects`, else `~/.claude/projects`. `None` only
84/// when no home directory can be resolved and no override is set.
85///
86/// `pub(crate)` so `src/sessions/relocate.rs` (the worktrees UI's "move/copy
87/// Claude session here" action, issue #1585 Phase 2) can resolve the same
88/// root without a second copy of this precedence chain.
89pub(crate) fn projects_dir() -> Option<PathBuf> {
90    if let Some(dir) = std::env::var_os(PROJECTS_DIR_ENV) {
91        return Some(PathBuf::from(dir));
92    }
93    if let Some(dir) = std::env::var_os(CLAUDE_CONFIG_DIR_ENV) {
94        return Some(PathBuf::from(dir).join("projects"));
95    }
96    dirs::home_dir().map(|home| home.join(".claude").join("projects"))
97}
98
99/// Whether `modified` is within [`RECENT_ACTIVITY_WINDOW`] of `now`. A file whose
100/// mtime is in the future (clock skew) counts as recent.
101fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
102    match now.duration_since(modified) {
103        Ok(elapsed) => elapsed <= RECENT_ACTIVITY_WINDOW,
104        Err(_) => true,
105    }
106}
107
108/// Scans `root` for `*.jsonl` transcripts and returns the sightings since the
109/// previous scan, updating `state` (path → last size) in place.
110///
111/// The layout is `root/<encoded-cwd>/<session-id>.jsonl`; the watcher walks the
112/// two levels with `read_dir` (no external walker, no new dependency). A file is
113/// surfaced only when it was modified within [`RECENT_ACTIVITY_WINDOW`]:
114///
115/// - unseen path → [`SessionEvent::TranscriptDiscovered`] (and its size recorded);
116/// - larger than last seen → [`SessionEvent::TranscriptGrew`];
117/// - unchanged → nothing.
118///
119/// An older-than-window file has its size recorded but is not surfaced, so the
120/// first scan of a long-lived `~/.claude/projects` does not announce every
121/// historical session. Pure and side-effect-free apart from `state`, so it is
122/// unit-tested against a temp directory.
123fn scan(root: &Path, state: &mut ScanState, now: SystemTime) -> Vec<Sighting> {
124    let mut sightings = Vec::new();
125    let Ok(project_dirs) = std::fs::read_dir(root) else {
126        return sightings;
127    };
128    for project in project_dirs.flatten() {
129        let Ok(files) = std::fs::read_dir(project.path()) else {
130            continue;
131        };
132        for file in files.flatten() {
133            let path = file.path();
134            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
135                continue;
136            }
137            let Some(session_id) = path
138                .file_stem()
139                .and_then(|s| s.to_str())
140                .filter(|s| !s.is_empty())
141                .map(str::to_string)
142            else {
143                continue;
144            };
145            let Ok(meta) = file.metadata() else {
146                continue;
147            };
148            let size = meta.len();
149            let recent = meta.modified().is_ok_and(|m| is_recent(m, now));
150            let previous = state.insert(path.clone(), size);
151            if !recent {
152                // Record the size (for a future growth comparison) but do not
153                // announce an inactive session.
154                continue;
155            }
156            let event = match previous {
157                None => SessionEvent::TranscriptDiscovered,
158                Some(prev) if size > prev => SessionEvent::TranscriptGrew,
159                Some(_) => continue,
160            };
161            sightings.push(Sighting {
162                session_id,
163                transcript_path: path,
164                event,
165            });
166        }
167    }
168    sightings
169}
170
171/// Spawns the watcher loop, returning its [`JoinHandle`].
172///
173/// The loop rescans every [`WATCH_INTERVAL`] and feeds every sighting to
174/// `registry`, until `token` is cancelled. A no-op loop when no transcripts root
175/// can be resolved (it parks on the cancel token). Must be called from within a
176/// tokio runtime.
177pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
178    tokio::spawn(async move {
179        let Some(root) = projects_dir() else {
180            tracing::debug!("no Claude projects dir; sessions transcript watcher idle");
181            token.cancelled().await;
182            return;
183        };
184        tracing::debug!("sessions transcript watcher scanning {}", root.display());
185        let mut state = ScanState::new();
186        loop {
187            let scan_root = root.clone();
188            // File stat is blocking disk I/O, so run each scan on a blocking
189            // thread; the state map moves in and back out so it persists across
190            // scans without a lock.
191            let mut owned_state = std::mem::take(&mut state);
192            let (returned_state, sightings) = tokio::task::spawn_blocking(move || {
193                let sightings = scan(&scan_root, &mut owned_state, SystemTime::now());
194                (owned_state, sightings)
195            })
196            .await
197            .unwrap_or_else(|_| (ScanState::new(), Vec::new()));
198            state = returned_state;
199            for sighting in sightings {
200                registry.observe(sighting.into_observe());
201            }
202            tokio::select! {
203                () = token.cancelled() => break,
204                () = tokio::time::sleep(WATCH_INTERVAL) => {}
205            }
206        }
207    })
208}
209
210#[cfg(test)]
211#[allow(clippy::unwrap_used, clippy::expect_used)]
212mod tests {
213    use super::*;
214    use std::io::Write;
215
216    /// Creates `root/<project>/<session>.jsonl` with `contents`, returning its path.
217    fn write_transcript(root: &Path, project: &str, session: &str, contents: &[u8]) -> PathBuf {
218        let dir = root.join(project);
219        std::fs::create_dir_all(&dir).unwrap();
220        let path = dir.join(format!("{session}.jsonl"));
221        let mut f = std::fs::File::create(&path).unwrap();
222        f.write_all(contents).unwrap();
223        f.flush().unwrap();
224        path
225    }
226
227    #[test]
228    fn scan_discovers_then_detects_growth() {
229        let tmp = tempfile::tempdir().unwrap();
230        let root = tmp.path();
231        let now = SystemTime::now();
232        write_transcript(root, "-home-me-proj", "sess-1", b"line one\n");
233
234        let mut state = ScanState::new();
235        // First scan: the recent file is discovered.
236        let first = scan(root, &mut state, now);
237        assert_eq!(first.len(), 1);
238        assert_eq!(first[0].session_id, "sess-1");
239        assert_eq!(first[0].event, SessionEvent::TranscriptDiscovered);
240
241        // A second scan with no change surfaces nothing.
242        assert!(scan(root, &mut state, now).is_empty());
243
244        // Growth is detected.
245        write_transcript(root, "-home-me-proj", "sess-1", b"line one\nline two\n");
246        let grew = scan(root, &mut state, now);
247        assert_eq!(grew.len(), 1);
248        assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
249    }
250
251    #[test]
252    fn scan_ignores_non_jsonl_and_empty_stems() {
253        let tmp = tempfile::tempdir().unwrap();
254        let root = tmp.path();
255        let now = SystemTime::now();
256        // A non-jsonl file and a dotfile with an empty stem are both skipped.
257        write_transcript(root, "proj", "notes", b"x"); // notes.jsonl — valid
258        std::fs::write(root.join("proj").join("readme.txt"), b"y").unwrap();
259        std::fs::write(root.join("proj").join(".jsonl"), b"z").unwrap();
260
261        let mut state = ScanState::new();
262        let sightings = scan(root, &mut state, now);
263        let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
264        assert_eq!(ids, vec!["notes"]);
265    }
266
267    #[test]
268    fn scan_does_not_announce_old_transcripts_but_records_them() {
269        let tmp = tempfile::tempdir().unwrap();
270        let root = tmp.path();
271        let path = write_transcript(root, "proj", "ancient", b"old\n");
272        // Pretend "now" is far in the future, so the file is outside the window.
273        let future = SystemTime::now() + Duration::from_secs(100_000);
274
275        let mut state = ScanState::new();
276        // Not surfaced (the file looks old relative to the far-future "now")...
277        assert!(scan(root, &mut state, future).is_empty());
278        // ...but its size was recorded, so a later real growth is caught.
279        assert_eq!(state.get(&path).copied(), Some(4));
280        // The resume rewrites the file, giving it a fresh (real) mtime, so a scan
281        // at real time surfaces the growth even though the file predated the daemon.
282        std::fs::write(&path, b"old\nresumed\n").unwrap();
283        let grew = scan(root, &mut state, SystemTime::now());
284        assert_eq!(grew.len(), 1);
285        assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
286    }
287
288    #[test]
289    fn scan_of_missing_root_is_empty() {
290        let mut state = ScanState::new();
291        let sightings = scan(
292            Path::new("/no/such/dir/omni-dev-test"),
293            &mut state,
294            SystemTime::now(),
295        );
296        assert!(sightings.is_empty());
297    }
298
299    #[test]
300    fn is_recent_window_boundaries() {
301        let now = SystemTime::now();
302        assert!(is_recent(now, now));
303        assert!(is_recent(now - Duration::from_secs(10), now));
304        assert!(!is_recent(now - Duration::from_secs(10_000), now));
305        // A future mtime (clock skew) counts as recent.
306        assert!(is_recent(now + Duration::from_secs(60), now));
307    }
308
309    #[test]
310    fn projects_dir_prefers_explicit_override() {
311        // The direct override wins regardless of other env; restore afterwards.
312        let prev = std::env::var_os(PROJECTS_DIR_ENV);
313        std::env::set_var(PROJECTS_DIR_ENV, "/tmp/omni-dev-transcripts");
314        assert_eq!(
315            projects_dir(),
316            Some(PathBuf::from("/tmp/omni-dev-transcripts"))
317        );
318        match prev {
319            Some(v) => std::env::set_var(PROJECTS_DIR_ENV, v),
320            None => std::env::remove_var(PROJECTS_DIR_ENV),
321        }
322    }
323
324    #[tokio::test]
325    async fn spawned_watcher_feeds_the_registry_and_stops() {
326        let tmp = tempfile::tempdir().unwrap();
327        write_transcript(tmp.path(), "proj", "sess-live", b"hi\n");
328        // Point the watcher at the temp tree.
329        std::env::set_var(PROJECTS_DIR_ENV, tmp.path());
330
331        let registry = Arc::new(SessionsRegistry::new());
332        let token = CancellationToken::new();
333        let handle = spawn(registry.clone(), token.clone());
334
335        // Poll until the first scan lands the session (the loop scans immediately).
336        let mut found = false;
337        for _ in 0..50 {
338            if registry.list().iter().any(|s| s.session_id == "sess-live") {
339                found = true;
340                break;
341            }
342            tokio::time::sleep(Duration::from_millis(20)).await;
343        }
344        token.cancel();
345        let _ = handle.await;
346        std::env::remove_var(PROJECTS_DIR_ENV);
347        assert!(found, "watcher should have discovered the transcript");
348    }
349
350    #[test]
351    fn scan_skips_loose_files_at_the_root() {
352        // A non-directory entry directly under the root (not a project dir) is
353        // skipped — `read_dir` on it fails and the scan continues.
354        let tmp = tempfile::tempdir().unwrap();
355        let root = tmp.path();
356        std::fs::write(root.join("loose.txt"), b"not a project dir").unwrap();
357        write_transcript(root, "proj", "sess-1", b"line\n");
358
359        let mut state = ScanState::new();
360        let sightings = scan(root, &mut state, SystemTime::now());
361        let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
362        assert_eq!(ids, vec!["sess-1"], "the loose file must not surface");
363    }
364}