Plugin registry: user-scope record with projectPath causes silent update failure and duplicate records (no CLI escape)
Summary
Three interacting behaviors in the plugin registry (~/.claude/plugins/installed_plugins.json) can leave a plugin permanently stuck on an old version while claude plugin update reports success every time. There is no CLI escape hatch: uninstall + install reproduces the broken state. Observed on Claude Code 2.1.216, Windows 11.
Behavior 1: trust/adoption writes a user-scope record carrying projectPath
When a plugin's enablement comes from tracked project settings (.claude/settings.json enabledPlugins in a repo), the trust/adoption flow writes a record under the plugin's ref that has scope: user (or is treated as the user-scope record) but ALSO carries a projectPath field. Timeline evidence on a fresh machine shows Claude Code wrote this record itself (no plugin/hook code had run yet; the write predates the first third-party code execution by ~100 seconds).
This chimera shape (user-scope + projectPath) is what breaks the two commands below. Additionally, the projectPath value on that record was later observed to drift to a different project's path without the record otherwise changing shape.
Behavior 2: claude plugin update --scope user stops matching that record but still reports success
With the chimera record present (and even earlier, while it was the only record), claude plugin update advanced a different record (or nothing at all) while logging updated <plugin> [0.45.0 -> 0.52.0] on every session. The plugin's loaded version never changed — the loader kept resolving the first/stale record, so 0.52.0 code never executed across five days of sessions, each one claiming the update succeeded.
Narrowing from our logs: the single-record variant of this behavior started between 2026-07-16 15:45Z and 2026-07-17 00:05Z, which suggests a change in Claude Code in that window.
Behavior 3: claude plugin install --scope user APPENDS instead of replacing when the existing record carries projectPath
Verified in one atomic write: 9 plugins were reinstalled with --scope user in the same second. The 8 whose existing user records had no projectPath were replaced (correct). The 1 whose record carried projectPath was duplicated — the new well-formed record was appended after the malformed one. Since readers resolve the first matching record, the plugin remained stuck on the stale entry, and uninstall/install cycles replace the healthy record while leaving the malformed one in place.
Impact
A plugin enabled via project settings can wedge permanently: old code loads forever, every session logs a successful update, and the supported CLI verbs cannot repair the registry. We ultimately shipped a standalone remediation that drops user-scope records carrying projectPath when a healthy duplicate exists — but this needs a fix at the source.
Suggested fixes
- Never write
projectPathon a user-scope record (or never treat such a record as user-scope). - Make
install/update --scope usermatch records by ref + scope regardless of extra fields, replacing rather than appending. - Have registry readers prefer the well-formed/highest-version record instead of first-match, so an accidental duplicate self-heals.
Environment
- Claude Code 2.1.216 (also observed across versions since ~2026-07-14)
- Windows 11 Pro, plugins installed from a git marketplace
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
This repos in 2.1.220 as well
Filed #81706 for what appears to be the mirror case of Behavior 1, arising from the same conflation of enablement scope and install scope.
This issue covers a
scope: userrecord that carries aprojectPath. #81706 covers the opposite: a plugin enabled at both user scope and project scope receives only ascope: projectrecord, so the user-level enablement has no install record satisfying it and the plugin is enabled-but-uninstalled in every project except the bound one. It surfaces asPlugin "X" not cached at <path>against a cache path that exists and is version-matched.Two details there that bear on this issue:
scope: "project"with noprojectPathfield at all, and in two refs such a record sits atentries[0], which is the record the loader resolves.Same machine and configuration as this report, on Claude Code 2.1.220.
I can corroborate your first-match observation from a different angle, with project-scope records rather than user-scope ones. This is on 2.1.220 running on MacOS 26.5.1. I think I can also pin down what "matching" means, because it turns out not to be an exact path comparison.
I had a different route into the duplicate state, but with the same consequence. Your behaviour 3 is
install --scope userappending when the existing record carriesprojectPath. Mine isinstall --scope projectwriting a new record per directory: a project-scope install keys to the session cwd rather than the project root, so installing from<repo>/aand later from<repo>gives two records for one plugin in one repo, silently. I've filed that half separately as #82830, since it's independently fixable - and if it were fixed, the duplicates would stop arising in the first place.What I can add is the selection rule. The resolver matches any record whose
projectPathis a descendant of the session cwd, then takes the first of those in array order. Records are appended in install order, so the oldest install wins permanently.Setup - two project-scope records, versions 0.9.0 and 0.13.0:
A session at
/Users/me/Dev/projresolves 0.9.0. The exact match loses to a record naming a subdirectory.Nothing in the UI reports which record is live, so I used two independent signals: invoking a plugin skill prints
Base directory for this skill: .../cache/<marketplace>/<plugin>/<version>/skills/<name>, and a plugin-provided subagent's tool list fingerprints its version where the two versions declare differenttools:. Mine grantsSkillin 0.13.0 but not 0.9.0, and the dispatched agents held the 0.9.0 set.Four tests, one variable each, fresh session every time, same nine cached versions throughout:
| Test | Change | Resolved |
|---|---|---|
| baseline | stale record at
<proj>/claude, index 0, v0.9.0 | 0.9.0 || retest | baseline config, upgraded 2.1.212 to 2.1.220 | 0.9.0 |
| 1 | stale record's projectPath changed to
/Users/me/Dev/unrelated| 0.13.0 || 2 | stale record's projectPath changed to
<proj>/zzz, a path that has never existed | 0.9.0 || 3 | path left as the descendant, array order swapped so 0.13.0 sits at index 0 | 0.13.0 |
What I take from those:
projectPathrather than a filesystem checkMy read is that the containment test is inverted somewhere. I'd assume the intent is that a session in a subdirectory inherits its project's plugins, i.e.
cwd.startsWith(projectPath); what I see behaves likeprojectPath.startsWith(cwd), so a session at the project root inherits records registered for anything beneath it.One note on your suggested fix: preferring the highest-version record would repair my case but I don't think it's sufficient on its own; a subdirectory's install would still win whenever it happened to be newer. I'd pair it with preferring a fully-matching
projectPath, so an exact match always beats a descendant (or an ancestor).Also worth mentioning for anyone triaging: I don't think this is the same bug as #77546. That one has a single record pointing at the newest version and the loader picking the oldest cache directory, which looks like cache-directory selection. Mine is record selection - editing a record's
projectPathflipped the outcome while every cache directory stayed untouched.Impact - this was driving me nuts for multiple days. A plugin's agents, tool grants and skills all came from a copy four releases old across ~10 dispatched subagent runs, and
claude plugin listreported everything installed and enabled the whole time. Every record shows✔ enabledwith nothing marking which is live (or even that multiple competing/colliding definitions exist).