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