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
82pub(crate) fn projects_dir() -> Option<PathBuf> {
90 if let Some(dir) = std::env::var_os(PROJECTS_DIR_ENV) {
91 return Some(PathBuf::from(dir));
92 }
93 if let Some(dir) = std::env::var_os(CLAUDE_CONFIG_DIR_ENV) {
94 return Some(PathBuf::from(dir).join("projects"));
95 }
96 dirs::home_dir().map(|home| home.join(".claude").join("projects"))
97}
98
99fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
102 match now.duration_since(modified) {
103 Ok(elapsed) => elapsed <= RECENT_ACTIVITY_WINDOW,
104 Err(_) => true,
105 }
106}
107
108fn scan(root: &Path, state: &mut ScanState, now: SystemTime) -> Vec<Sighting> {
124 let mut sightings = Vec::new();
125 let Ok(project_dirs) = std::fs::read_dir(root) else {
126 return sightings;
127 };
128 for project in project_dirs.flatten() {
129 let Ok(files) = std::fs::read_dir(project.path()) else {
130 continue;
131 };
132 for file in files.flatten() {
133 let path = file.path();
134 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
135 continue;
136 }
137 let Some(session_id) = path
138 .file_stem()
139 .and_then(|s| s.to_str())
140 .filter(|s| !s.is_empty())
141 .map(str::to_string)
142 else {
143 continue;
144 };
145 let Ok(meta) = file.metadata() else {
146 continue;
147 };
148 let size = meta.len();
149 let recent = meta.modified().is_ok_and(|m| is_recent(m, now));
150 let previous = state.insert(path.clone(), size);
151 if !recent {
152 continue;
155 }
156 let event = match previous {
157 None => SessionEvent::TranscriptDiscovered,
158 Some(prev) if size > prev => SessionEvent::TranscriptGrew,
159 Some(_) => continue,
160 };
161 sightings.push(Sighting {
162 session_id,
163 transcript_path: path,
164 event,
165 });
166 }
167 }
168 sightings
169}
170
171pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
178 tokio::spawn(async move {
179 let Some(root) = projects_dir() else {
180 tracing::debug!("no Claude projects dir; sessions transcript watcher idle");
181 token.cancelled().await;
182 return;
183 };
184 tracing::debug!("sessions transcript watcher scanning {}", root.display());
185 let mut state = ScanState::new();
186 loop {
187 let scan_root = root.clone();
188 let mut owned_state = std::mem::take(&mut state);
192 let (returned_state, sightings) = tokio::task::spawn_blocking(move || {
193 let sightings = scan(&scan_root, &mut owned_state, SystemTime::now());
194 (owned_state, sightings)
195 })
196 .await
197 .unwrap_or_else(|_| (ScanState::new(), Vec::new()));
198 state = returned_state;
199 for sighting in sightings {
200 registry.observe(sighting.into_observe());
201 }
202 tokio::select! {
203 () = token.cancelled() => break,
204 () = tokio::time::sleep(WATCH_INTERVAL) => {}
205 }
206 }
207 })
208}
209
210#[cfg(test)]
211#[allow(clippy::unwrap_used, clippy::expect_used)]
212mod tests {
213 use super::*;
214 use std::io::Write;
215
216 fn write_transcript(root: &Path, project: &str, session: &str, contents: &[u8]) -> PathBuf {
218 let dir = root.join(project);
219 std::fs::create_dir_all(&dir).unwrap();
220 let path = dir.join(format!("{session}.jsonl"));
221 let mut f = std::fs::File::create(&path).unwrap();
222 f.write_all(contents).unwrap();
223 f.flush().unwrap();
224 path
225 }
226
227 #[test]
228 fn scan_discovers_then_detects_growth() {
229 let tmp = tempfile::tempdir().unwrap();
230 let root = tmp.path();
231 let now = SystemTime::now();
232 write_transcript(root, "-home-me-proj", "sess-1", b"line one\n");
233
234 let mut state = ScanState::new();
235 let first = scan(root, &mut state, now);
237 assert_eq!(first.len(), 1);
238 assert_eq!(first[0].session_id, "sess-1");
239 assert_eq!(first[0].event, SessionEvent::TranscriptDiscovered);
240
241 assert!(scan(root, &mut state, now).is_empty());
243
244 write_transcript(root, "-home-me-proj", "sess-1", b"line one\nline two\n");
246 let grew = scan(root, &mut state, now);
247 assert_eq!(grew.len(), 1);
248 assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
249 }
250
251 #[test]
252 fn scan_ignores_non_jsonl_and_empty_stems() {
253 let tmp = tempfile::tempdir().unwrap();
254 let root = tmp.path();
255 let now = SystemTime::now();
256 write_transcript(root, "proj", "notes", b"x"); std::fs::write(root.join("proj").join("readme.txt"), b"y").unwrap();
259 std::fs::write(root.join("proj").join(".jsonl"), b"z").unwrap();
260
261 let mut state = ScanState::new();
262 let sightings = scan(root, &mut state, now);
263 let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
264 assert_eq!(ids, vec!["notes"]);
265 }
266
267 #[test]
268 fn scan_does_not_announce_old_transcripts_but_records_them() {
269 let tmp = tempfile::tempdir().unwrap();
270 let root = tmp.path();
271 let path = write_transcript(root, "proj", "ancient", b"old\n");
272 let future = SystemTime::now() + Duration::from_secs(100_000);
274
275 let mut state = ScanState::new();
276 assert!(scan(root, &mut state, future).is_empty());
278 assert_eq!(state.get(&path).copied(), Some(4));
280 std::fs::write(&path, b"old\nresumed\n").unwrap();
283 let grew = scan(root, &mut state, SystemTime::now());
284 assert_eq!(grew.len(), 1);
285 assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
286 }
287
288 #[test]
289 fn scan_of_missing_root_is_empty() {
290 let mut state = ScanState::new();
291 let sightings = scan(
292 Path::new("/no/such/dir/omni-dev-test"),
293 &mut state,
294 SystemTime::now(),
295 );
296 assert!(sightings.is_empty());
297 }
298
299 #[test]
300 fn is_recent_window_boundaries() {
301 let now = SystemTime::now();
302 assert!(is_recent(now, now));
303 assert!(is_recent(now - Duration::from_secs(10), now));
304 assert!(!is_recent(now - Duration::from_secs(10_000), now));
305 assert!(is_recent(now + Duration::from_secs(60), now));
307 }
308
309 #[test]
310 fn projects_dir_prefers_explicit_override() {
311 let prev = std::env::var_os(PROJECTS_DIR_ENV);
313 std::env::set_var(PROJECTS_DIR_ENV, "/tmp/omni-dev-transcripts");
314 assert_eq!(
315 projects_dir(),
316 Some(PathBuf::from("/tmp/omni-dev-transcripts"))
317 );
318 match prev {
319 Some(v) => std::env::set_var(PROJECTS_DIR_ENV, v),
320 None => std::env::remove_var(PROJECTS_DIR_ENV),
321 }
322 }
323
324 #[tokio::test]
325 async fn spawned_watcher_feeds_the_registry_and_stops() {
326 let tmp = tempfile::tempdir().unwrap();
327 write_transcript(tmp.path(), "proj", "sess-live", b"hi\n");
328 std::env::set_var(PROJECTS_DIR_ENV, tmp.path());
330
331 let registry = Arc::new(SessionsRegistry::new());
332 let token = CancellationToken::new();
333 let handle = spawn(registry.clone(), token.clone());
334
335 let mut found = false;
337 for _ in 0..50 {
338 if registry.list().iter().any(|s| s.session_id == "sess-live") {
339 found = true;
340 break;
341 }
342 tokio::time::sleep(Duration::from_millis(20)).await;
343 }
344 token.cancel();
345 let _ = handle.await;
346 std::env::remove_var(PROJECTS_DIR_ENV);
347 assert!(found, "watcher should have discovered the transcript");
348 }
349
350 #[test]
351 fn scan_skips_loose_files_at_the_root() {
352 let tmp = tempfile::tempdir().unwrap();
355 let root = tmp.path();
356 std::fs::write(root.join("loose.txt"), b"not a project dir").unwrap();
357 write_transcript(root, "proj", "sess-1", b"line\n");
358
359 let mut state = ScanState::new();
360 let sightings = scan(root, &mut state, SystemTime::now());
361 let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
362 assert_eq!(ids, vec!["sess-1"], "the loose file must not surface");
363 }
364}