[BUG] Desktop (macOS): transcript bring-home migration slugifies NFD paths — sessions silently freeze in Desktop UI for projects with Japanese/accented folder names
Summary
On macOS, Claude Desktop's CCD layer migrates session transcripts between ~/.claude/projects/<slug> directories on every resume ("bring-home"). The migration target slug is computed from a non-Unicode-normalized (NFD) path, while the terminal CLI computes slugs from NFC-normalized paths. For any project whose path contains characters with combining marks (Japanese dakuten/handakuten, European accents), this creates two slug directories for the same project and permanently desynchronizes what Desktop displays from what the CLI writes.
User-visible symptom: chats silently disappear from the Desktop UI after an app restart or a ~15 min idle re-warm, while claude --resume in the terminal still shows everything. In one code path the transcript is moved (not copied), making it invisible to the terminal as well.
Related but distinct from #22243 (VSCode extension, read-only failure, closed as not planned). This one actively copies/moves transcript files. Also related: #17475, #29859.
Environment
- macOS (Darwin 25.0.0), APFS
- Claude Desktop 1.30096.1 through 1.32352.1 (verified still present by inspecting
app.asarof 1.32352.1) - Bundled CLI 2.1.222–2.1.234 (CLI itself is NOT affected — it NFC-normalizes
cwdbefore slugifying) - Example project path used below:
/Users/…/2025-01-01_デモ/myapp(any folder created via Finder is stored in NFD, which is the macOS default for Finder-created names)
Root cause (from app.asar, minified names kept)
Resume path in CCD ("resume bring-home"):
// I.resume && s.kind === 'local' && a
let i = await n.run(`resolve`, () => fs.promises.realpath(t.Z_(u)), t.Z_(u));
let o = t.Xu(i); // Xu = replace(/[^a-zA-Z0-9]/g,'-') — NO normalize('NFC')
let s = join(t.vd(), `projects`, o); // ← migration target = NFD-derived slug
t.Z_is tilde-expansion only (no normalization).fs.promises.realpath(libuv/JS implementation, not.native) preserves the input spelling; the storedsession.cwd(u = e.workingDir) is captured from the OS in NFD. So the slug gets one extra-per combining mark — for2025-01-01_デモ/myapp:…-2025-01-01----myapp(NFC, 4 dashes) vs…-2025-01-01-----myapp(NFD, 5 dashes: デ decomposes to テ + U+3099).- The terminal CLI normalizes before slugifying (
originalCwd/projectRoot/cwd → .normalize("NFC")in its state setter), so the authoritative transcript lives in the NFC slug directory.
The identity guards in migrateTranscriptOnWorktreeFallback cannot catch this:
if (path.normalize(a) === path.normalize(s) || await this.isSamePhysicalDir(a, s)) { /* no-op */ }
path.normalize() does no Unicode normalization, and the two slug dirs are genuinely different inodes, so isSamePhysicalDir is false. With srcSize > dstSize (dst absent = 0) the copy fires.
After the first migration, diskTranscript.setProjectDir(sessionId, nfdDir) pins the NFD directory; every later resolve returns it (probeSessionFile finds the copied file), so source == target and the migration never re-runs. The NFD copy is a permanently frozen snapshot of the moment of the first resume after the feature shipped. All subsequent writes (which the bundled CLI correctly sends to the NFC dir) are invisible to Desktop forever.
Note: another module in the same bundle already contains a darwin ? p.normalize('NFC') : p helper feeding the same slug function (branch/VM path), so the need for normalization is known — it's just missing from the CCD resume/migration path.
Timeline evidence (logs, identifiers replaced)
The feature shipped in a stealth update; migrations started 17 s after the post-update relaunch:
[stealth-update] Triggering stealth update after idle timeout
[updater] Version changed since last launch: 1.26832.0 → 1.30096.1
[CCD] Migrated transcript <session-A> from …/projects/<NFC-slug> to …/projects/<NFD-slug>
The same session had been resumed in Desktop 5× the previous day with zero migrations — the trigger was the app update, not user action. On this machine, 12 migrations across 3 projects followed over the next days, each freezing that session's Desktop view at the moment of its first post-update resume.
Worse, the worktree-fallback caller uses keepSource:false (move, not copy): when Desktop fell back from a deleted worktree, transcript <session-B> was moved into the NFD slug dir — after which the terminal CLI (which looks in the NFC slug dir) could no longer see it at all.
Reproduction
- macOS: create a project folder via Finder with a dakuten character in a path component (e.g.
2025-01-01_デモ/myapp) — Finder stores NFD. - Open the folder in Claude Desktop, chat, let the session idle >15 min (or restart the app).
- Send another message (resume-spawn) → log shows
[CCD] Migrated transcript … from <NFC-slug> to <NFD-slug>. - Chat more, restart Desktop → messages sent after step 3 are gone from the UI.
ls ~/.claude/projects | sed 's/-\{2,\}/-N-/g' | sort | uniq -dshows the split; the NFD-side file is a byte-prefix of the NFC-side file.
Suggested fix
Normalize at the slug boundary, matching the CLI: p.normalize('NFC') inside the CCD slugify (Xu) or at its call sites; or resolve via fs.realpath.native (which returns the true on-disk form) plus normalization. Also make the migration identity guard normalization-aware so existing split installs self-heal instead of re-copying.
Workaround we used
Quit Desktop; move/merge the NFD-slug transcripts into the NFC-slug dir (NFD files are byte-prefixes, so newest-wins); rewrite the stored cwd/originCwd strings in ~/Library/Application Support/Claude/claude-code-sessions/**/local_*.json to NFC (or rename the project folder to ASCII); delete the NFD slug dir. Renaming the folder on disk alone does NOT help, because the migration input is Desktop's stored cwd string, not the on-disk spelling.