My C: drive dropped to 230 MB free out of 231 GB, and one of the fattest things I 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. The obvious move was also wrong, because at that moment a stack of Claude Code sessions were running in parallel and every one of them was appending to its own transcript file under that directory. You cannot repoint a junction under live file handles. Here is why, and what I did instead.

The plan that doesn’t work

The idea is clean on paper. Move the data once, leave a pointer behind:

robocopy C:\Users\<you>\.claude G:\Caches\.claude /E
rmdir C:\Users\<you>\.claude
mklink /J C:\Users\<you>\.claude G:\Caches\.claude

mklink /J makes a directory junction, which is a reparse point the filesystem resolves transparently. No admin token needed, no app changes, no symlink-permission dance. Apps open C:\Users\<you>\.claude\projects\foo.jsonl and the OS quietly redirects to G:\Caches\.claude\projects\foo.jsonl. For a cold directory this is a quick operation and it just works.

The problem is the word “cold.” My .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, not idle files. And open handles are exactly what breaks the plan above.

Why each step fails on a live directory

Two distinct mechanisms bite you, and it’s worth separating them because they fail differently on Windows and Linux.

First, the rename/delete itself 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’s supposed to clear the path for the junction is the part that 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. This is the part people miss. A junction (or a symlink) is resolved at open() time, not on every read or write. When a process called open() on the transcript file, the OS walked the path, resolved any reparse points then, and handed back a handle that points at the underlying file object. After that, the handle is bound to that object. It does not re-walk 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 FD 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 you’ve split your live data across two directories without a single error to tell you.

So the failure mode if you force it is the worst kind: not a crash, but silent divergence. Half your sessions writing to G:, half still appending to the now-detached C: data through handles they opened before the swap. For append-only JSONL transcripts that’s straight-up corruption of the record, and there’s no exception thrown to flag it.

The one rule that explains all of it

A symlink or junction swap is only honored by future open() calls.

That’s the whole lesson compressed to a sentence. Anything that already has the old path open keeps using 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 “and the running apps pick up the new location” step, because that step does not exist at the OS level.

Which means the migration is not a clever-trick problem. It’s a sequencing problem. The swap has to happen when nothing has those files open, so that the next open() (the one the freshly-restarted session makes) resolves through the new junction to the new drive. Cold is not a nice-to-have here. Cold is the entire mechanism.

Stage it, guard it, run it cold

Since the operation is only safe with zero live sessions, the worst thing I could do is run it by hand in a terminal while eight other windows are mid-turn. The cleanup session that hit the disk wall is not the session that should also fire the move. So I 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 claude process exists. If any session is running, stop. This is the guard that actually matters, because it’s the open handles that make the move unsafe.
  • The destination already exists. Don’t clobber a prior partial move.
  • The junction is already in place. If .claude is already a reparse point, the move was already done; 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 only gets removed by hand once the relocated copy is confirmed working. The point of the guards isn’t politeness. The script encodes the OS rule directly: it will not perform the swap while a handle could be open, so it can’t produce the silent-divergence outcome even if I run it at the wrong moment. The right time to run it is from a clean terminal with every Claude window closed, and the guard makes “is it actually clean” a check instead of a hope.

The companion fix went after the cause rather than the symptom. The disk kept filling because npm’s cache and the Temp directory live on C: and grow without bound, so a second script, redirect_caches_to_g.ps1, redirects npm cache and Temp to G:\Caches. That one’s a config change, not a live-handle problem, so it doesn’t need the same guarding. But it’s the part that stops C: from refilling a week later and forcing the whole exercise again.

The takeaway

If you ever want to move a busy directory behind a symlink or junction, internalize the one rule and let it dictate your sequencing: the swap is honored only by future open() calls, so any process holding the old path open keeps using the old location. On Windows the OS protects you a little by refusing to delete a directory with open handles. On Linux it doesn’t, and you can quietly split live writes across two inodes with no error at all. Either way the answer is the same. You cannot hot-migrate a directory that live processes have files open in. Stage the move into a script, guard it so it aborts the instant it sees a live process, and run it cold.