[BUG] Root cause: the `enabledPlugins` → `installed_plugins.json` sync is gated on plugin-key presence instead of `(key, projectPath)`, permanently starving every project after the first
Summary
The startup sync that reconciles installed_plugins.json against enabledPlugins
decides it has nothing to do by asking "does this plugin id have any record?"
rather than "does it have a record usable by this project?".
Consequence: the first project to install a plugin permanently starves every later
project of a sync. The later project never receives an install record, the loader
correctly refuses to use the foreign one, and the session reports a cache miss — every
session, forever.
Since v2.1.200 this no longer affects two working directories inside the same git
repository (see Scope below), but it still affects separate repositories, which is the
common case for a team sharing plugins via checked-in .claude/settings.json.
The defect
The codebase has a record-eligibility predicate — minified KFe in the 2.1.220 bundle —
which correctly answers "is this record usable by the current project?":
function KFe(e){
if (e.scope === "user" || e.scope === "managed") return !0;
let t = gn(); // originalCwd
if (e.projectPath === t) return !0;
if (!e.projectPath) return !1;
let r = gu(t); // git-root identity
return r !== null && gu(e.projectPath) === r
}
It is applied consistently across plugin management — .filter(KFe) in the loader's
record selector, .some(KFe) / .filter(KFe) in the enable, disable and uninstall
paths. Ase shows the intended shape: if (!r.some(KFe)) ….
The sync's skip-check is the one place that does not apply it (Ggy in 2.1.220,Kho in 2.1.183 — logically identical):
let g = [...r.keys()].every((_) => {
let E = m[_];
if (!E || E.length === 0) return !1; // existence only; KFe never applied
if (e.has(_)) return E.length === 1 && E[0]?.scope === "managed";
return !0
}),
y = Object.entries(m).every(([_, E]) => e.has(_) || !E.some((A) => A.scope === "managed"));
if (g && y) { w("All plugins already exist, skipping migration"); return }
E.length === 0 is satisfied by a record belonging to any project, so the sync
returns before it can create the record the current project needs.
The single-string control
From the failing state, edit installed_plugins.json and change only theprojectPath value — nothing else:
- "projectPath": "/tmp/repro/projA"
+ "projectPath": "/tmp/repro/projB"
Result: cache-miss count = 0, Found 1 plugins (1 enabled, 0 disabled).
Same cache directory, same version, same commit sha, same installPath, same
marketplace, same settings, same trust state. That one string is the entire difference —
which rules out a missing cache directory, a version or path mismatch, an unregistered
marketplace, trust gating, and the plugin not being enabled, all at once.
A second control isolates the sync itself. Deleting only the plugin's key frominstalled_plugins.json, leaving the cache directory byte-identical, makes the next
session run the sync:
[DEBUG] Syncing installed_plugins.json with enabledPlugins from all settings.json files
[DEBUG] Added agent-sdk-dev@claude-plugins-official with scope project
[DEBUG] Sync completed: 1 added, 0 updated in installed_plugins.json
Two caveats on that second control, both relevant to designing a fix. The sync runs
after the first plugin load, so the healing session still logs a cache miss and only
the session after it is clean. And the add branch does u[f] = [{...}], replacing
the array rather than appending — so healing project B moved the breakage onto project A
(Found 0 plugins, its own cache miss). A fix that merely runs the sync unconditionally
would ping-pong between projects rather than resolve.
Steps to Reproduce
Fully isolated; no devcontainer, editor, or unusual configuration involved. Uses the
first-party marketplace.
The two project directories must be in different git repositories, or not in a git
repository at all — see Scope below. The /tmp paths here are plain non-git
directories and reproduce as written.
export CLAUDE_CONFIG_DIR=/tmp/repro/cfg
mkdir -p /tmp/repro/{cfg,projA/.claude,projB/.claude}
S='{"enabledPlugins":{"agent-sdk-dev@claude-plugins-official":true}}'
echo "$S" > /tmp/repro/projA/.claude/settings.json
echo "$S" > /tmp/repro/projB/.claude/settings.json # identical
cd /tmp/repro/projA
claude plugins marketplace add anthropics/claude-plugins-official
claude plugins install agent-sdk-dev@claude-plugins-official --scope project
cd /tmp/repro/projB
claude --debug -p "x" </dev/null >/dev/null 2>&1
grep -hE 'cache-miss|not cached|skipping migration' "$CLAUDE_CONFIG_DIR"/debug/*.txt
Notes for anyone running this:
- The
projBsession exits 1 withNot logged in · Please run /login. That is
expected and irrelevant — plugin loading precedes authentication, so the debug log
already contains the relevant lines. Verified identical with real credentials.
- The
cdsteps are load-bearing:projectPathis recorded fromoriginalCwd. marketplace addwritesextraKnownMarketplacesinto user settings itself
(✔ Successfully added marketplace: … (declared in user settings)), so it does not
need to be in the project settings. It must run before install.
- Only
marketplace addneeds network. The failing session is fully offline.
Actual Behaviour
The not cached line repeats; de-duplicated for readability:
[DEBUG] Plugin not available for MCP: agent-sdk-dev@claude-plugins-official - error type: plugin-cache-miss
[DEBUG] Plugin loading errors: Plugin "agent-sdk-dev" not cached at
<cfg>/plugins/cache/claude-plugins-official/agent-sdk-dev/<sha> — run /plugin to refresh
[DEBUG] All plugins already exist, skipping migration
Project B never receives a record. Runs 2 and 3 reproduce identically andinstalled_plugins.json stays byte-identical (same md5) — this is permanent, not
first-run flakiness.
The named path exists and is fully populated, including .claude-plugin/plugin.json, so
the message is misleading in both halves: nothing is missing from the cache, and
refreshing does not help. The path comes from a fallback chaininstallPath: a ?? l ?? t, where t is the marketplace clone location.
Expected Behaviour
The sync should create an install record for the current project when no existing record
satisfies KFe — appending rather than replacing, so other projects keep theirs.
Suggested Fix
Apply the predicate the rest of the module already uses:
- if (!E || E.length === 0) return !1;
+ if (!E || !E.some(KFe)) return !1;
The add branch's u[f] = [{...}] should also append rather than replace, otherwise
fixing the skip-check converts a permanent failure into a ping-pong between projects.
Workaround
Running claude plugin install <id>@<marketplace> --scope project once per project
satisfies the skip-check legitimately. Verified stable: five alternating A→B→A→B→A
sessions produced zero cache misses in either project, both records retained their ownprojectPath, and all skills loaded in both.
Scope and versions
Reproduced on 2.1.183 and 2.1.220 (current latest). This is not a recent
regression, but the affected set narrowed in between:
| Layout | 2.1.183 | 2.1.220 |
|---|---|---|
| Two separate repos, or non-git directories | reproduces | reproduces |
| Two directories inside the same git repo | reproduces | does not reproduce |
The same-repo case is covered by the gu() git-root branch added to KFe in v2.1.200
("Fixed project-scoped plugins not loading correctly from git worktrees of the same
repository"). That change hardened the predicate; it did not make the sync apply it.
A maintainer who simplifies the repro to two directories in one checkout will not
reproduce this on latest.
Message text also differs by version:
- 2.1.220 —
not cached at <cfg>/plugins/cache/<mkt>/<plugin>/<ver> — run /plugin to refresh
(the versioned cache dir; contains .claude-plugin/plugin.json)
- 2.1.183 —
not cached at <cfg>/plugins/marketplaces/<mkt> — run /plugins to refresh
(the marketplace root; contains only marketplace.json)
Both named paths exist and are populated, so the message misleads on both versions.
Reproduced with plugins that carry a real version field and with ones versioned by
commit sha. The skip-check reads only the plugin-id key, record presence, and the managed
set — never marketplace, version, installPath, or dependencies — so it should be
independent of those, though only the version-format variation was tested empirically.
Platform: linux-x64, npm-global install.
Related issues
These read as downstream symptoms of this single defect rather than independent bugs.
Same failure via the settings-driven sync:
- bug 3 of issue #70655 is the purest statement of this: install in project A, add the
plugin to project B's enabledPlugins, UI shows it enabled, skills never load, no
error surfaced.
- issue #73796 — project-scoped plugin enabled in multiple repos never loads after
switching. Its "record gets re-stamped" observation matches the replacing add branch
described above.
- issue #81924 — the misleading
plugin-cache-misswording; correctly observes the
named path exists and is populated and that refreshing does not help.
- issue #81706 — enabled at both user and project scope, only a project-scoped record
results.
- issue #76759 — settings-driven auto-install writes records without populating the cache.
Same missing predicate at the install / marketplace-UI existence check, which blocks a
later project from ever obtaining a record:
- issue #14202 — the "(installed)" indicator and install command do not check
projectPath while the Installed tab does. Open since 2025-12.
- issues #14185 and #15791 — install refuses in project B because a record exists
globally.
- issues #18322 and #26513 — same, closed as stale or duplicate rather than fixed.
#26513 additionally shows that removing and re-adding the marketplace does not help,
because the record persists independently.
- issues #15524, #14815, #20390, #20077 and #19743 — further duplicates.
Adjacent, likely distinct bugs in the same area, included for context:
- issue #75392 —
--scope projectinstall overwritesinstalled_plugins.jsonrather
than merging; a comment there reports that merely using a plugin re-scopes records to
the current project. Consistent with the replacing add branch above.
- issue #74912 — case-sensitive
projectPathcomparison insideKFeon Windows. - issue #82830 — project-scope install keys to session cwd rather than project root,
poisoning KFe's input.
- issue #60380 — the same sync cannot recover a record it dropped for URL-based plugins.
- issue #79892 — a user-scope record carrying a
projectPathcauses silent update
failure.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗