//! Shared Claude Code transcript-path helpers. //! //! Claude Code records each conversation as a JSONL transcript under the user's //! home directory: //! //! ```text //! /.claude/projects//.jsonl //! ``` //! //! where `` is the agent's working directory with its path separators //! flattened to `-` (see [`encode_cwd`]). Both the [`ClaudeTranscriptInspector`] (which //! reads one known transcript) and the [`ClaudeTranscriptTurnWatcher`] (which watches the //! whole per-agent project dir) need to derive these paths, so the convention lives in //! **one** small, tested place: if Claude's exact encoding ever differs, only this seam //! changes. //! //! [`ClaudeTranscriptInspector`]: super::ClaudeTranscriptInspector //! [`ClaudeTranscriptTurnWatcher`]: super::ClaudeTranscriptTurnWatcher use domain::ports::{FileSystem, RemotePath}; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::ProjectPath; /// Encodes a cwd the way Claude Code names its per-project transcript folder. /// /// **Verified against real `~/.claude/projects/` folders:** Claude flattens the absolute /// cwd into a single directory segment by replacing **every character that is not an ASCII /// alphanumeric** (`[a-zA-Z0-9]`) with `-` — this is Claude Code's `replace(/[^a-zA-Z0-9]/g, /// '-')`. Crucially that means **`.` is encoded too**, not just path separators: a run dir /// like `/home/anthony/Documents/Projects/IdeA/.ideai/run/` becomes /// `-home-anthony-Documents-Projects-IdeA--ideai-run-` (the `/.` collapses to a /// **double** dash). The earlier version only replaced `/` and `\`, leaving the `.`, so it /// computed `-IdeA-.ideai-run-…` — a folder that never exists on disk; the turn-watcher /// then saw an empty dir (baseline 0, conversation ``) and never fired `turn_ended`, /// wedging the no-reply backstop. We keep this convention in one small, tested seam so a /// future Claude encoding change touches only here. pub(crate) fn encode_cwd(cwd: &str) -> String { cwd.chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect() } /// `/.claude/projects/` — the directory holding **all** of one /// agent's conversation transcripts for `cwd` (its isolated run dir). Trailing path /// separators on `home_dir` are trimmed first. pub(crate) fn claude_project_dir(home_dir: &str, cwd: &ProjectPath) -> RemotePath { let base = home_dir.trim_end_matches(['/', '\\']); let encoded = encode_cwd(cwd.as_str()); RemotePath::new(format!("{base}/.claude/projects/{encoded}")) } /// `/.claude/projects//.jsonl`. pub(crate) fn claude_transcript_path( home_dir: &str, conversation_id: &str, cwd: &ProjectPath, ) -> RemotePath { let dir = claude_project_dir(home_dir, cwd); RemotePath::new(format!("{}/{conversation_id}.jsonl", dir.0)) } /// Recognises a Claude profile by its conventional context file `CLAUDE.md`, mirroring /// the detection in `application::agent::lifecycle`. Shared by the inspector and the /// turn watcher so both agree on what they can read. pub(crate) fn supports_claude(profile: &AgentProfile) -> bool { matches!( &profile.context_injection, ContextInjection::ConventionFile { target } if target .rsplit(['/', '\\']) .next() .unwrap_or(target) .eq_ignore_ascii_case("CLAUDE.md") ) } /// Cumulative byte size of **all** `.jsonl` transcripts in a Claude agent's per-run-dir /// folder (`/.claude/projects//`) — a monotonic **activity token** for /// the `idea_ask_agent` rendezvous inactivity watchdog (server-side safety net). /// /// Why bytes, not the `turn_duration` count: a target working in **one long turn** never /// emits a `turn_duration` record until that turn ends, yet its transcript file keeps /// **growing** as the model streams output. Summing transcript bytes therefore detects /// "alive and working" even mid-turn — exactly the wedge the flat timeout mis-handled. /// Best-effort and side-effect-free: a missing folder (cold start) or any unreadable file /// contributes `0`; `None` only when the folder cannot be listed at all (treated by the /// watchdog as "no observable progress"). Lives beside the path encoder so the transcript /// layout knowledge stays in one tested seam. pub async fn transcript_activity_token( fs: &dyn FileSystem, home_dir: &str, cwd: &ProjectPath, ) -> Option { let dir = claude_project_dir(home_dir, cwd); let entries = fs.list(&dir).await.ok()?; let mut total: u64 = 0; for entry in entries { if entry.is_dir || !entry.name.ends_with(".jsonl") { continue; } let path = RemotePath::new(format!("{}/{}", dir.0, entry.name)); if let Ok(bytes) = fs.read(&path).await { total = total.saturating_add(bytes.len() as u64); } } Some(total) } #[cfg(test)] mod tests { use super::*; #[test] fn encode_cwd_flattens_separators_to_dash() { assert_eq!( encode_cwd("/home/anthony/Documents/Projects/IdeA"), "-home-anthony-Documents-Projects-IdeA" ); assert_eq!(encode_cwd("/a/b"), "-a-b"); // Every non-alphanumeric flattens to `-`, including `:` and `\` on Windows paths. assert_eq!(encode_cwd("C:\\Users\\me"), "C--Users-me"); } /// Non-regression for the no-reply backstop wedge: an IdeA agent's isolated run dir /// contains a `.` (`/.ideai/`), which Claude encodes as `-` just like `/`. The `/.` /// must therefore collapse to a **double** dash, matching the real on-disk folder /// `-home-anthony-Documents-Projects-IdeA--ideai-run-`. The earlier `/`-only /// encoding produced `-IdeA-.ideai-run-…` (nonexistent) and broke the turn watcher. #[test] fn encode_cwd_encodes_dot_in_run_dir_to_double_dash() { let uuid = "73c853d1-c0fd-463b-ad17-1d24fefa371f"; let cwd = format!("/home/anthony/Documents/Projects/IdeA/.ideai/run/{uuid}"); assert_eq!( encode_cwd(&cwd), format!("-home-anthony-Documents-Projects-IdeA--ideai-run-{uuid}") ); // The hyphens already present inside the UUID survive (they are non-alphanumeric, // re-encoded to the same `-`), so the segment matches byte-for-byte. assert!(encode_cwd(&cwd).ends_with(uuid)); } #[test] fn project_dir_and_transcript_path_compose() { let cwd = ProjectPath::new("/a/b").expect("valid path"); assert_eq!( claude_project_dir("/home/me/", &cwd).0, "/home/me/.claude/projects/-a-b" ); assert_eq!( claude_transcript_path("/home/me", "conv-1", &cwd).0, "/home/me/.claude/projects/-a-b/conv-1.jsonl" ); } }