[BUG] Auto-update runs for disabled plugins and re-clones source:"url" repos before comparing versions

Status Open
Reported on v2.1.271
Maintainer reply None cached
Activity 0 comments · opened Sep 15, 2026

What's Wrong?

Differential first, since the plugin cache has several open issues and this is none of them:

  • Not #90781 — that reports temp_git_* clone-staging dirs surviving and never being swept. This reports what creates them in the volume it does. #90781 is the leak; this is the tap. Fixing either alone leaves the other.
  • Not #86139 / #84401 / #89243 / #91271 — those are all about auto-update being silently off, not pulling, or losing autoUpdate. This is the opposite direction: updates running for plugins that should be excluded.
  • #22461 (closed as duplicate, Feb 2026) described "marketplace plugins re-fetched repeatedly despite valid cached version" — same family, apparently never root-caused. This report supplies a mechanism.

Observed on 2.1.271 and 2.1.272, macOS.

---

Two defects that compound

1. Auto-update does not filter on enabledPlugins.

A plugin disabled in settings.json is still resolved and updated. Directly checkable — settings.json:

"enabledPlugins": { "hyperframes@claude-plugins-official": false }

and installed_plugins.json for that same plugin:

"hyperframes@claude-plugins-official": [{
  "scope": "user",
  "installPath": ".../cache/claude-plugins-official/hyperframes/0.8.38",
  "version": "0.8.38",
  "installedAt": "2026-06-17T03:52:59.124Z",
  "lastUpdated": "2026-09-14T22:30:10.100Z"
}]

Disabled since June; updated in September. The plugin has never been invoked once.

This looks like an omission rather than a decision: the command-source refresher in the same chunk does check, and says so — the binary contains the literal Plugin autoupdate: not re-resolving + (disabled or blocked by policy). The regular pass has no equivalent. It does read the enabled/disabled sets, but feeds them to the pinner-constraint lookup (disabledPinners, autoupdate-blocked-by-pinner), not to a skip.

**2. source:"url" plugins are cloned before the version is compared.**

Early up_to_date returns exist for claudeai sources, the archive/entry-helper path, and tag-constrained pinner paths (resolvedVersion + gitCommitSha compared against ls-remote). A plain source:"url" entry matches none of them, so the full clone is paid on every pass even when the installed version is already current.

Evidence on disk — two staging clones of the same repo, 86 minutes apart on the same day, after the plugin was already recorded at its current version:

temp_git_1789423887909_lebnqz   15:11   922M   .git/lfs = 371M
temp_git_1789429044078_c9gob5   16:37   877M
both: git config remote.origin.url = https://github.com/heygen-com/hyperframes.git

The second produced no version change and wrote no lastUpdated.

---

Why it gets expensive

  • Git-LFS smudge is on and cannot be turned off for a plugin source. skipLfs exists only on marketplace sources — it appears in the settings schema twice, both inside the marketplace source union, and every call site is a case "github" / case "git" marketplace branch. The three git helpers that honor it (clone, pull, sparse-checkout) each merge GIT_LFS_SKIP_SMUDGE:"1" into the git env when it is set. The plugin clone helper is a different function — it is the one tagged plugin_clone in telemetry, versus marketplace_clone — and it passes its git env straight through with no LFS merge and no equivalent option. So each plugin clone pulls the full LFS payload: ~880 MB working tree, which becomes a 2.2 GB install dir once dependencies are installed.
  • Per-process scheduling. Each Claude Code process runs the pass once, after a randomized delay of up to ten minutes (Math.floor(Math.random()*D) with D = 600000). I could not establish whether any longer-period throttle also applies — but empirically it did not prevent two full clones of the same repository 86 minutes apart on the same machine on the same day, and with many concurrent sessions the passes overlap and abandon partial clones.
  • lastUpdated undercounts it. It is written only on an updated outcome, never up_to_date — so the field you would naturally check to measure this is blind to most of the traffic. The abandoned clones on disk are the honest record.

Net cost on one machine: a disabled, never-used, git-LFS plugin accounted for ~39 GB of a 48 GB plugin cache — 41 leftover staging clones totalling ~26 GB, plus 6 cached version directories at 2.2 GB each, of which only one was referenced.

Steps to Reproduce

  1. Install a plugin from a marketplace with auto-update on, whose upstream is a large git-LFS repo and which ships releases frequently.
  2. Disable it: "<plugin>@<marketplace>": false in settings.json.
  3. Never use it.
  4. Start Claude Code sessions normally for a few weeks.
  5. du -sh ~/.claude/plugins/cache and list temp_* directories; check lastUpdated on the disabled plugin in installed_plugins.json.

Expected

A disabled plugin is not fetched. A source:"url" plugin whose recorded gitCommitSha still matches git ls-remote is not re-cloned.

Suggested fix

  • Add the enabled check to the regular pass so it matches the command-source refresher's existing behavior.
  • For source:"url", compare the recorded gitCommitSha against git ls-remote before cloning, as the tag-pinned path already does.
  • Optionally, honor skipLfs on plugin entry sources too.

Happy to supply the function names and byte offsets I read this from, though they are build-specific; everything above is reproducible from the on-disk artifacts without them.

View original on GitHub ↗