[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

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 2 comments · opened Aug 1, 2026

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 the
projectPath 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 from
installed_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 projB session exits 1 with Not 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 cd steps are load-bearing: projectPath is recorded from originalCwd.
  • marketplace add writes extraKnownMarketplaces into 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 add needs 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 and
installed_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 chain
installPath: 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 own
projectPath, 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-miss wording; 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 project install overwrites installed_plugins.json rather

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 projectPath comparison inside KFe on 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 projectPath causes silent update

failure.

View original on GitHub ↗

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