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 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
85pub(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
102fn 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
111fn 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
120fn 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 io_errors += 1;
150 continue;
151 };
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 io_errors += 1;
161 continue;
162 };
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 io_errors += 1;
176 continue;
177 };
179 let size = meta.len();
180 let recent = if let Ok(modified) = meta.modified() {
181 is_recent(modified, now)
182 } else {
183 io_errors += 1;
185 false
186 };
188 let previous = state.insert(path.clone(), size);
189 if !recent {
190 inactive += 1;
191 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
223pub 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 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 tracing::warn!(%error, outcome = "state_reset", "session_transcript_scan_failed");
252 (ScanState::new(), Vec::new())
253 });
254 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 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 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 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 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 assert!(scan(root, &mut state, now).is_empty());
351
352 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 write_transcript(root, "proj", "notes", b"x"); 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 let future = SystemTime::now() + Duration::from_secs(100_000);
382
383 let mut state = ScanState::new();
384 assert!(scan(root, &mut state, future).is_empty());
386 assert_eq!(state.get(&path).copied(), Some(4));
388 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 assert!(is_recent(now + Duration::from_secs(60), now));
415 }
416
417 #[test]
418 fn projects_dir_prefers_explicit_override() {
419 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 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 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 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}