Plugin registry: user-scope record with projectPath causes silent update failure and duplicate records (no CLI escape)

Status Open
Reported on v2.1.216
Maintainer reply None cached
Activity 4 comments · opened Jul 21, 2026

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 projectPath on a user-scope record (or never treat such a record as user-scope).
  • Make install/update --scope user match 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

View original on GitHub ↗

3 Comments

kitaekatt · 1 month ago

This repos in 2.1.220 as well

kitaekatt · 1 month ago

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: user record that carries a projectPath. #81706 covers the opposite: a plugin enabled at both user scope and project scope receives only a scope: project record, 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 as Plugin "X" not cached at <path> against a cache path that exists and is version-matched.

Two details there that bear on this issue:

  • The append-instead-of-replace behavior described here as Behavior 3 is visible in that report as well -- one ref accumulated four records, all the same version, one per project it was adopted in.
  • Some of those records have scope: "project" with no projectPath field at all, and in two refs such a record sits at entries[0], which is the record the loader resolves.

Same machine and configuration as this report, on Claude Code 2.1.220.

smm11235 · 1 month ago

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 user appending when the existing record carries projectPath. Mine is install --scope project writing a new record per directory: a project-scope install keys to the session cwd rather than the project root, so installing from <repo>/a and 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 projectPath is 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:

[0] 0.9.0   projectPath /Users/me/Dev/proj/claude
[1] 0.13.0  projectPath /Users/me/Dev/proj

A session at /Users/me/Dev/proj resolves 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 different tools:. Mine grants Skill in 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:

  • test 1 - path matching is what decides candidacy
  • test 2 - any descendant path qualifies, including one that has never existed on disk; so it's a string containment test on projectPath rather than a filesystem check
  • test 3 - among candidates the winner is chosen by array position, not by version; 0.9.0 loses as soon as it is second. That was my first theory (lexicographic version comparison, since "0.13.0" sorts below "0.9.0" as a string) and it's refuted

My 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 like projectPath.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 projectPath flipped 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 list reported everything installed and enabled the whole time. Every record shows ✔ enabled with nothing marking which is live (or even that multiple competing/colliding definitions exist).

Showing cached comments. Read the full discussion on GitHub ↗