[BUG] Desktop app: plan file links can never be opened — ~/.claude/plans is never added to the File Pane's allowed roots
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?
Summary
Plan mode writes plans to the global ~/.claude/plans/. The desktop File Pane will only render files contained in the session's working directory plus the two paths supplied via memory_paths (auto, team). Nothing ever adds ~/.claude/plans to that set, so every plan file link in the desktop app fails to open, for every user, on every project, 100% of the time.
The failure is not a missing file and not an OS permission problem. The read is refused client-side, before any filesystem call, and the user is shown a message that misattributes it to deletion.
The app writes these files itself, and reads other files from the same ~/.claude directory (settings.json, memory, transcripts) on every launch through the main process. Only the File Pane's display path applies this restriction.
Environment
| | |
|---|---|
| Claude desktop app | 1.44121.1 |
| Claude Code CLI | 2.1.258 |
| OS | macOS 26.6.2, arm64 |
| Session type | local (not remote/cloud, not SSH, not Remote Control) |
| Relevant settings | none — no permissions.additionalDirectories set at user or project level |
Reproduction
- Open any project in the desktop app (working directory e.g.
~/Dev/myproject). - Enter plan mode and let Claude produce a plan. The plan is written to
~/.claude/plans/<slug>.md. - The plan renders correctly in the conversation.
- Click the plan's file link / header to open the file itself.
Expected: the File Pane renders ~/.claude/plans/<slug>.md.
Actual: the File Pane shows
Couldn't find this file It may have been deleted, or it lives outside the working directory. /Users/<user>/.claude/plans/<slug>.md
The file exists and is readable. In my case: -rw-r--r-- 1 feras staff 21354 Sep 2 12:16 given-all-the-findings-dynamic-robin.md, written by the app minutes earlier, opening fine in any other editor.
Root cause
Traced through the shipped renderer bundles. Minified identifiers below are build-specific and will differ across releases; the control flow is the stable part.
1. The read gate — ion-dist/assets/v1/c5da7eb42--bHbCpLJ.js
Before fetching bytes for the pane, the session handle is resolved to { root, extraRoots, uncontained } and the requested path is containment-checked:
} else {
let e = su(i.root, t, i.extraRoots);
if (!e) {
lu(!t || t.includes("\0") || t.startsWith("~") ? "malformed_path"
: i.root?.startsWith("/") ? (i.extraRoots.length === 0 ? "no_mount_seed" : "outside_roots")
: "no_cwd_seed");
return r("client_denied");
}
a = e;
}
lu() emits telemetry claudeai.ccr.epitaxy_read_denied. For this case the reason is outside_roots. No filesystem call is made — the file is never stat'd.
The containment test is lexical (p === root || p.startsWith(root + "/"), after a string-only ./.. normalizer). No realpath, no symlink resolution.
2. What populates the allowed roots
root is written from two places only:
$c.set(e, n.cwd) // from the CLI's system/init message
$c.set(e, r.workingDir) // from a session record
extraRoots lives in one map whose sole writer is Cl(sessionId, path). Exhaustive enumeration of every call site in the bundle:
Cl(e, r?.auto) // r = n.memory_paths, from the CLI's system/init message
Cl(e, r?.team) // ditto
Cl(e, t) x2 // for (let t of n.active_mount_paths ?? []) — remote session API record only
The active_mount_paths branch reads off the cloud session payload (the same object carrying environment_kind, self_hosted_runner_pool_id). It is never populated for a local desktop session.
So for a local session the complete set of renderable roots is: cwd + memory_paths.auto + memory_paths.team. ~/.claude/plans is not among them and there is no code path that could add it.
3. Error classification — ion-dist/assets/v1/cd5a31703-DPCARDPv.js
client_denied yields a null read classified missing. That is then combined with the (separately computed) outsideRoot flag:
function zE({outsideRootUnrenderable: e, outsideRoot: t = false, nullReadClass: n, isBridge: r}) {
return e ? "outside_root"
: n === "missing" ? (t ? "missing_or_outside" : "missing")
: ...
}
producing message id IcjhOvUX8s (ion-dist/i18n/en-US.json): "It may have been deleted, or it lives outside the working directory."
Note there is already a precise, honest string for this condition — l5hJ1Ge03E, "This file is outside the working directory" — reached only via outsideRootUnrenderable. The plan case takes the hedged branch instead and speculates about deletion for a file it never looked at.
Why this has gone largely unreported
ExitPlanMode carries the plan's full text inline as plan, alongside planFilePath:
if (t.name === "ExitPlanMode") {
let t = VD(n.plan);
if (t !== void 0) { ... e.content = t } // rendered from memory
let r = VD(n.planFilePath);
if (r !== void 0 && HD(r)) e.path = r // link target only
}
The plan therefore displays correctly at creation time without the filesystem being touched. Only clicking through to open the plan as a file — i.e. revisiting a plan later — hits the resolver. Combined with the fact that virtually every other file link the app emits points inside the working directory, the broken surface is narrow enough to read as an isolated glitch rather than a systematic break.
Impact
- Plans accumulate in a directory the app that wrote them cannot display. I have 24 spanning several months, none openable from the UI.
- The error message actively misleads: users conclude the file was deleted or that their permissions are wrong. macOS Full Disk Access does not help and cannot, since no syscall is attempted.
- There is no configuration that fixes this.
permissions.additionalDirectorieslooks like the lever and is not: it feedssessionFoldersandexecuteRoot(display metadata, and the wording of this very error) but is never wired intoextraRoots./add-dirdoes not exist in the desktop app.
Suggested fix
Preferred, ~1 line: declare the plans directory alongside the memory paths in the CLI's system/init message and add the corresponding Cl(e, r?.plans). The mechanism already exists and is already used for two other ~/.claude subdirectories; plans were simply never added to it.
Alternative: have permissions.additionalDirectories actually feed extraRoots, so the setting that appears to control this does. Worth doing regardless — the current behaviour is a trap.
Independently, fix the message. When the denial reason is outside_roots, use the existing l5hJ1Ge03E string rather than speculating about deletion. Surfacing the reason code would save the next person this trace.
Secondary bug found in the same function
Any path beginning with ~ is rejected as malformed_path before containment is evaluated. A tilde-form link is therefore unopenable even when its target is inside an allowed root. Not what bit me — the emitted path is fully expanded — but it will defeat the fix above wherever a path is rendered in tilde form.
Related issues
- #61443 — same resolver, "outside the session folder" on existing files (Windows); closed as duplicate, so a parent issue exists
- #51331 — same class, SSH sessions
- #45728, #14186 — plan mode writes to
~/.claude/plansignoring project-level overrides - #21042, #12707 — requests to relocate plans into the project; both closed as not planned, which makes the declaration side (above) the right place to fix this
What Should Happen?
Open plans without permissions issues
Error Messages/Logs
Steps to Reproduce
Create a plan, deny it ask to revise in auto mode and then click on the link with the updated lines. Permissions denied in the open file pane
Claude Model
None
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
Claude 1.44121.1 (0ec430) 2026-09-02T02:45:14.000Z
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Other
Additional Information
_No response_