[BUG] Auto-update runs for disabled plugins and re-clones source:"url" repos before comparing versions
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.
skipLfsexists only on marketplace sources — it appears in the settings schema twice, both inside the marketplace source union, and every call site is acase "github"/case "git"marketplace branch. The three git helpers that honor it (clone, pull, sparse-checkout) each mergeGIT_LFS_SKIP_SMUDGE:"1"into the git env when it is set. The plugin clone helper is a different function — it is the one taggedplugin_clonein telemetry, versusmarketplace_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)withD = 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. lastUpdatedundercounts it. It is written only on anupdatedoutcome, neverup_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
- Install a plugin from a marketplace with auto-update on, whose upstream is a large git-LFS repo and which ships releases frequently.
- Disable it:
"<plugin>@<marketplace>": falseinsettings.json. - Never use it.
- Start Claude Code sessions normally for a few weeks.
du -sh ~/.claude/plugins/cacheand listtemp_*directories; checklastUpdatedon the disabled plugin ininstalled_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 recordedgitCommitShaagainstgit ls-remotebefore cloning, as the tag-pinned path already does. - Optionally, honor
skipLfson 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.