[BUG] Session history / resume list is silently empty on Windows when the workspace is on a `subst` drive
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 extension resolves the workspace path with two different realpath implementations that disagree on Windows subst drives. Transcripts are written under a project key derived from the unresolved path (x--myproject), but the session lister reads from a key derived from the natively-resolved path (C--Apache2-htdocs-myproject). That second directory never exists, readdir throws, the error is swallowed, and listSessions() returns zero sessions.
Every past conversation becomes invisible in the UI. No error is shown or logged. The transcripts are intact on disk the whole time and resume correctly via claude.exe --resume <id>, so this is purely a listing bug — but it reads to the user as "my sessions were never saved".
subst is common in Apache/PHP/XAMPP setups, where a drive letter is mapped to the docroot.
What Should Happen?
Past conversation should show in the UI.
Error Messages/Logs
Steps to Reproduce
subst X: C:\Apache2\htdocs(any target works; an Apache docroot is the common real-world case).mkdir C:\Apache2\htdocs\myproject- Open
X:\myprojectas the workspace folder in VS Code. - Start a Claude Code conversation and send at least one message.
- Confirm the transcript exists at
%USERPROFILE%\.claude\projects\x--myproject\<uuid>.jsonl. - Confirm
%USERPROFILE%\.claude\projects\C--Apache2-htdocs-myprojectdoes not exist. - Reload the window (or start a new conversation).
- Open the Session history list.
Expected: the conversation from step 4 is listed and resumable.
Actual: only the live conversation is listed. The step-4 session never appears, with no error.
Claude Model
Opus
Is this a regression?
No, this never worked
Last Working Version
_No response_
Claude Code Version
2.1.212
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
VS Code integrated terminal
Additional Information
Note: All below written by Claude - problem identified.
Environment
| Component | Version |
|---|---|
| VS Code | 1.128.1 |
| Claude Code extension | anthropic.claude-code 2.1.212 (win32-x64) |
| Bundled CLI (resources/native-binary/claude.exe) | 2.1.212 |
| Claude Agent SDK (CLAUDE_AGENT_SDK_VERSION in bundle) | 0.3.212 |
| OS | Windows 10 Pro 22H2 (10.0.19045) |
| Node used for the standalone repro | v22.16.0 |
| Drive mapping | subst X: C:\Apache2\htdocs |
Symptom
- The Session history list shows only the currently-active conversation.
- All previous sessions for the workspace are missing, permanently.
- No error, no entry in the extension output channel.
- The transcripts exist and are valid:
%USERPROFILE%\.claude\projects\x--myproject\<uuid>.jsonl claude.exe --resume <uuid>resumes them correctly — so the data layer is fine and only the listing path is broken.
Root cause
The writer keeps the subst drive letter; the reader resolves it away.
Identifiers below are the minified names in extension.js as shipped in 2.1.212, with their semantics, since the mapping to source names isn't public.
Writer (observed behaviour): transcripts are persisted under the project key derived from the unresolved cwd — x:\myproject → %USERPROFILE%\.claude\projects\x--myproject\.
this.cwd — in the webview view provider (resolveWebviewView), the workspace root is resolved with the JS implementation, which preserves the subst drive:
let o = fs.realpathSync(workspaceFolders[0].uri.fsPath || os.homedir()).normalize("NFC");
// -> 'x:\myproject'
Reader — listSessions() passes that value as dir:
async listSessions() {
let e = await xme({
dir: this.cwd, // 'x:\myproject'
includeWorktrees: false,
includeProgrammatic: this.includeProgrammaticSessions
});
...
}
xme → mRt → pRt, which resolves dir a second time, via Zpe:
async function Zpe(e) {
try { return Hd(await fs.promises.realpath(e)) } catch { return Hd(e) }
}
fs.promises.realpath is the native binding (uv_fs_realpath → GetFinalPathNameByHandle on Windows), not the JS implementation used above. It resolves the subst mapping to C:\Apache2\htdocs\myproject.
That resolved path is then slugged and joined to the projects directory:
function dx(e) { return e.replace(/[^a-zA-Z0-9]/g, "-") } // path -> project key
function px() { return path.join(configDir(), "projects") }
function UAt(e) { return path.join(px(), dx(e)) }
async function wb(e) {
let t = UAt(e), r = [];
try { await fs.promises.readdir(t), r.push(t) } catch {} // <-- ENOENT swallowed
let n = dx(e);
if (n.length <= 200) return r; // returns []
...
}
So the lister looks in %USERPROFILE%\.claude\projects\C--Apache2-htdocs-myproject, which has never existed. readdir throws ENOENT, the catch {} discards it, wb returns [], no transcript files are discovered, and listSessions() returns an empty array.
The realpath divergence, isolated
This reproduces with plain Node and no VS Code involved:
const fs = require('fs');
const p = 'x:\\myproject'; // subst X: C:\Apache2\htdocs
fs.realpathSync(p); // 'x:\myproject' <- writer / this.cwd
fs.realpathSync.native(p); // 'C:\Apache2\htdocs\myproject'
fs.promises.realpath(p); // 'C:\Apache2\htdocs\myproject' <- Zpe / reader
fs.realpathSync is a JS path-walk and preserves the subst drive letter. fs.promises.realpath and fs.realpathSync.native both call the native binding and resolve it. The two call sites therefore derive two different project keys from the same workspace.
Why the active conversation still shows (and masks the bug)
In the webview's doListSessions(), results from listSessions() are merged into an existing list, while the active session object exists independently of that call:
for (let a of i.sessions) {
if (!a.isCurrentWorkspace) continue;
...
}
An empty listSessions() therefore renders as "only the current conversation" rather than an empty list or an error. This is what makes the failure so hard to diagnose from the UI — it looks like an over-aggressive filter rather than a read from a non-existent directory.
For the record, these are not involved, though they look like plausible culprits:
isCurrentWorkspace(Jjt) only special-cases.claude/worktrees/<name>paths and returnstruefor all ordinary paths.includeProgrammatic: falseonly excludes entrypointssdk-cli/sdk-ts/sdk-py; affected sessions recordclaude-vscode.hiddenSessionIdsin globalState is unset.
Steps to reproduce
subst X: C:\Apache2\htdocs(any target works; an Apache docroot is the common real-world case).mkdir C:\Apache2\htdocs\myproject- Open
X:\myprojectas the workspace folder in VS Code. - Start a Claude Code conversation and send at least one message.
- Confirm the transcript exists at
%USERPROFILE%\.claude\projects\x--myproject\<uuid>.jsonl. - Confirm
%USERPROFILE%\.claude\projects\C--Apache2-htdocs-myprojectdoes not exist. - Reload the window (or start a new conversation).
- Open the Session history list.
Expected: the conversation from step 4 is listed and resumable.
Actual: only the live conversation is listed. The step-4 session never appears, with no error.
Confirm the data is intact and only listing is broken:
"%USERPROFILE%\.vscode\extensions\anthropic.claude-code-2.1.212-win32-x64\resources\native-binary\claude.exe" --resume <uuid>
This resumes the session correctly.
Impact
- All session history is invisible for every workspace on a
substdrive. - Completely silent — no error surfaced, nothing in the output channel.
- Users reasonably conclude sessions aren't being saved or that resume is broken. Recovery requires knowing that
--resumeexists and digging the UUID out of~/.claude/projects. - Likely under-reported:
substis standard practice for Apache/PHP/XAMPP docroots, and the "only the current conversation shows" presentation doesn't look like a path bug.
Workaround
A directory junction that makes both keys resolve to the same real directory:
mklink /J "%USERPROFILE%\.claude\projects\C--Apache2-htdocs-myproject" "%USERPROFILE%\.claude\projects\x--myproject"
Verified working. Needed per project, survives extension updates, and remains harmless whichever way the bug is fixed (reader and writer meet in the same directory either way).
Suggested fix
- Use one resolver for both write and read. The specific choice matters less than consistency — but note that changing only the writer to the native resolver would orphan every existing transcript on affected machines.
- Don't swallow the
readdirerror inwb(). The barecatch {}is what turned a path mismatch into a silent empty list. Even a debug-level log naming the directory it tried would have made this self-diagnosing. - Consider a fallback for existing installs: if the resolved project directory is missing or empty, retry with the key derived from the unresolved path. That would auto-heal affected users without requiring a junction.
- Consider distinguishing "no sessions found" from "project directory not found" in the UI, so a broken read can't render as a normal-looking list.
Verification performed
The shipped functions (Zpe, wb, fb, dRt, Ype, Xpe, RAt) were extracted from extension.js 2.1.212 and executed directly against a real project directory containing four valid transcripts:
- Before the junction:
wb()returned[]→listSessions()returned 0 sessions. - After the junction: four transcripts discovered →
listSessions()returned 4 sessions, each with the correct title.
Confirming the mismatch is the sole cause, and that every other filter in the path (isCurrentWorkspace, includeProgrammatic, the title/parse drop, hiddenSessionIds) passes the sessions through cleanly.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
The sessions are all sitting under
x--myproject. Nothing's been lost. The lister buildsC--Apache2-htdocs-myprojectfrom the natively-resolved path, that directory was never created,readdirthrows, the error gets swallowed, and you end up with zero.I built
BasedGPT/claude-code-session-recoveryfor this family.diagnose.pywalks~/.claude/projects/and maps every slug against the transcript files inside it, so you can see both directories and confirm which one holds the sessions before touching anything.The stopgap is a directory junction: point the read-time slug at the write-time one and the lister resolves to the real transcripts without moving a file. The same
subst/realpath divergence turned up on a mapped drive in #76205 and the junction held there.Fixing it properly means resolving the workspace path with one realpath implementation on both the write and read paths. The junction just buys you a working session list until that lands.
Hope this helps, if my tools are able to help you, would appreciate a ⭐ :)
I'd like to add that CC CLI doesn't have this problem, it's a problem solely of the CC extension. So the purpose of the fix would be to fix the extension so that it works the same way as CC CLI does.
Confirming this on a different setup, and adding one finding that affects suggested fix #3.
Environment
subst P: C:\Users\me\work\code, workspace opened asP:\MyAppUnchanged from 2.1.212 through 2.1.220. Scale here: 31 transcripts spanning three weeks, all
invisible in the panel.
The minified names moved between builds. For anyone verifying against 2.1.220, the reader chain is:
Same structure, same silent
catch {}.---
Suggested fix #3 would not have fired in my install
Here the resolved-path directory existed and was not empty. It held one transcript, written by
Claude Desktop, whose recorded
cwdis the resolved path rather than thesubstpath:I can't tell from the transcript whether Desktop resolved the path itself or whether I had simply
opened the real path there. Either way the consequence is the same: a "missing or empty → retry
unresolved" heuristic sees a populated directory, does not retry, and the 31 transcripts stay
hidden. The panel showed exactly one historical session — the Desktop one — which reads as "history
works, those other sessions just weren't saved". Arguably worse than an empty list, because it looks
plausible enough not to report.
Reading both candidate directories and merging by session id would handle this, and would also
cover mixed-client setups generally, where transcripts for one workspace can legitimately end up
under two different keys.
---
Two side effects worth folding into the same fix
1.
~/.claude.jsonaccumulates an entry per spelling, with conflicting trust state.The same directory ended up with entries under both the
substand the resolved spelling.hasTrustDialogAcceptedwastrueon one andfalseon the other, so the folder-trust dialog canreappear depending on which code path registered the workspace;
projectOnboardingSeenCountandhasCompletedProjectOnboardingdiverge the same way. Whatever canonical form the fix settles onshould be used for this key too, with pre-existing variants merged on load.
2. The per-project
memory/directory lives under the raw-cwd slug.~/.claude/projects/<slug>/memory/follows the writer, so anything reading it through the resolvedpath sees a project with no memory at all.
---
Junction workaround confirmed on 2.1.220
Worth noting the ordering for anyone who already has a split history: move the stray transcripts out
of the resolved-path directory into the raw-path one first, then delete the empty directory and
junction it. Nothing in the raw-path directory has to move that way, so the transcript of the
currently running session is never touched.
Reader went from 1 transcript to 32.
Hey JanSprengers, the populated
P--MyAppdirectory changes the recovery check. A fallback that retries only when the resolved directory is missing or empty would still miss your 31 transcripts because that directory already contains the one Desktop transcript.Keep both project directories in place and run
python tools/diagnose.pyfrom the affected Windows install before moving anything. I builtBasedGPT/claude-code-session-recoveryfor this path-split family; its diagnosis maps the transcript files, project slugs, and recordedcwd, then prints the exact next command. The repair needs to merge by session ID rather than choosing whichever slug happens to be populated. The same canonical path should be used for the trust entry and per-project memory as well.If you use the junction as a stopgap, preserve the two directories first and move any unrelated Desktop transcript out of the resolved-path directory before creating it. That keeps the current session's file from being hidden by the wrong link target.
Hope this helps, if my tools are able to help you, would appreciate a ⭐ :)