[BUG] Claude Desktop: self-uploaded plugin cannot be updated or uninstalled — install record is frozen at first install
Component: Claude desktop app — plugins / "My Uploads" shelf
Severity: a self-published plugin cannot be updated or removed at all, and the
failure is silent until the next app restart.
Environment
| | |
|---|---|
| App | Claude desktop (CCD 2.1.266), MSIX Claude_1.52386.0.0_x64 |
| OS | Windows 11 Pro 10.0.26200 |
| Plugin | self-uploaded, id <plugin-id> |
| Marketplace | "My Uploads", <marketplace-id> |
| Installed | 2026-09-09T10:09:22.237679Z, at v1.2.0 (13 skills) |
| Observed | 2026-09-09 through 2026-09-11 |
Client plugin state referenced below lives at%APPDATA%\Claude\local-agent-mode-sessions\<user-id>\<workspace-id>\rpm\.
Identifiers are redacted here. The affected plugin id, marketplace id and account
can be supplied privately on request — the install record in question is still in
its frozen state and available for inspection.
Summary
Uploading a new version of a self-published plugin persists correctly. Every
write after the original install does not — install, update, uninstall. The
plugin view updates optimistically and reverts on restart, so a failed
server-side write is indistinguishable from success for the rest of the session.
There are two separable defects. They should be triaged separately, because
fixing either one alone changes the outcome.
Steps to reproduce
- Install a self-uploaded plugin from My Uploads (here: v1.2.0, 2026-09-09).
- Build v1.5.5 and upload it to My Uploads. **The marketplace entry correctly
shows 1.5.5.**
- Restart the app. The installed plugin still shows 1.2.0.
- Uninstall the plugin, reinstall from My Uploads, restart. Still 1.2.0.
- Uninstall it, then install a renamed build — byte-identical payload,
different name in plugin.json. Restart.
- Upload v1.9.1 to My Uploads. The upload persists; the marketplace entry
updates. The installed record stays at v1.2.0.
Expected vs actual
| After step | Expected | Actual |
|---|---|---|
| 3 | 1.5.5, 9 skills | 1.2.0, 13 skills |
| 4 | 1.5.5, 9 skills | 1.2.0, 13 skills |
| 5 | renamed plugin installed | never appears anywhere on disk or in the UI |
| 6 | marketplace 1.9.1, installed 1.9.1 | marketplace 1.9.1, installed 1.2.0 |
Step 5 is the informative one: a plugin with no prior installed record also
fails to install, so this is not version pinning or a stale cache of an existing
entry. The install path itself is not persisting.
Steps 2 and 6 show both sides cleanly: two separate uploads both persisted to the
marketplace, and neither reached the installed record.
Defect 1 — post-install mutations do not persist
[PluginsFetcher] fetchAccountEnabledPlugins returned 7 plugins before the
original install and 8 plugins from 2026-09-09 17:09:28 onward. Across every
sync on 2026-09-10 and 2026-09-11 it never returned to 7, through two
uninstall cycles.
So the original install did persist — the count moved 7 → 8 and stayed. What
never persists is every mutation after it. The UI reports success regardless; no
error is surfaced when the server-side write is rejected or dropped.
On-disk corroboration, unchanged through every attempt above:
<plugin-id>\.claude-plugin\plugin.json→"version": "1.2.0", mtime frozen
at 2026-09-09 17:09:30
<plugin-id>\skills\→ 13 directories, including four removed in later versionsrpm\manifest.json→updatedAt: 2026-09-09T10:09:22.237679Z,
updatedAtVerified: true
- no directory for the renamed build exists anywhere under
%APPDATA%\Claude
manifest.json is a live mirror, not a dead cache — it was observed being
rewritten eight seconds after a prior read, and the rewrite still returned the
2026-09-09 record. The client re-syncs, asks the account what is installed, and
the account keeps answering with the old state.
Defect 2 — re-upload does not rebind the install record
[RemotePluginManager] refreshes a user-installed plugin only when the remote
install record is newer than the local one. On 2026-09-11 08:24:51:
[RemotePluginManager] Refreshed 7 stale user-installed plugin(s)
(remote_newer, remote_newer, remote_newer, remote_newer,
remote_newer, remote_newer, remote_newer)
Seven of eight. The eighth is the self-uploaded plugin. In manifest.json the
seven first-party records carry updatedAt: 2026-09-10T20:23:0x; the
self-uploaded one still carries 2026-09-09T10:09:22.237679Z — the moment it was
first installed. Publishing a new version to the uploads shelf does not bump
the install record's updatedAt, so remote_newer is never true and the client
never re-downloads.
Controlled experiment: forcing the gate open
To separate "the client never asks" from "the server answers with the old
version", the gate was forced by hand.
Method. In rpm\manifest.json, set this plugin's updatedAt to2020-01-01T00:00:00.000000Z, leaving every other field and record untouched.
Restart the app.
Result. The gate fired, on exactly one plugin:
09:25:22 [RemotePluginManager] Refreshed 1 stale user-installed plugin(s) (remote_newer)
09:25:24 [RemotePluginManager] Sync complete: 1 downloaded, 0 removed, 0 orphans cleaned
The payload directory was re-materialised at 09:25:22 — its first write since
2026-09-09 17:09:30. What arrived was v1.2.0 with 13 skills, the same artifact
as before, and manifest.json was rewritten with updatedAt reset to the
server's value.
Conclusion. The client is not the blocker. When it does ask, the account
serves the artifact from the original install. Both the install record's
timestamp and its artifact binding are frozen at install time. Forcing a
refresh cannot deliver an update; it only re-downloads the original.
Ruled out
- Not packaging. The v1.5.5 archive was verified three times by two
independent toolchains: 35 entries, manifest present, 9 skills, zero backslash
path separators, ASCII-only manifest, matching sha256.
- Not the manifest version field.
versionwas bumped every attempt, and the
marketplace entry reflects it correctly each time.
- Not a name collision. The renamed build fails identically.
- Not a stale local directory. The durable state is server-side, and
updatedAtVerified: true marks the local copy a verified cache. Replacing the
payload by hand does load the new skills — and survives both an app restart and
routine periodic syncs, because the local updatedAt then matches the server's
exactly so remote_newer cannot fire — but it never touches the install
record, and a forced refresh reverts it. A local workaround, not a
counter-example.
Impact
- A self-published plugin cannot be updated or removed through any supported UI
path.
- The stale version here serves four skills that also exist as account-level
Cowork skills, so two skills compete for each trigger. Those four were
deliberately removed in the newer version precisely to end that collision, and
the fix cannot be delivered.
- The silent failure is what makes it expensive: the user sees a successful
install or uninstall until the next restart, with no error anywhere.
Suggested fixes
- Surface the write failure. Do not update plugin state optimistically
without reconciling against the server response; show an error when the
install/uninstall write is rejected or dropped.
- Make post-install mutations persist — restoring uninstall/reinstall as a
manual upgrade route.
- Rebind existing installs on publish. Publishing a new version should point
existing install records at the new artifact and bump updatedAt so the
refresh gate fires.
Fix 2 alone restores a manual upgrade path. Fix 3 is what makes publishing an
update mean anything.
Related
- #92234 — a broken plugin surviving uninstall/reinstall on Windows. I posted the log evidence above there as possibly related; a later report on that thread (the same plugin id failing across accounts, with the hook file present on disk but unreadable) points more toward the Sep 8 Windows drive-access incident than toward this frozen-install-record defect.
- #85218 — uninstall not persisting for account-scoped plugins (closed as not planned).
- #73907 — personal marketplace never re-syncing on Windows/Cowork (closed as not planned).