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.
85fn projects_dir() -> Option<PathBuf> {
86    if let Some(dir) = std::env::var_os(PROJECTS_DIR_ENV) {
87        return Some(PathBuf::from(dir));
88    }
89    if let Some(dir) = std::env::var_os(CLAUDE_CONFIG_DIR_ENV) {
90        return Some(PathBuf::from(dir).join("projects"));
91    }
92    dirs::home_dir().map(|home| home.join(".claude").join("projects"))
93}
94
95/// Whether `modified` is within [`RECENT_ACTIVITY_WINDOW`] of `now`. A file whose
96/// mtime is in the future (clock skew) counts as recent.
97fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
98    match now.duration_since(modified) {
99        Ok(elapsed) => elapsed <= RECENT_ACTIVITY_WINDOW,
100        Err(_) => true,
101    }
102}
103
104/// Scans `root` for `*.jsonl` transcripts and returns the sightings since the
105/// previous scan, updating `state` (path → last size) in place.
106///
107/// The layout is `root/<encoded-cwd>/<session-id>.jsonl`; the watcher walks the
108/// two levels with `read_dir` (no external walker, no new dependency). A file is
109/// surfaced only when it was modified within [`RECENT_ACTIVITY_WINDOW`]:
110///
111/// - unseen path → [`SessionEvent::TranscriptDiscovered`] (and its size recorded);
112/// - larger than last seen → [`SessionEvent::TranscriptGrew`];
113/// - unchanged → nothing.
114///
115/// An older-than-window file has its size recorded but is not surfaced, so the
116/// first scan of a long-lived `~/.claude/projects` does not announce every
117/// historical session. Pure and side-effect-free apart from `state`, so it is
118/// unit-tested against a temp directory.
119fn scan(root: &Path, state: &mut ScanState, now: SystemTime) -> Vec<Sighting> {
120    let mut sightings = Vec::new();
121    let Ok(project_dirs) = std::fs::read_dir(root) else {
122        return sightings;
123    };
124    for project in project_dirs.flatten() {
125        let Ok(files) = std::fs::read_dir(project.path()) else {
126            continue;
127        };
128        for file in files.flatten() {
129            let path = file.path();
130            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
131                continue;
132            }
133            let Some(session_id) = path
134                .file_stem()
135                .and_then(|s| s.to_str())
136                .filter(|s| !s.is_empty())
137                .map(str::to_string)
138            else {
139                continue;
140            };
141            let Ok(meta) = file.metadata() else {
142                continue;
143            };
144            let size = meta.len();
145            let recent = meta.modified().is_ok_and(|m| is_recent(m, now));
146            let previous = state.insert(path.clone(), size);
147            if !recent {
148                // Record the size (for a future growth comparison) but do not
149                // announce an inactive session.
150                continue;
151            }
152            let event = match previous {
153                None => SessionEvent::TranscriptDiscovered,
154                Some(prev) if size > prev => SessionEvent::TranscriptGrew,
155                Some(_) => continue,
156            };
157            sightings.push(Sighting {
158                session_id,
159                transcript_path: path,
160                event,
161            });
162        }
163    }
164    sightings
165}
166
167/// Spawns the watcher loop, returning its [`JoinHandle`].
168///
169/// The loop rescans every [`WATCH_INTERVAL`] and feeds every sighting to
170/// `registry`, until `token` is cancelled. A no-op loop when no transcripts root
171/// can be resolved (it parks on the cancel token). Must be called from within a
172/// tokio runtime.
173pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
174    tokio::spawn(async move {
175        let Some(root) = projects_dir() else {
176            tracing::debug!("no Claude projects dir; sessions transcript watcher idle");
177            token.cancelled().await;
178            return;
179        };
180        tracing::debug!("sessions transcript watcher scanning {}", root.display());
181        let mut state = ScanState::new();
182        loop {
183            let scan_root = root.clone();
184            // File stat is blocking disk I/O, so run each scan on a blocking
185            // thread; the state map moves in and back out so it persists across
186            // scans without a lock.
187            let mut owned_state = std::mem::take(&mut state);
188            let (returned_state, sightings) = tokio::task::spawn_blocking(move || {
189                let sightings = scan(&scan_root, &mut owned_state, SystemTime::now());
190                (owned_state, sightings)
191            })
192            .await
193            .unwrap_or_else(|_| (ScanState::new(), Vec::new()));
194            state = returned_state;
195            for sighting in sightings {
196                registry.observe(sighting.into_observe());
197            }
198            tokio::select! {
199                () = token.cancelled() => break,
200                () = tokio::time::sleep(WATCH_INTERVAL) => {}
201            }
202        }
203    })
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used, clippy::expect_used)]
208mod tests {
209    use super::*;
210    use std::io::Write;
211
212    /// Creates `root/<project>/<session>.jsonl` with `contents`, returning its path.
213    fn write_transcript(root: &Path, project: &str, session: &str, contents: &[u8]) -> PathBuf {
214        let dir = root.join(project);
215        std::fs::create_dir_all(&dir).unwrap();
216        let path = dir.join(format!("{session}.jsonl"));
217        let mut f = std::fs::File::create(&path).unwrap();
218        f.write_all(contents).unwrap();
219        f.flush().unwrap();
220        path
221    }
222
223    #[test]
224    fn scan_discovers_then_detects_growth() {
225        let tmp = tempfile::tempdir().unwrap();
226        let root = tmp.path();
227        let now = SystemTime::now();
228        write_transcript(root, "-home-me-proj", "sess-1", b"line one\n");
229
230        let mut state = ScanState::new();
231        // First scan: the recent file is discovered.
232        let first = scan(root, &mut state, now);
233        assert_eq!(first.len(), 1);
234        assert_eq!(first[0].session_id, "sess-1");
235        assert_eq!(first[0].event, SessionEvent::TranscriptDiscovered);
236
237        // A second scan with no change surfaces nothing.
238        assert!(scan(root, &mut state, now).is_empty());
239
240        // Growth is detected.
241        write_transcript(root, "-home-me-proj", "sess-1", b"line one\nline two\n");
242        let grew = scan(root, &mut state, now);
243        assert_eq!(grew.len(), 1);
244        assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
245    }
246
247    #[test]
248    fn scan_ignores_non_jsonl_and_empty_stems() {
249        let tmp = tempfile::tempdir().unwrap();
250        let root = tmp.path();
251        let now = SystemTime::now();
252        // A non-jsonl file and a dotfile with an empty stem are both skipped.
253        write_transcript(root, "proj", "notes", b"x"); // notes.jsonl — valid
254        std::fs::write(root.join("proj").join("readme.txt"), b"y").unwrap();
255        std::fs::write(root.join("proj").join(".jsonl"), b"z").unwrap();
256
257        let mut state = ScanState::new();
258        let sightings = scan(root, &mut state, now);
259        let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
260        assert_eq!(ids, vec!["notes"]);
261    }
262
263    #[test]
264    fn scan_does_not_announce_old_transcripts_but_records_them() {
265        let tmp = tempfile::tempdir().unwrap();
266        let root = tmp.path();
267        let path = write_transcript(root, "proj", "ancient", b"old\n");
268        // Pretend "now" is far in the future, so the file is outside the window.
269        let future = SystemTime::now() + Duration::from_secs(100_000);
270
271        let mut state = ScanState::new();
272        // Not surfaced (the file looks old relative to the far-future "now")...
273        assert!(scan(root, &mut state, future).is_empty());
274        // ...but its size was recorded, so a later real growth is caught.
275        assert_eq!(state.get(&path).copied(), Some(4));
276        // The resume rewrites the file, giving it a fresh (real) mtime, so a scan
277        // at real time surfaces the growth even though the file predated the daemon.
278        std::fs::write(&path, b"old\nresumed\n").unwrap();
279        let grew = scan(root, &mut state, SystemTime::now());
280        assert_eq!(grew.len(), 1);
281        assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
282    }
283
284    #[test]
285    fn scan_of_missing_root_is_empty() {
286        let mut state = ScanState::new();
287        let sightings = scan(
288            Path::new("/no/such/dir/omni-dev-test"),
289            &mut state,
290            SystemTime::now(),
291        );
292        assert!(sightings.is_empty());
293    }
294
295    #[test]
296    fn is_recent_window_boundaries() {
297        let now = SystemTime::now();
298        assert!(is_recent(now, now));
299        assert!(is_recent(now - Duration::from_secs(10), now));
300        assert!(!is_recent(now - Duration::from_secs(10_000), now));
301        // A future mtime (clock skew) counts as recent.
302        assert!(is_recent(now + Duration::from_secs(60), now));
303    }
304
305    #[test]
306    fn projects_dir_prefers_explicit_override() {
307        // The direct override wins regardless of other env; restore afterwards.
308        let prev = std::env::var_os(PROJECTS_DIR_ENV);
309        std::env::set_var(PROJECTS_DIR_ENV, "/tmp/omni-dev-transcripts");
310        assert_eq!(
311            projects_dir(),
312            Some(PathBuf::from("/tmp/omni-dev-transcripts"))
313        );
314        match prev {
315            Some(v) => std::env::set_var(PROJECTS_DIR_ENV, v),
316            None => std::env::remove_var(PROJECTS_DIR_ENV),
317        }
318    }
319
320    #[tokio::test]
321    async fn spawned_watcher_feeds_the_registry_and_stops() {
322        let tmp = tempfile::tempdir().unwrap();
323        write_transcript(tmp.path(), "proj", "sess-live", b"hi\n");
324        // Point the watcher at the temp tree.
325        std::env::set_var(PROJECTS_DIR_ENV, tmp.path());
326
327        let registry = Arc::new(SessionsRegistry::new());
328        let token = CancellationToken::new();
329        let handle = spawn(registry.clone(), token.clone());
330
331        // Poll until the first scan lands the session (the loop scans immediately).
332        let mut found = false;
333        for _ in 0..50 {
334            if registry.list().iter().any(|s| s.session_id == "sess-live") {
335                found = true;
336                break;
337            }
338            tokio::time::sleep(Duration::from_millis(20)).await;
339        }
340        token.cancel();
341        let _ = handle.await;
342        std::env::remove_var(PROJECTS_DIR_ENV);
343        assert!(found, "watcher should have discovered the transcript");
344    }
345
346    #[test]
347    fn scan_skips_loose_files_at_the_root() {
348        // A non-directory entry directly under the root (not a project dir) is
349        // skipped — `read_dir` on it fails and the scan continues.
350        let tmp = tempfile::tempdir().unwrap();
351        let root = tmp.path();
352        std::fs::write(root.join("loose.txt"), b"not a project dir").unwrap();
353        write_transcript(root, "proj", "sess-1", b"line\n");
354
355        let mut state = ScanState::new();
356        let sightings = scan(root, &mut state, SystemTime::now());
357        let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
358        assert_eq!(ids, vec!["sess-1"], "the loose file must not surface");
359    }
360}