Skip to main content

agentmux_srv/backend/
claude_layout.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Where the Claude Code CLI keeps a conversation under a `CLAUDE_CONFIG_DIR`.
5//!
6//! One implementation, because every reader has to agree with the CLI byte for
7//! byte: a resume check that computes a different folder name than the CLI
8//! reports a transcript that is sitting on disk as unreachable, and the agent
9//! loses its native resume. Three copies of this rule used to disagree, and
10//! the one on the resume path was wrong for any working directory holding an
11//! underscore, a space or most other punctuation.
12
13/// The CLI keeps this many characters of the sanitized path before it
14/// appends a hash instead.
15const PROJECT_DIR_NAME_MAX: usize = 200;
16
17/// The folder under `<CLAUDE_CONFIG_DIR>/projects/` that holds the sessions
18/// the CLI ran with `cwd` as its working directory.
19///
20/// The CLI's rule (its `RC()`, read from the binary AgentMux 0.57.0 bundles,
21/// Claude Code v2.1.280):
22///
23/// ```js
24/// function TQ(e){let r=0;for(let n=0;n<e.length;n++)r=(r<<5)-r+e.charCodeAt(n)|0;return r}
25/// function RC(e){let n=e.replace(/[^a-zA-Z0-9]/g,"-");
26///   if(n.length<=200)return n;return`${n.slice(0,200)}-${Math.abs(TQ(e)).toString(36)}`}
27/// ```
28///
29/// JavaScript strings are UTF-16, so the rule works on UTF-16 code units: a
30/// character outside the Basic Multilingual Plane (an emoji) becomes two
31/// dashes, not one.
32///
33/// `cwd` must be the path as the CLI sees it — already expanded, not a
34/// `~`-shorthand.
35pub fn project_dir_name(cwd: &str) -> String {
36    let name: String = cwd
37        .encode_utf16()
38        .map(|unit| match u8::try_from(unit) {
39            Ok(b) if b.is_ascii_alphanumeric() => b as char,
40            _ => '-',
41        })
42        .collect();
43    if name.len() <= PROJECT_DIR_NAME_MAX {
44        return name;
45    }
46    // Every character of `name` is ASCII, so slicing by bytes is slicing by
47    // characters.
48    format!(
49        "{}-{}",
50        &name[..PROJECT_DIR_NAME_MAX],
51        radix_36(path_hash(cwd))
52    )
53}
54
55/// `Math.abs(TQ(cwd))`: 31·h + c over UTF-16 code units, wrapping at 32 bits
56/// (Java's `String.hashCode`). `i32::MIN`'s absolute value doesn't fit an
57/// `i32`, and JavaScript numbers don't wrap, so the magnitude is returned
58/// unsigned.
59fn path_hash(s: &str) -> u32 {
60    let mut hash: i32 = 0;
61    for unit in s.encode_utf16() {
62        hash = hash
63            .wrapping_shl(5)
64            .wrapping_sub(hash)
65            .wrapping_add(i32::from(unit));
66    }
67    hash.unsigned_abs()
68}
69
70/// `Number.prototype.toString(36)` for a non-negative integer.
71fn radix_36(mut n: u32) -> String {
72    const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
73    if n == 0 {
74        return "0".to_string();
75    }
76    let mut buf = Vec::new();
77    while n > 0 {
78        buf.push(DIGITS[(n % 36) as usize]);
79        n /= 36;
80    }
81    buf.reverse();
82    String::from_utf8(buf).expect("base-36 digits are ASCII")
83}
84
85/// The directory the CLI names a working directory's auto-memory folder
86/// after — not the working directory itself, unlike its sessions. The CLI's
87/// rule (its `defaultPath()`, Claude Code v2.1.280):
88///
89/// ```js
90/// g = this.canonicalWcRootForProject(r) ?? Ur(r) ?? r   // r: the process cwd
91/// function Ur(e){let n=Un(e);if(!n)return null;return eQ(uu().canonicalRootByRoot,n,ke)}
92/// ```
93///
94/// That is: the physical working directory, walked up to the nearest `.git`
95/// (a directory or a file), and — for a linked worktree, whose `.git` is a
96/// `gitdir:` file — on to the main checkout that owns it. So every
97/// subdirectory and every linked worktree of one repository shares the
98/// repository root's memory folder.
99///
100/// Returns the working directory unchanged when it isn't in a repository.
101pub fn memory_project_root(cwd: &std::path::Path) -> std::path::PathBuf {
102    let cwd = physical_dir(cwd);
103    match git_root(&cwd) {
104        Some(root) => canonical_worktree_root(&root),
105        None => cwd,
106    }
107}
108
109/// The working directory as the CLI's process sees it (`process.cwd()`):
110/// symlinks resolved. On Windows `canonicalize` returns a `\\?\` verbatim
111/// path the CLI never sees, and the current directory keeps links as given.
112pub fn physical_dir(cwd: &std::path::Path) -> std::path::PathBuf {
113    if cfg!(windows) {
114        cwd.to_path_buf()
115    } else {
116        cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf())
117    }
118}
119
120/// Whether the repository whose main checkout is `root` has linked
121/// worktrees — each of which shares `root`'s memory folder.
122pub fn has_linked_worktrees(root: &std::path::Path) -> bool {
123    std::fs::read_dir(root.join(".git").join("worktrees")).is_ok_and(|mut d| d.next().is_some())
124}
125
126/// The nearest directory at or above `dir` holding a `.git` directory or
127/// file (the CLI's `Gt`).
128fn git_root(dir: &std::path::Path) -> Option<std::path::PathBuf> {
129    dir.ancestors()
130        .find(|d| std::fs::metadata(d.join(".git")).is_ok_and(|m| m.is_dir() || m.is_file()))
131        .map(std::path::Path::to_path_buf)
132}
133
134/// For a linked worktree, the main checkout that owns it; otherwise `root`
135/// (the CLI's `ke`/`Ht`). Anything that doesn't check out — a `.git` file
136/// that isn't `gitdir:`, a missing `commondir`, a worktree whose `gitdir`
137/// doesn't point back — leaves `root` as it is, as the CLI does.
138fn canonical_worktree_root(root: &std::path::Path) -> std::path::PathBuf {
139    use std::path::{Path, PathBuf};
140    fn read_trimmed(p: &Path) -> Option<String> {
141        std::fs::read_to_string(p).ok().map(|s| s.trim().to_string())
142    }
143    let resolve = || -> Option<PathBuf> {
144        let git_file = read_trimmed(&root.join(".git"))?;
145        let git_dir = real_path(&root.join(git_file.strip_prefix("gitdir:")?.trim()));
146        let common = real_path(&git_dir.join(read_trimmed(&git_dir.join("commondir"))?));
147        if git_dir.parent()? != common.join("worktrees") {
148            return None;
149        }
150        let back = real_path(&git_dir.join(read_trimmed(&git_dir.join("gitdir"))?));
151        if back != real_path(root).join(".git") {
152            return None;
153        }
154        if common.file_name()? != ".git" {
155            // A bare repository's worktree: the repository itself, unless it
156            // is somehow a checkout too.
157            return (!common.join(".git").exists()).then_some(common);
158        }
159        common.parent().map(Path::to_path_buf)
160    };
161    resolve().unwrap_or_else(|| root.to_path_buf())
162}
163
164/// `p` with symlinks resolved, as the CLI's process would name it. On
165/// Windows `canonicalize` returns a `\\?\` verbatim path, which names the
166/// folder `----C--repo` where the CLI, seeing `C:\repo`, names it
167/// `C--repo`. Tests that build a working directory take it from here too:
168/// a raw `canonicalize` gives them a path no real cwd has (#3749).
169pub fn real_path(p: &std::path::Path) -> std::path::PathBuf {
170    without_verbatim_prefix(p.canonicalize().unwrap_or_else(|_| p.to_path_buf()))
171}
172
173/// `p` without a leading `\\?\`. A verbatim UNC path (`\\?\UNC\server\…`)
174/// is kept: dropping only the prefix would leave `UNC\server\…`, which is
175/// not the path the CLI sees.
176fn without_verbatim_prefix(p: std::path::PathBuf) -> std::path::PathBuf {
177    match p.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
178        Some(rest) if !rest.starts_with("UNC\\") => std::path::PathBuf::from(rest),
179        _ => p,
180    }
181}
182
183#[cfg(test)]
184pub(crate) mod tests {
185    use super::*;
186
187    // Every expected value below was produced by the CLI's own `RC()`
188    // (quoted on `project_dir_name`), run under Node — not by this module.
189
190    #[test]
191    fn a_windows_agent_directory() {
192        assert_eq!(
193            project_dir_name(r"C:\Users\user\.agentmux\agents\agent3-0630k"),
194            "C--Users-user--agentmux-agents-agent3-0630k"
195        );
196    }
197
198    #[test]
199    fn a_posix_path_keeps_the_dashes_inside_a_segment() {
200        assert_eq!(
201            project_dir_name("/home/u/.agentmux/agents/foo-bar"),
202            "-home-u--agentmux-agents-foo-bar"
203        );
204    }
205
206    #[test]
207    fn underscores_and_spaces_become_dashes() {
208        assert_eq!(
209            project_dir_name("/home/u/my_project dir"),
210            "-home-u-my-project-dir"
211        );
212    }
213
214    #[test]
215    fn non_ascii_letters_and_punctuation_become_dashes() {
216        assert_eq!(
217            project_dir_name("C:\\code\\caf\u{e9} (old)@2~x"),
218            "C--code-caf---old--2-x"
219        );
220    }
221
222    #[test]
223    fn a_character_outside_the_bmp_becomes_two_dashes() {
224        assert_eq!(project_dir_name("/x/\u{1F600}y"), "-x---y");
225    }
226
227    #[test]
228    fn a_long_path_is_cut_at_200_and_hashed() {
229        let cwd = format!("/home/u/{}repo", "deeply_nested/".repeat(20));
230        let got = project_dir_name(&cwd);
231        assert_eq!(
232            got,
233            "-home-u-deeply-nested-deeply-nested-deeply-nested-deeply-nested-deeply-nested-\
234             deeply-nested-deeply-nested-deeply-nested-deeply-nested-deeply-nested-deeply-nested-\
235             deeply-nested-deeply-nested-deeply-nes-fygn57"
236        );
237        assert_eq!(got.len(), 207);
238    }
239
240    #[test]
241    fn a_long_windows_path_is_cut_at_200_and_hashed() {
242        let cwd = format!(r"C:\Users\user\{}", "a".repeat(230));
243        assert_eq!(
244            project_dir_name(&cwd),
245            format!("C--Users-user-{}-eqzdy0", "a".repeat(186))
246        );
247    }
248
249    #[test]
250    fn exactly_200_characters_is_not_hashed() {
251        let cwd = "a".repeat(200);
252        assert_eq!(project_dir_name(&cwd), cwd);
253    }
254
255    #[test]
256    fn the_hash_magnitude_of_i32_min_does_not_wrap() {
257        // JavaScript's Math.abs(-2147483648) is 2147483648; an i32 `abs`
258        // would overflow.
259        assert_eq!(radix_36(i32::MIN.unsigned_abs()), "zik0zk");
260        assert_eq!(radix_36(0), "0");
261    }
262
263    // The prefix rule is string handling, so it is checked on every
264    // platform — PR CI runs no tests on Windows (ci-pr.yml).
265    #[test]
266    fn a_verbatim_drive_path_loses_its_prefix() {
267        use std::path::PathBuf;
268        assert_eq!(without_verbatim_prefix(PathBuf::from(r"\\?\C:\repo")), PathBuf::from(r"C:\repo"));
269    }
270
271    #[test]
272    fn a_verbatim_unc_path_and_a_plain_path_are_kept() {
273        use std::path::PathBuf;
274        for p in [r"\\?\UNC\server\share\repo", r"C:\repo", "/home/u/repo"] {
275            assert_eq!(without_verbatim_prefix(PathBuf::from(p)), PathBuf::from(p));
276        }
277    }
278
279    /// A repository with one commit, and a linked worktree of it outside it,
280    /// as the CLI's process would see them ([`real_path`]).
281    pub(crate) fn repo_with_worktree(base: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
282        let (repo, wt) = (base.join("repo"), base.join("wt"));
283        std::fs::create_dir_all(repo.join("sub")).unwrap();
284        let git = |args: &[&str]| {
285            let out = std::process::Command::new("git")
286                .args(["-c", "user.email=a@b", "-c", "user.name=a"])
287                .args(args)
288                .current_dir(&repo)
289                .output()
290                .unwrap();
291            assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
292        };
293        git(&["init", "-q"]);
294        std::fs::write(repo.join("sub").join("x"), "x").unwrap();
295        git(&["add", "."]);
296        git(&["commit", "-qm", "x"]);
297        git(&["worktree", "add", "-q", wt.to_str().unwrap()]);
298        (real_path(&repo), real_path(&wt))
299    }
300
301    #[test]
302    fn memory_is_keyed_by_the_main_checkout_for_subdirectories_and_worktrees() {
303        let base = tempfile::tempdir().unwrap();
304        let (repo, wt) = repo_with_worktree(base.path());
305        assert_eq!(memory_project_root(&repo), repo);
306        assert_eq!(memory_project_root(&repo.join("sub")), repo);
307        assert_eq!(memory_project_root(&wt), repo, "a linked worktree shares the main checkout's memory");
308        assert_eq!(memory_project_root(&wt.join("sub")), repo);
309    }
310
311    #[test]
312    fn outside_a_repository_memory_is_keyed_by_the_physical_directory() {
313        let base = tempfile::tempdir().unwrap();
314        let real = real_path(base.path()).join("plain");
315        std::fs::create_dir_all(&real).unwrap();
316        assert_eq!(memory_project_root(&real), real);
317        #[cfg(unix)]
318        {
319            let link = base.path().join("link");
320            std::os::unix::fs::symlink(&real, &link).unwrap();
321            assert_eq!(memory_project_root(&link), real);
322        }
323    }
324
325    #[test]
326    fn a_git_file_that_is_not_a_linked_worktree_keeps_its_own_root() {
327        let base = tempfile::tempdir().unwrap();
328        let dir = real_path(base.path()).join("odd");
329        std::fs::create_dir_all(&dir).unwrap();
330        std::fs::write(dir.join(".git"), "gitdir: ../nowhere\n").unwrap();
331        assert_eq!(memory_project_root(&dir), dir);
332    }
333}