[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)

Status Open
Reported on v2.1.259
Maintainer reply None cached
Activity 2 comments · opened Sep 7, 2026

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
  • cleanupPeriodDays unset (default)

Steps to reproduce

  1. 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).

  1. Let the daily retention sweep run (start a session once ~/.claude/.last-cleanup is older than a day).
  2. 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: true for the plans sweep like the others.
  • Make the empty-dir removal actually check emptiness ((await readdir(dir)).length === 0) and/or refuse when lstat(dir).isSymbolicLink() / the path is a reparse point, instead of relying on rmdir to throw.

Workarounds

  • Keep ~/.claude/plans a real directory and link the other way (mklink /J C:\some\repo\plans %USERPROFILE%\.claude\plans); a real non-empty directory cannot be rmdir'd, and Git for Windows walks the junction like a normal directory.
  • cleanupPeriodDays: 0 (disables the whole sweep).

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗