use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use super::{ObserveRequest, SessionEvent, SessionsRegistry};
const CLAUDE_CONFIG_DIR_ENV: &str = "CLAUDE_CONFIG_DIR";
const PROJECTS_DIR_ENV: &str = "OMNI_DEV_CLAUDE_PROJECTS_DIR";
const WATCH_INTERVAL: Duration = Duration::from_secs(5);
const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
type ScanState = HashMap<PathBuf, u64>;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Sighting {
session_id: String,
transcript_path: PathBuf,
event: SessionEvent,
}
impl Sighting {
fn into_observe(self) -> ObserveRequest {
ObserveRequest {
session_id: self.session_id,
cwd: None,
transcript_path: Some(self.transcript_path),
event: self.event,
repo: None,
model: None,
}
}
}
fn projects_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os(PROJECTS_DIR_ENV) {
return Some(PathBuf::from(dir));
}
if let Some(dir) = std::env::var_os(CLAUDE_CONFIG_DIR_ENV) {
return Some(PathBuf::from(dir).join("projects"));
}
dirs::home_dir().map(|home| home.join(".claude").join("projects"))
}
fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
match now.duration_since(modified) {
Ok(elapsed) => elapsed <= RECENT_ACTIVITY_WINDOW,
Err(_) => true,
}
}
fn scan(root: &Path, state: &mut ScanState, now: SystemTime) -> Vec<Sighting> {
let mut sightings = Vec::new();
let Ok(project_dirs) = std::fs::read_dir(root) else {
return sightings;
};
for project in project_dirs.flatten() {
let Ok(files) = std::fs::read_dir(project.path()) else {
continue;
};
for file in files.flatten() {
let path = file.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let Some(session_id) = path
.file_stem()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
else {
continue;
};
let Ok(meta) = file.metadata() else {
continue;
};
let size = meta.len();
let recent = meta.modified().is_ok_and(|m| is_recent(m, now));
let previous = state.insert(path.clone(), size);
if !recent {
continue;
}
let event = match previous {
None => SessionEvent::TranscriptDiscovered,
Some(prev) if size > prev => SessionEvent::TranscriptGrew,
Some(_) => continue,
};
sightings.push(Sighting {
session_id,
transcript_path: path,
event,
});
}
}
sightings
}
pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
tokio::spawn(async move {
let Some(root) = projects_dir() else {
tracing::debug!("no Claude projects dir; sessions transcript watcher idle");
token.cancelled().await;
return;
};
tracing::debug!("sessions transcript watcher scanning {}", root.display());
let mut state = ScanState::new();
loop {
let scan_root = root.clone();
let mut owned_state = std::mem::take(&mut state);
let (returned_state, sightings) = tokio::task::spawn_blocking(move || {
let sightings = scan(&scan_root, &mut owned_state, SystemTime::now());
(owned_state, sightings)
})
.await
.unwrap_or_else(|_| (ScanState::new(), Vec::new()));
state = returned_state;
for sighting in sightings {
registry.observe(sighting.into_observe());
}
tokio::select! {
() = token.cancelled() => break,
() = tokio::time::sleep(WATCH_INTERVAL) => {}
}
}
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::io::Write;
fn write_transcript(root: &Path, project: &str, session: &str, contents: &[u8]) -> PathBuf {
let dir = root.join(project);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{session}.jsonl"));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(contents).unwrap();
f.flush().unwrap();
path
}
#[test]
fn scan_discovers_then_detects_growth() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let now = SystemTime::now();
write_transcript(root, "-home-me-proj", "sess-1", b"line one\n");
let mut state = ScanState::new();
let first = scan(root, &mut state, now);
assert_eq!(first.len(), 1);
assert_eq!(first[0].session_id, "sess-1");
assert_eq!(first[0].event, SessionEvent::TranscriptDiscovered);
assert!(scan(root, &mut state, now).is_empty());
write_transcript(root, "-home-me-proj", "sess-1", b"line one\nline two\n");
let grew = scan(root, &mut state, now);
assert_eq!(grew.len(), 1);
assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
}
#[test]
fn scan_ignores_non_jsonl_and_empty_stems() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let now = SystemTime::now();
write_transcript(root, "proj", "notes", b"x"); std::fs::write(root.join("proj").join("readme.txt"), b"y").unwrap();
std::fs::write(root.join("proj").join(".jsonl"), b"z").unwrap();
let mut state = ScanState::new();
let sightings = scan(root, &mut state, now);
let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
assert_eq!(ids, vec!["notes"]);
}
#[test]
fn scan_does_not_announce_old_transcripts_but_records_them() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let path = write_transcript(root, "proj", "ancient", b"old\n");
let future = SystemTime::now() + Duration::from_secs(100_000);
let mut state = ScanState::new();
assert!(scan(root, &mut state, future).is_empty());
assert_eq!(state.get(&path).copied(), Some(4));
std::fs::write(&path, b"old\nresumed\n").unwrap();
let grew = scan(root, &mut state, SystemTime::now());
assert_eq!(grew.len(), 1);
assert_eq!(grew[0].event, SessionEvent::TranscriptGrew);
}
#[test]
fn scan_of_missing_root_is_empty() {
let mut state = ScanState::new();
let sightings = scan(
Path::new("/no/such/dir/omni-dev-test"),
&mut state,
SystemTime::now(),
);
assert!(sightings.is_empty());
}
#[test]
fn is_recent_window_boundaries() {
let now = SystemTime::now();
assert!(is_recent(now, now));
assert!(is_recent(now - Duration::from_secs(10), now));
assert!(!is_recent(now - Duration::from_secs(10_000), now));
assert!(is_recent(now + Duration::from_secs(60), now));
}
#[test]
fn projects_dir_prefers_explicit_override() {
let prev = std::env::var_os(PROJECTS_DIR_ENV);
std::env::set_var(PROJECTS_DIR_ENV, "/tmp/omni-dev-transcripts");
assert_eq!(
projects_dir(),
Some(PathBuf::from("/tmp/omni-dev-transcripts"))
);
match prev {
Some(v) => std::env::set_var(PROJECTS_DIR_ENV, v),
None => std::env::remove_var(PROJECTS_DIR_ENV),
}
}
#[tokio::test]
async fn spawned_watcher_feeds_the_registry_and_stops() {
let tmp = tempfile::tempdir().unwrap();
write_transcript(tmp.path(), "proj", "sess-live", b"hi\n");
std::env::set_var(PROJECTS_DIR_ENV, tmp.path());
let registry = Arc::new(SessionsRegistry::new());
let token = CancellationToken::new();
let handle = spawn(registry.clone(), token.clone());
let mut found = false;
for _ in 0..50 {
if registry.list().iter().any(|s| s.session_id == "sess-live") {
found = true;
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
token.cancel();
let _ = handle.await;
std::env::remove_var(PROJECTS_DIR_ENV);
assert!(found, "watcher should have discovered the transcript");
}
#[test]
fn scan_skips_loose_files_at_the_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("loose.txt"), b"not a project dir").unwrap();
write_transcript(root, "proj", "sess-1", b"line\n");
let mut state = ScanState::new();
let sightings = scan(root, &mut state, SystemTime::now());
let ids: Vec<&str> = sightings.iter().map(|s| s.session_id.as_str()).collect();
assert_eq!(ids, vec!["sess-1"], "the loose file must not surface");
}
}