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 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
84pub(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
101fn 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
110fn 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 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
173pub 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 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 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 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 assert!(scan(root, &mut state, now).is_empty());
245
246 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 write_transcript(root, "proj", "notes", b"x"); 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 let future = SystemTime::now() + Duration::from_secs(100_000);
276
277 let mut state = ScanState::new();
278 assert!(scan(root, &mut state, future).is_empty());
280 assert_eq!(state.get(&path).copied(), Some(4));
282 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 assert!(is_recent(now + Duration::from_secs(60), now));
309 }
310
311 #[test]
312 fn projects_dir_prefers_explicit_override() {
313 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 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 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 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}