diff --git a/crates/infrastructure/src/inspector/claude_paths.rs b/crates/infrastructure/src/inspector/claude_paths.rs index 0761b47..0371b12 100644 --- a/crates/infrastructure/src/inspector/claude_paths.rs +++ b/crates/infrastructure/src/inspector/claude_paths.rs @@ -23,18 +23,20 @@ use domain::project::ProjectPath; /// Encodes a cwd the way Claude Code names its per-project transcript folder. /// -/// **Assumption (documented, isolated, and unit-tested):** Claude flattens the -/// absolute cwd into a single directory segment by replacing each path separator -/// (`/` or `\`) — and the leading separator — with `-`. So -/// `/home/anthony/Documents/Projects/IdeA` becomes -/// `-home-anthony-Documents-Projects-IdeA`. We keep this convention in one small, -/// tested function so that if Claude's exact encoding differs in some edge case, only -/// this seam needs to change. We do not attempt to encode `.` or other characters, as -/// the common case (clean absolute project paths) is covered and over-encoding would -/// risk pointing at the wrong 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 == '/' || c == '\\' { '-' } else { c }) + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect() } @@ -83,8 +85,26 @@ mod tests { "-home-anthony-Documents-Projects-IdeA" ); assert_eq!(encode_cwd("/a/b"), "-a-b"); - // Windows-style separators flatten too. - assert_eq!(encode_cwd("C:\\Users\\me"), "C:-Users-me"); + // 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]