[BUG] Lossy path normalization causes cross-project session contamination
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
The path normalization function that maps project directories to storage folders under ~/.claude/projects/ is lossy — distinct project paths can produce the same storage key, causing cross-project session contamination. This violates the fundamental requirement that a storage key uniquely identify its source project.
The function collapses path separators, spaces, dashes, and other characters into a single delimiter. Any projects whose paths differ only in ways the normalization collapses will silently share a session directory. Session JSONL files under that directory contain no metadata identifying the original project path, so either project can start, close, and resume sessions from the other.
Additionally, because storage paths are derived from absolute project paths, moving or renaming a project directory orphans all its sessions — claude --continue and claude --resume stop working. There is no built-in migration tool.
Todos and plans use a different storage mechanism — UUID-keyed JSON files in flat global directories (~/.claude/todos/, ~/.claude/plans/). They don't suffer from path collisions directly, but session files reference them by UUID. When sessions are orphaned, their referenced todos and plans become unreachable as well.
Additional encoding limitations reported by the community:
- Non-ASCII characters (e.g., Chinese paths) are replaced with hyphens, making encoded paths unreadable and increasing collision surface (#19972)
- Deeply nested directory paths exceed filesystem name-length limits after encoding (#19742)
What Should Happen?
Project-scoped data should be stored in the project-local .claude/ folder, which already exists for settings.local.json:
project-root/
├── .claude/
│ ├── settings.local.json # Already exists (local project permissions)
│ ├── sessions/ # NEW — conversation JSONL files
│ ├── todos/ # NEW — todo JSON files
│ └── plans/ # NEW — plan files
This eliminates the encoding layer for sessions entirely — each project's data lives in its own directory tree, so no path normalization is needed and no collisions are possible. It also makes project data robust to folder moves and renames, and aligns with the convention used by .git/, node_modules/, .venv/, and .idea/.
claude --resume {sessionId} already only works from the directory where the session was created (#5768), so sessions are already effectively project-scoped. Global storage does not provide cross-project access — it only introduces the encoding and orphaning problems. Moving sessions into the project folder aligns storage location with the access model already enforced, with no loss of capability.
An alternative approach would be to replace the current normalization scheme with a collision-resistant key (e.g., a hash of the absolute path). However, this would retain the orphaning behavior on directory moves and would not co-locate project data with the project itself.
Error Messages/Logs
Observed storage path collision:
Project path A: D:\Test\Project\Name
Project path B: D:\Test\Project Name
Both normalize to: ~/.claude/projects/D--Test-Project-Name/
Sessions from project A and project B are written to the same directory.
Resuming from either project can load sessions created in the other.
Steps to Reproduce
- Create two project directories whose paths differ only by a path separator vs. a space — e.g.,
D:\Test\Project\NameandD:\Test\Project Name - Run
claudein each directory and start a session in each - Observe that both sessions are stored under the same folder:
~/.claude/projects/D--Test-Project-Name/ - From project A, run
claude --resumeand observe that sessions from project B are listed and can be resumed - The agent now operates with context from the wrong project
Claude Model
Opus
Is this a regression?
No, this never worked
Last Working Version
_No response_
Claude Code Version
2.1.63 (Claude Code)
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
Windows Terminal
Additional Information
.gitignore Handling
The .claude/ folder already requires .gitignore consideration due to settings.local.json. The creation code path should ensure .claude/sessions/, .claude/todos/, and .claude/plans/ are also covered by .gitignore entries — unless the user intentionally commits them (e.g., to share session history across a team or transfer between machines).
Migration
Sessions: When starting a session, if matching data is found in the legacy global folder (~/.claude/projects/), prompt the user with a choice:
- Move — migrate sessions into the project-local
.claude/sessions/folder - Delete — remove the legacy sessions
- Skip — leave legacy data in place and defer the decision (prompt again next session)
Note: if the legacy global folder contains sessions from multiple colliding projects, the user may need to sort them manually. A Copy option (leave the global data intact, copy into the local project) could help in this case, though it increases implementation scope.
Todos and plans: These are UUID-keyed files in flat global directories, referenced by UUID from session JSONL files. Migrating todos and plans is an optional secondary step — session migration is the primary concern and would work independently. If desired, todo/plan migration could follow the UUID references in session files to identify which files belong to which project.
Configuration (Optional)
A setting could allow users to opt back into global storage:
// .claude/settings.json
{
"sessionStorage": "local" // or "global"
}
Making project-local the default (with migration) is the stronger approach, since the current global storage model contributes to the problems documented here.
Related Issues and How This Proposal Differs
| Issue | Relationship |
|-------|-------------|
| #5768 — Resume only works from started directory | Symptom. Resume is directory-scoped because storage is global-but-keyed-by-path. Project-local storage makes resume work naturally after folder moves. |
| #19972 — Non-ASCII chars replaced with hyphens | Symptom. The lossy encoding makes Unicode paths collide. Project-local storage eliminates encoding entirely. |
| #19742 — ENAMETOOLONG for nested directories | Symptom. The encoded path exceeds filesystem limits. Project-local storage uses a fixed short path. |
| #26875 — Session unreachable when projectPath updates mid-session | Symptom. Storage location and index key diverge when paths change. |
| #25947 — Store memory files in project-local .claude | Same direction, narrower scope. Covers memory only. |
| #28745 — Allow resuming from different directories | Different approach. Proposes making global resume work everywhere. This proposal makes storage local so resume needs no path resolution. |
Note: Issues #26964, #27658, and #28801 describe cross-session contamination bugs with different root causes (concurrent file-write routing, Desktop app session routing, multi-terminal agent behavior). Those are distinct bugs that project-local storage does not address.
Prior Requests
This problem has been reported in #1516, #4050, #7584, #9306, #12646, #20622, #22387, and path-encoding bug reports #7009, #9221, #18829, #21085, #24789, #29471. No active tracked issue currently exists for this feature.
This issue has 6 comments on GitHub. Read the full discussion on GitHub ↗