Our C: drive was down to 230 MB free out of 231 GB, and one of the fattest things we could move off it was a 1.7 GB .claude directory full of transcripts and a chrome-debug profile. The obvious move was to relocate it to G: and leave a directory junction behind, so every app still saw a normal folder at the old path. That plan broke on one fact. A stack of Claude Code sessions was running in parallel, and every one of them was appending to its own transcript file under that directory. You can’t repoint a junction under live file handles. Here is why, and what we did instead.
The plan on paper
The idea is clean. Move the data once, leave a pointer behind:
robocopy C:\Users\<you>\.claude G:\Caches\.claude /Ermdir C:\Users\<you>\.claudemklink /J C:\Users\<you>\.claude G:\Caches\.claudemklink /J makes a directory junction, a reparse point the filesystem resolves transparently. It needs no admin token, no app changes and no symlink-permission dance. Apps open C:\Users\<you>\.claude\projects\foo.jsonl and the OS redirects to G:\Caches\.claude\projects\foo.jsonl. For a cold directory this is quick and it just works.
The catch is the word “cold.” Our .claude directory was anything but. Around eight sessions were live in parallel, and a Claude Code session keeps a steady grip on the files under there: the per-project projects/<slug>/<session-id>.jsonl transcript it appends to on every turn, plus lock files and shell snapshots. Those are open handles, and open handles break the plan.
Two ways it fails on a live directory
Two distinct mechanisms bite, and they fail differently on Windows and Linux.
First, the rename or delete is refused. On Windows you cannot rename or rmdir a directory that has open handles on files inside it. The rmdir fails with the sharing-violation error, “The process cannot access the file because it is being used by another process,” and the junction never gets created because the old directory is still sitting there. Robocopy can copy a file that’s open for append (it reads what it can), but the teardown step that clears the path for the junction gets stonewalled. As long as one session holds projects/.../session.jsonl open, the directory is pinned.
Second, even if you somehow swapped the path, the running processes wouldn’t follow it. A junction or symlink is resolved when open() is called. The OS walks the path, resolves any reparse points at that moment, and hands back a handle bound to the underlying file object. The handle never re-walks the path. Swap the junction underneath a running process and its already-open handles keep writing to wherever they were originally resolved.
On Linux the same truth shows up through the inode. An open file descriptor refers to an inode, not a path. If you move the directory and repoint a symlink, every descriptor a live process holds keeps pointing at the old inode. The process happily keeps writing to the old location, now orphaned from the path you think it lives at. New open() calls go to the new place, old descriptors go to the old place, and your live data is split across two directories without a single error.
So the failure mode if you force it is silent divergence with no crash. Half the sessions write to G:, and half keep appending to the now-detached C: data through handles they opened before the swap. For append-only JSONL transcripts that corrupts the record, and no exception is thrown to flag it.
What follows from that
A symlink or junction swap is honored only by future open() calls. Anything that already has the old path open keeps the old resolution, because the resolution happened once, at open time, and was baked into the handle. You cannot hot-migrate a directory that live processes have files open in. There is no step where the running apps pick up the new location, because that step does not exist at the OS level.
That makes the migration a sequencing problem. The swap has to happen when nothing has those files open, so the next open() (the one a freshly restarted session makes) resolves through the new junction to the new drive. Cold is the entire mechanism.
Staging it so it only runs cold
Since the operation is safe only with zero live sessions, the worst thing we could do was run it by hand in a terminal while eight other windows were mid-turn. The cleanup session that hit the disk wall is also not the session that should fire the move. So we staged it as a script that refuses to run unless the world is actually cold.
The script, relocate_claude_to_g.ps1, aborts on three guards before it touches anything:
- A live
claudeprocess exists. If any session is running, stop. This is the guard that matters, because open handles are what make the move unsafe. - The destination already exists. Don’t clobber a prior partial move.
- The junction is already in place. If
.claudeis already a reparse point, the move was already done, so bail.
Only past all three does it copy with robocopy (/E, plus a file-count verification before it touches the original), rename the old directory to .claude.bak instead of deleting it outright, and create the junction. If the mklink step fails it rolls the .bak back into place. The original gets removed by hand once the relocated copy is confirmed working. The script encodes the OS rule directly: it will not perform the swap while a handle could be open, so even if we run it at the wrong moment it can’t produce the silent divergence. The right time to run it is from a clean terminal with every Claude window closed, and the guard turns “is it actually clean” into a check we run on every attempt.
Fixing the refill
The second fix targeted what kept filling the disk. npm’s cache and the Temp directory live on C: and grow without bound, so redirect_caches_to_g.ps1 redirects both to G:\Caches. That one is a config change and doesn’t need the same guarding. It is the part that stops C: from refilling a week later and forcing the whole exercise again.
Before you move a busy directory behind a junction or symlink, check whether any process has files open in it. The swap is honored only by future open() calls, so put the move in a script that aborts the instant it sees a live process, and run it cold.
Related
- Moving Claude Code Off Your C: Drive: The 12.4 GB VM Bundle and the UWP Permission Wall: the fuller story of reclaiming C: drive space occupied by Claude and its dependencies
- Where does the daemon live? Reusing a scheduled-task tick instead of always-on infra: deciding where persistent processes should live on the same Windows box
- A pinned app kept vanishing after every reboot, and Windows was not to blame: another Windows path/process lifecycle surprise with a staged fix
- claude —resume worked in a terminal and failed in a web app: the LocalSystem homedir trap: how process identity affects which paths a service can reach
- The console window that flashed every 30 minutes: Interactive vs S4U scheduled tasks: Windows process-session isolation producing unexpected runtime behavior