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            agent_id: None,
73            pid: None,
74            agent: crate::sessions::Agent::Claude,
75            session_id: self.session_id,
76            cwd: None,
77            transcript_path: Some(self.transcript_path),
78            event: self.event,
79            repo: None,
80            model: None,
81        }
82    }
83}
84
85/// The transcripts root: `$OMNI_DEV_CLAUDE_PROJECTS_DIR` (test/escape override),
86/// else `$CLAUDE_CONFIG_DIR/projects`, else `~/.claude/projects`. `None` only
87/// when no home directory can be resolved and no override is set.
88///
89/// `pub(crate)` so `src/sessions/relocate.rs` (the worktrees UI's "move/copy
90/// Claude session here" action, issue #1585 Phase 2) can resolve the same
91/// root without a second copy of this precedence chain.
92pub(crate) fn projects_dir() -> Option<PathBuf> {
93    if let Some(dir) = std::env::var_os(PROJECTS_DIR_ENV) {
94        return Some(PathBuf::from(dir));
95    }
96    if let Some(dir) = std::env::var_os(CLAUDE_CONFIG_DIR_ENV) {
97        return Some(PathBuf::from(dir).join("projects"));
98    }
99    dirs::home_dir().map(|home| home.join(".claude").join("projects"))
100}
101
102/// Whether `modified` is within [`RECENT_ACTIVITY_WINDOW`] of `now`. A file whose
103/// mtime is in the future (clock skew) counts as recent.
104fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
105    match now.duration_since(modified) {
106        Ok(elapsed) => elapsed <= RECENT_ACTIVITY_WINDOW,
107        Err(_) => true,
108    }
109}
110
111/// The session id a transcript path names: its file stem, when that is
112/// non-empty UTF-8 (a session id is a UUID, so anything else is not one).
113fn session_id_of(path: &Path) -> Option<String> {
114    path.file_stem()
115        .and_then(|s| s.to_str())
116        .filter(|s| !s.is_empty())
117        .map(str::to_string)
118}
119
120/// Scans `root` for `*.jsonl` transcripts and returns the sightings since the
121/// previous scan, updating `state` (path → last size) in place.
122///
123/// The layout is `root/<encoded-cwd>/<session-id>.jsonl`; the watcher walks the
124/// two levels with `read_dir` (no external walker, no new dependency). A file is
125/// surfaced only when it was modified within [`RECENT_ACTIVITY_WINDOW`]:
126///
127/// - unseen path → [`SessionEvent::TranscriptDiscovered`] (and its size recorded);
128/// - larger than last seen → [`SessionEvent::TranscriptGrew`];
129/// - unchanged → nothing.
130///
131/// An older-than-window file has its size recorded but is not surfaced, so the
132/// first scan of a long-lived `~/.claude/projects` does not announce every
133/// historical session. Pure and side-effect-free apart from `state`, so it is
134/// unit-tested against a temp directory.
135fn scan(root: &Path, state: &mut ScanState, now: SystemTime) -> Vec<Sighting> {
136    let mut sightings = Vec::new();
137    let mut scanned = 0_u64;
138    let mut io_errors = 0_u64;
139    let mut invalid_names = 0_u64;
140    let mut inactive = 0_u64;
141    let mut unchanged = 0_u64;
142    let Ok(project_dirs) = std::fs::read_dir(root) else {
143        tracing::debug!(outcome = "root_unreadable", "session_transcript_scan");
144        return sightings;
145    };
146    for project in project_dirs {
147        let Ok(project) = project else {
148            // omni-dev: coverage ignore reason="read_dir yields an Err entry only on an I/O fault (EIO, a vanished directory mid-iteration), which a test cannot provoke; the scan counts it and moves on, the same handling as the unreadable project directory below"
149            io_errors += 1;
150            continue;
151            // omni-dev: coverage end
152        };
153        let Ok(files) = std::fs::read_dir(project.path()) else {
154            io_errors += 1;
155            continue;
156        };
157        for file in files {
158            let Ok(file) = file else {
159                // omni-dev: coverage ignore reason="read_dir yields an Err entry only on an I/O fault (EIO, a vanished directory mid-iteration), which a test cannot provoke; the scan counts it and moves on, the same handling as the unreadable project directory above"
160                io_errors += 1;
161                continue;
162                // omni-dev: coverage end
163            };
164            let path = file.path();
165            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
166                continue;
167            }
168            scanned += 1;
169            let Some(session_id) = session_id_of(&path) else {
170                invalid_names += 1;
171                continue;
172            };
173            let Ok(meta) = file.metadata() else {
174                // omni-dev: coverage ignore reason="DirEntry::metadata does not follow symlinks, so it fails only when the entry vanished between read_dir and the stat, a race a test cannot provoke; the scan counts it and moves on"
175                io_errors += 1;
176                continue;
177                // omni-dev: coverage end
178            };
179            let size = meta.len();
180            let recent = if let Ok(modified) = meta.modified() {
181                is_recent(modified, now)
182            } else {
183                // omni-dev: coverage ignore reason="Metadata::modified fails only on a platform with no mtime, and every supported one (Linux, macOS) has it"
184                io_errors += 1;
185                false
186                // omni-dev: coverage end
187            };
188            let previous = state.insert(path.clone(), size);
189            if !recent {
190                inactive += 1;
191                // Record the size (for a future growth comparison) but do not
192                // announce an inactive session.
193                continue;
194            }
195            let event = match previous {
196                None => SessionEvent::TranscriptDiscovered,
197                Some(prev) if size > prev => SessionEvent::TranscriptGrew,
198                Some(_) => {
199                    unchanged += 1;
200                    continue;
201                }
202            };
203            sightings.push(Sighting {
204                session_id,
205                transcript_path: path,
206                event,
207            });
208        }
209    }
210    let found = sightings.len();
211    tracing::debug!(
212        scanned,
213        sightings = found,
214        io_errors,
215        invalid_names,
216        inactive,
217        unchanged,
218        "session_transcript_scan"
219    );
220    sightings
221}
222
223/// Spawns the watcher loop, returning its [`JoinHandle`].
224///
225/// The loop rescans every [`WATCH_INTERVAL`] and feeds every sighting to
226/// `registry`, until `token` is cancelled. A no-op loop when no transcripts root
227/// can be resolved (it parks on the cancel token). Must be called from within a
228/// tokio runtime.
229pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
230    tokio::spawn(async move {
231        let Some(root) = projects_dir() else {
232            tracing::debug!("no Claude projects dir; sessions transcript watcher idle");
233            token.cancelled().await;
234            return;
235        };
236        tracing::debug!("sessions transcript watcher scanning {}", root.display());
237        let mut state = ScanState::new();
238        loop {
239            let scan_root = root.clone();
240            // File stat is blocking disk I/O, so run each scan on a blocking
241            // thread; the state map moves in and back out so it persists across
242            // scans without a lock.
243            let mut owned_state = std::mem::take(&mut state);
244            let (returned_state, sightings) = tokio::task::spawn_blocking(move || {
245                let sightings = scan(&scan_root, &mut owned_state, SystemTime::now());
246                (owned_state, sightings)
247            })
248            .await
249            .unwrap_or_else(|error| {
250                // omni-dev: coverage ignore reason="spawn_blocking's JoinError needs the scan closure to panic or the runtime to shut down mid-scan; scan has no panicking path, and the runtime outlives the watcher, whose token is cancelled first"
251                tracing::warn!(%error, outcome = "state_reset", "session_transcript_scan_failed");
252                (ScanState::new(), Vec::new())
253            });
254            // omni-dev: coverage end
255            state = returned_state;
256            for sighting in sightings {
257                registry.observe(sighting.into_observe());
258            }
259            tokio::select! {
260                () = token.cancelled() => break,
261                () = tokio::time::sleep(WATCH_INTERVAL) => {}
262            }
263        }
264    })
265}
266
267#[cfg(test)]
268#[allow(clippy::unwrap_used, clippy::expect_used)]
269mod tests {
270    use super::*;
271    use std::io::Write;
272
273    #[test]
274    fn scan_diagnostics_summarize_candidates_and_unchanged_files() {
275        let dir = tempfile::tempdir().unwrap();
276        write_transcript(dir.path(), "project", "session", b"hello");
277        let mut state = ScanState::new();
278        let logs = crate::test_support::capture_at(tracing::Level::DEBUG, || {
279            assert_eq!(scan(dir.path(), &mut state, SystemTime::now()).len(), 1);
280            assert!(scan(dir.path(), &mut state, SystemTime::now()).is_empty());
281        });
282        assert_eq!(logs.matches("session_transcript_scan").count(), 2);
283        assert!(logs.contains("scanned=1"));
284        assert!(logs.contains("unchanged=1"));
285    }
286
287    #[test]
288    fn a_session_id_is_the_non_empty_utf8_file_stem() {
289        assert_eq!(
290            session_id_of(Path::new("/p/abc-123.jsonl")).as_deref(),
291            Some("abc-123")
292        );
293        // No file name at all, so no stem.
294        assert_eq!(session_id_of(Path::new("/")), None);
295    }
296
297    #[cfg(unix)]
298    #[test]
299    fn scan_counts_a_non_utf8_transcript_name_and_skips_it() {
300        use std::os::unix::ffi::OsStrExt;
301        let dir = tempfile::tempdir().unwrap();
302        let project = dir.path().join("project");
303        std::fs::create_dir_all(&project).unwrap();
304        let name = std::ffi::OsStr::from_bytes(b"\xff\xfe.jsonl");
305        // macOS filesystems refuse names that are not valid UTF-8; Linux accepts them.
306        if std::fs::write(project.join(name), b"x").is_err() {
307            return;
308        }
309        let mut state = ScanState::new();
310        let logs = crate::test_support::capture_at(tracing::Level::DEBUG, || {
311            assert!(scan(dir.path(), &mut state, SystemTime::now()).is_empty());
312        });
313        assert!(logs.contains("invalid_names=1"), "{logs}");
314    }
315
316    #[cfg(unix)]
317    #[test]
318    fn a_non_utf8_file_name_is_not_a_session_id() {
319        use std::os::unix::ffi::OsStrExt;
320        let path = Path::new(std::ffi::OsStr::from_bytes(b"/p/\xff\xfe.jsonl"));
321        assert_eq!(session_id_of(path), None);
322    }
323
324    /// Creates `root/<project>/<session>.jsonl` with `contents`, returning its path.
325    fn write_transcript(root: &Path, project: &str, session: &str, contents: &[u8]) -> PathBuf {
326        let dir = root.join(project);
327        std::fs::create_dir_all(&dir).unwrap();
328        let path = dir.join(format!("{session}.jsonl"));
329        let mut f = std::fs::File::create(&path).unwrap();
330        f.write_all(contents).unwrap();
331        f.flush().unwrap();
332        path
333    }
334
335    #[test]
336    fn scan_discovers_then_detects_growth() {
337        let tmp = tempfile::tempdir().unwrap();
338        let root = tmp.path();
339        let now = SystemTime::now();
340        write_transcript(root, "-home-me-proj", "sess-1", b"line one\n");
341
342        let mut state = ScanState::new();
343        // First scan: the recent file is discovered.
344        let first = scan(root, &mut state, now);
345        assert_eq!(first.len(), 1);
346        assert_eq!(first[0].session_id, "sess-1");
347        assert_eq!(first[0].event, SessionEvent::TranscriptDiscovered);
348
349        // A second scan with no change surfaces nothing.
350        assert!(scan(root, &mut state, now).is_empty());
351
352        // Growth is detected.
353        write_transcript(root, "-home-me-proj", "sess-1", b"line one\nline two\n");
354        let grew = scan(root, &mut state, now);
355        assert_eq!(grew.len(), 1);
356        assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
357    }
358
359    #[test]
360    fn scan_ignores_non_jsonl_and_empty_stems() {
361        let tmp = tempfile::tempdir().unwrap();
362        let root = tmp.path();
363        let now = SystemTime::now();
364        // A non-jsonl file and a dotfile with an empty stem are both skipped.
365        write_transcript(root, "proj", "notes", b"x"); // notes.jsonl — valid
366        std::fs::write(root.join("proj").join("readme.txt"), b"y").unwrap();
367        std::fs::write(root.join("proj").join(".jsonl"), b"z").unwrap();
368
369        let mut state = ScanState::new();
370        let sightings = scan(root, &mut state, now);
371        let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
372        assert_eq!(ids, vec!["notes"]);
373    }
374
375    #[test]
376    fn scan_does_not_announce_old_transcripts_but_records_them() {
377        let tmp = tempfile::tempdir().unwrap();
378        let root = tmp.path();
379        let path = write_transcript(root, "proj", "ancient", b"old\n");
380        // Pretend "now" is far in the future, so the file is outside the window.
381        let future = SystemTime::now() + Duration::from_secs(100_000);
382
383        let mut state = ScanState::new();
384        // Not surfaced (the file looks old relative to the far-future "now")...
385        assert!(scan(root, &mut state, future).is_empty());
386        // ...but its size was recorded, so a later real growth is caught.
387        assert_eq!(state.get(&path).copied(), Some(4));
388        // The resume rewrites the file, giving it a fresh (real) mtime, so a scan
389        // at real time surfaces the growth even though the file predated the daemon.
390        std::fs::write(&path, b"old\nresumed\n").unwrap();
391        let grew = scan(root, &mut state, SystemTime::now());
392        assert_eq!(grew.len(), 1);
393        assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
394    }
395
396    #[test]
397    fn scan_of_missing_root_is_empty() {
398        let mut state = ScanState::new();
399        let sightings = scan(
400            Path::new("/no/such/dir/omni-dev-test"),
401            &mut state,
402            SystemTime::now(),
403        );
404        assert!(sightings.is_empty());
405    }
406
407    #[test]
408    fn is_recent_window_boundaries() {
409        let now = SystemTime::now();
410        assert!(is_recent(now, now));
411        assert!(is_recent(now - Duration::from_secs(10), now));
412        assert!(!is_recent(now - Duration::from_secs(10_000), now));
413        // A future mtime (clock skew) counts as recent.
414        assert!(is_recent(now + Duration::from_secs(60), now));
415    }
416
417    #[test]
418    fn projects_dir_prefers_explicit_override() {
419        // The direct override wins regardless of other env; restore afterwards.
420        let prev = std::env::var_os(PROJECTS_DIR_ENV);
421        std::env::set_var(PROJECTS_DIR_ENV, "/tmp/omni-dev-transcripts");
422        assert_eq!(
423            projects_dir(),
424            Some(PathBuf::from("/tmp/omni-dev-transcripts"))
425        );
426        match prev {
427            Some(v) => std::env::set_var(PROJECTS_DIR_ENV, v),
428            None => std::env::remove_var(PROJECTS_DIR_ENV),
429        }
430    }
431
432    #[tokio::test]
433    async fn spawned_watcher_feeds_the_registry_and_stops() {
434        let tmp = tempfile::tempdir().unwrap();
435        write_transcript(tmp.path(), "proj", "sess-live", b"hi\n");
436        // Point the watcher at the temp tree.
437        std::env::set_var(PROJECTS_DIR_ENV, tmp.path());
438
439        let registry = Arc::new(SessionsRegistry::new());
440        let token = CancellationToken::new();
441        let handle = spawn(registry.clone(), token.clone());
442
443        // Poll until the first scan lands the session (the loop scans immediately).
444        let mut found = false;
445        for _ in 0..50 {
446            if registry.list().iter().any(|s| s.session_id == "sess-live") {
447                found = true;
448                break;
449            }
450            tokio::time::sleep(Duration::from_millis(20)).await;
451        }
452        token.cancel();
453        let _ = handle.await;
454        std::env::remove_var(PROJECTS_DIR_ENV);
455        assert!(found, "watcher should have discovered the transcript");
456    }
457
458    #[test]
459    fn scan_skips_loose_files_at_the_root() {
460        // A non-directory entry directly under the root (not a project dir) is
461        // skipped — `read_dir` on it fails and the scan continues.
462        let tmp = tempfile::tempdir().unwrap();
463        let root = tmp.path();
464        std::fs::write(root.join("loose.txt"), b"not a project dir").unwrap();
465        write_transcript(root, "proj", "sess-1", b"line\n");
466
467        let mut state = ScanState::new();
468        let sightings = scan(root, &mut state, SystemTime::now());
469        let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
470        assert_eq!(ids, vec!["sess-1"], "the loose file must not surface");
471    }
472}