[BUG] Retention sweep deletes ~/.claude/plans when it is an NTFS junction: rmdir on a junction succeeds regardless of target contents (Windows, 2.1.259)
Summary
On Windows, the daily retention sweep removes ~/.claude/plans when that directory is an NTFS junction (or directory symlink) to somewhere else, even though the target is not empty. The sweep's "remove the base directory if it is now empty" step is a try-rmdir-and-ignore-errors idiom, and on Windows rmdir on a junction succeeds regardless of the target's contents: it deletes the reparse point and leaves the target alone.
The result is silent: the link vanishes, the plan files are untouched at the target, sibling junctions (commands, skills, per-project memory) are untouched, and the next plan write recreates ~/.claude/plans as an ordinary folder, so plans stop landing wherever the user was keeping them (in my case a git repo).
Environment
- Claude Code 2.1.259 (native
claude.exe; the VS Code extension's bundled binary is byte-identical) - Windows 11 Pro 10.0.26200, NTFS
cleanupPeriodDaysunset (default)
Steps to reproduce
- Keep plans in a repo and link them in the way the docs' "junction, no elevation needed" advice suggests:
mklink /J %USERPROFILE%\.claude\plans C:\some\repo\plans (target contains .md files newer than the cleanup window).
- Let the daily retention sweep run (start a session once
~/.claude/.last-cleanupis older than a day). dir /AL %USERPROFILE%\.claude
Expected: the plans junction is still there (the sweep should never remove a directory that is not empty).
Actual: the plans junction is gone; C:\some\repo\plans and its files are intact; commands and skills junctions are intact. Observed four times on one machine: 2026-09-03 (twice), 2026-09-06 and 2026-09-07, each on the first sweep after the junction was recreated.
Cause (from the 2.1.259 bundle)
async function zbr(e){let r=w(be(),"plans");try{if(e!==void 0)return await I(r,(n)=>n.endsWith(".md")||n.includes(".md.tmp."));return await I(r,".md")}finally{T7e()}}
async function I(e,r,n=!0,a){let o=nS(a),f=tb();if(o===null)return f; ... g=await d.readdir(e) ... /* delete stale matching files */ ... if(n)await b(e,d);return f}
async function b(e,r){try{await r.rmdir(e)}catch{}}
b() relies on ENOTEMPTY to keep a non-empty directory. That holds for a real directory but not for a junction or directory symlink on Windows:
fs.rmdirSync(junctionToNonEmptyDir) // succeeds, link removed, target intact
fs.rmdirSync(realNonEmptyDir) // throws ENOTEMPTY
The newer sweeps in the same module (skills, plugins, agent directories) pass refuseRedirectedRoot: true and bail with cleanup_sweep_root_refused when the root is redirected, and projects/*/memory/ is on the exclusion set. The plans sweep has neither guard. This is the same class as the worktree junction problem addressed in 2.1.205 (#84162, #75876).
Suggested fix
Either of:
- Pass
refuseRedirectedRoot: truefor theplanssweep like the others. - Make the empty-dir removal actually check emptiness (
(await readdir(dir)).length === 0) and/or refuse whenlstat(dir).isSymbolicLink()/ the path is a reparse point, instead of relying onrmdirto throw.
Workarounds
- Keep
~/.claude/plansa real directory and link the other way (mklink /J C:\some\repo\plans %USERPROFILE%\.claude\plans); a real non-empty directory cannot bermdir'd, and Git for Windows walks the junction like a normal directory. cleanupPeriodDays: 0(disables the whole sweep).
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗