//! [`ClaudeTranscriptTurnWatcher`] — the [`TurnWatcher`] adapter for the **Claude Code** //! CLI (rendez-vous no-reply backstop). //! //! It replaces the dead PTY prompt-ready sniff as the **end-of-turn** authority. Claude //! records each completed turn as a `turn_duration` entry in its JSONL transcript; this //! adapter **tail-polls** the agent's per-run-dir transcript folder //! (`/.claude/projects//`) every [`POLL_INTERVAL`] and fires the //! [`OnTurnEnd`] callback each time the **aggregate** count of `turn_duration` records //! grows above the **baseline** captured at arm time. //! //! ## Why the whole folder, not one file //! //! The transcript file is named by Claude's **engine session id** (the resumable), which //! is unknown at cold start (the file does not exist yet) and is **not** the IdeA pair //! `conversation_id`. Since every IdeA agent runs in its **own isolated run dir** //! (`.ideai/run//`), its `` folder belongs to exactly **one** //! agent: aggregating `turn_duration` across the `.jsonl` files in that folder is a sound //! per-agent turn-end signal that needs neither the engine id nor file mtimes (the //! [`domain::ports::FileSystem`] port exposes neither). //! //! ## Robustness (cadrage pièges) //! //! - **Baseline at arm** ⇒ a pre-existing transcript (relaunch / background wake) never //! fires a phantom turn end (cf. `mcp-e2e-findings-reply-wedge-phantom-busy`). //! - **Cold start**: the folder/file may not exist yet ⇒ treated as count `0`, the watch //! simply waits for it to appear (the first turn is also covered by the MCP-initialize //! cold-start release). //! - **Rotation / truncation**: if the aggregate count ever **drops**, the baseline is //! reset to the new (lower) value — best-effort, never fires spuriously. //! - **Poll, not inotify** ⇒ portable inside the AppImage. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use domain::ids::AgentId; use domain::ports::{FileSystem, OnTurnEnd, RemotePath, TurnWatchHandle, TurnWatcher}; use domain::profile::AgentProfile; use domain::project::ProjectPath; use super::claude_paths::{claude_project_dir, supports_claude}; /// Tail-poll interval (cadrage : 250-500 ms). 300 ms balances latency vs. cost. const POLL_INTERVAL: Duration = Duration::from_millis(300); /// Substring marking a completed turn in a Claude transcript line. const TURN_END_MARKER: &str = "turn_duration"; /// [`TurnWatcher`] reading Claude Code transcripts. Composes a [`FileSystem`] port (so it /// stays OS- and Tauri-agnostic and trivially testable) plus the base directory standing /// in for the user's home (`/.claude/projects/`). #[derive(Clone)] pub struct ClaudeTranscriptTurnWatcher { fs: Arc, home_dir: String, } impl ClaudeTranscriptTurnWatcher { /// Builds the watcher from an injected [`FileSystem`] and the user's home directory /// (resolved by the composition root, e.g. from `$HOME`). #[must_use] pub fn new(fs: Arc, home_dir: impl Into) -> Self { Self { fs, home_dir: home_dir.into(), } } } /// Handle whose [`Drop`] stops the polling task **effectively and idempotently**: it /// flips the shared stop flag (observed at the top *and* bottom of each loop iteration) /// **and** aborts the task (so an in-flight `await` cannot fire after the drop). struct ClaudeTurnWatchHandle { stop: Arc, task: tokio::task::JoinHandle<()>, } impl TurnWatchHandle for ClaudeTurnWatchHandle {} impl Drop for ClaudeTurnWatchHandle { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); self.task.abort(); } } impl TurnWatcher for ClaudeTranscriptTurnWatcher { fn supports(&self, profile: &AgentProfile) -> bool { supports_claude(profile) } fn watch( &self, agent: AgentId, conversation_id: Option, cwd: ProjectPath, on_turn_end: OnTurnEnd, ) -> Box { let fs = Arc::clone(&self.fs); let dir = claude_project_dir(&self.home_dir, &cwd); let stop = Arc::new(AtomicBool::new(false)); let stop_task = Arc::clone(&stop); application::diag!( "[turn-watcher] arm agent={agent} dir={} conversation={}", dir.0, conversation_id.as_deref().unwrap_or(""), ); let task = tokio::spawn(async move { // Baseline = turn ends already present at arm time (never re-fired). let mut last = count_turn_ends(fs.as_ref(), &dir).await; application::diag!("[turn-watcher] baseline agent={agent} count={last}"); loop { if stop_task.load(Ordering::SeqCst) { break; } tokio::time::sleep(POLL_INTERVAL).await; if stop_task.load(Ordering::SeqCst) { break; } let current = count_turn_ends(fs.as_ref(), &dir).await; if current < last { // Rotation / truncation : re-baseline (best-effort, no spurious fire). last = current; continue; } if current > last { application::diag!( "[turn-watcher] turn_duration detected agent={agent} count {last}->{current} -> turn_ended" ); last = current; // Callback invoked from the polling task, holding no lock (it ends up // in `InputMediator::turn_ended`). One fire per detected increase: a // burst still ends the currently-busy turn; an idle agent is a no-op. on_turn_end(agent); } } }); Box::new(ClaudeTurnWatchHandle { stop, task }) } } /// Aggregate count of `turn_duration` records across **all** `.jsonl` transcripts in the /// agent's per-run-dir folder. A missing folder/file (cold start) ⇒ `0`. Unreadable /// files are skipped (best-effort, never fatal). async fn count_turn_ends(fs: &dyn FileSystem, dir: &RemotePath) -> usize { let Ok(entries) = fs.list(dir).await else { return 0; // folder not created yet (cold start) or transient error. }; let mut total = 0usize; for entry in entries { if entry.is_dir || !entry.name.ends_with(".jsonl") { continue; } let path = RemotePath::new(format!("{}/{}", dir.0, entry.name)); let Ok(bytes) = fs.read(&path).await else { continue; }; total += count_marker_lines(&bytes); } total } /// Counts transcript **lines** containing the [`TURN_END_MARKER`] (one logical turn end /// per `turn_duration` record). Substring match on the raw bytes — cheap and tolerant of /// partial/unparseable lines. fn count_marker_lines(body: &[u8]) -> usize { String::from_utf8_lossy(body) .lines() .filter(|line| line.contains(TURN_END_MARKER)) .count() } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use domain::ports::{DirEntry, FsError}; use std::collections::HashMap; use std::sync::Mutex; /// In-memory [`FileSystem`] double: a flat map of path → bytes; `list` returns the /// basenames whose parent is the queried dir. Only `read`/`list` are exercised. #[derive(Default)] struct FakeFs { files: Mutex>>, } impl FakeFs { fn set(&self, path: &str, body: &str) { self.files .lock() .unwrap() .insert(path.to_owned(), body.as_bytes().to_vec()); } } #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.files .lock() .unwrap() .get(&path.0) .cloned() .ok_or_else(|| FsError::NotFound(path.0.clone())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { self.files.lock().unwrap().insert(path.0.clone(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { Ok(self.files.lock().unwrap().contains_key(&path.0)) } async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { Ok(()) } async fn list(&self, path: &RemotePath) -> Result, FsError> { let prefix = format!("{}/", path.0); let files = self.files.lock().unwrap(); let mut out = Vec::new(); for key in files.keys() { if let Some(rest) = key.strip_prefix(&prefix) { if !rest.contains('/') { out.push(DirEntry { name: rest.to_owned(), is_dir: false, }); } } } if out.is_empty() && !files.keys().any(|k| k.starts_with(&prefix)) { return Err(FsError::NotFound(path.0.clone())); } Ok(out) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } fn agent(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } fn line(extra: &str) -> String { format!("{{\"type\":\"result\",\"{extra}\":1}}\n") } /// The run dir `/run/a` encodes to `-run-a` ⇒ folder `/.claude/projects/-run-a`. fn transcript_path(name: &str) -> String { format!("/home/me/.claude/projects/-run-a/{name}") } fn count_calls() -> (Arc>>, OnTurnEnd) { let log: Arc>> = Arc::new(Mutex::new(Vec::new())); let log2 = Arc::clone(&log); let cb: OnTurnEnd = Arc::new(move |a: AgentId| log2.lock().unwrap().push(a)); (log, cb) } async fn wait_until(mut cond: impl FnMut() -> bool) -> bool { for _ in 0..100 { if cond() { return true; } tokio::time::sleep(Duration::from_millis(20)).await; } cond() } #[test] fn count_marker_lines_counts_only_turn_duration_lines() { let body = concat!( "{\"type\":\"user\"}\n", "{\"type\":\"result\",\"turn_duration\":12}\n", "garbage line\n", "{\"type\":\"result\",\"turn_duration\":7}\n", ); assert_eq!(count_marker_lines(body.as_bytes()), 2); } #[tokio::test] async fn fires_on_new_turn_duration_above_baseline() { let fs = Arc::new(FakeFs::default()); // Baseline: one pre-existing turn end ⇒ must NOT fire for it. fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration")); let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); let (log, cb) = count_calls(); let cwd = ProjectPath::new("/run/a").expect("path"); let _h = watcher.watch(agent(1), None, cwd, cb); // Append a second turn end ⇒ exactly one fire. tokio::time::sleep(Duration::from_millis(50)).await; fs.set( &transcript_path("engine-1.jsonl"), &format!("{}{}", line("turn_duration"), line("turn_duration")), ); assert!( wait_until(|| !log.lock().unwrap().is_empty()).await, "a new turn_duration above baseline fires turn_ended" ); assert_eq!(log.lock().unwrap().as_slice(), &[agent(1)]); } #[tokio::test] async fn pre_existing_transcript_never_fires_phantom() { let fs = Arc::new(FakeFs::default()); fs.set( &transcript_path("engine-1.jsonl"), &format!("{}{}", line("turn_duration"), line("turn_duration")), ); let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); let (log, cb) = count_calls(); let cwd = ProjectPath::new("/run/a").expect("path"); let _h = watcher.watch(agent(1), None, cwd, cb); // No new turn end appended ⇒ no fire, ever (baseline absorbs the existing ones). tokio::time::sleep(Duration::from_millis(120)).await; assert!(log.lock().unwrap().is_empty(), "baseline must absorb pre-existing turn ends"); } #[tokio::test] async fn cold_start_missing_folder_then_first_turn_fires() { let fs = Arc::new(FakeFs::default()); let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); let (log, cb) = count_calls(); let cwd = ProjectPath::new("/run/a").expect("path"); let _h = watcher.watch(agent(1), None, cwd, cb); // Folder/file appear later (cold start), then a first turn end is written. tokio::time::sleep(Duration::from_millis(40)).await; fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration")); assert!( wait_until(|| !log.lock().unwrap().is_empty()).await, "a first turn end after a cold start fires (baseline was 0)" ); } #[tokio::test] async fn dropping_handle_stops_polling() { let fs = Arc::new(FakeFs::default()); let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc, "/home/me"); let (log, cb) = count_calls(); let cwd = ProjectPath::new("/run/a").expect("path"); let h = watcher.watch(agent(1), None, cwd, cb); drop(h); // stop the watch. // A turn end written after the drop must not fire. tokio::time::sleep(Duration::from_millis(40)).await; fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration")); tokio::time::sleep(Duration::from_millis(120)).await; assert!(log.lock().unwrap().is_empty(), "a dropped handle stops firing"); } }