1use 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
36const CLAUDE_CONFIG_DIR_ENV: &str = "CLAUDE_CONFIG_DIR";
39
40const PROJECTS_DIR_ENV: &str = "OMNI_DEV_CLAUDE_PROJECTS_DIR";
43
44const WATCH_INTERVAL: Duration = Duration::from_secs(5);
47
48const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
53
54type ScanState = HashMap<PathBuf, u64>;
57
58#[derive(Debug, Clone, PartialEq, Eq)]
61struct Sighting {
62 session_id: String,
63 transcript_path: PathBuf,
64 event: SessionEvent,
65}
66
67impl Sighting {
68 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
82fn 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
95fn 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
104fn 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 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
167pub 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 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 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 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 assert!(scan(root, &mut state, now).is_empty());
239
240 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 write_transcript(root, "proj", "notes", b"x"); 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 let future = SystemTime::now() + Duration::from_secs(100_000);
270
271 let mut state = ScanState::new();
272 assert!(scan(root, &mut state, future).is_empty());
274 assert_eq!(state.get(&path).copied(), Some(4));
276 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 assert!(is_recent(now + Duration::from_secs(60), now));
303 }
304
305 #[test]
306 fn projects_dir_prefers_explicit_override() {
307 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 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 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 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}