Plugins installed/enabled out-of-band are silently pruned by the in-use sweep
Plugins installed/enabled out-of-band are silently pruned by the in-use sweep
Summary
A plugin that is installed and enabled programmatically (via claude plugin install
run from a short-lived, non-interactive process — a script, CI step, or a background
daemon) is silently removed by Claude Code's periodic plugin in-use sweep, even
though it is correctly recorded as installed and enabled.
The plugin disappears from all three of:
~/.claude/settings.json→enabledPlugins~/.claude/settings.json→extraKnownMarketplaces~/.claude/plugins/installed_plugins.json
…leaving ~/.claude/plugins/known_marketplaces.json behind (so the on-disk state is
left internally inconsistent). The plugin's behavior (hooks, MCP servers, commands)
then silently stops applying to subsequent sessions.
This makes programmatic / headless / daemon-managed plugin provisioning unreliable: a
tool can install a plugin, verify it is present and enabled, and have it vanish a short
time later with no error and no log surfaced to the user.
Environment
- Claude Code (observed on the 2.1.x desktop build; the in-use sweep machinery is present
in the bundled CLI binary).
- macOS (the marker timestamps are stored in a
ps-style UTCasctimeformat).
Root cause (as far as it can be determined from on-disk state)
Claude Code tracks which plugins are "in use" with per-version marker files:
~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/.in_use/<pid>
Each marker is JSON of the form:
{"pid": 12345, "procStart": "Thu Jun 18 14:15:10 2026"}
procStart is the owning process's start time as a UTC asctime string
(%a %b %e %H:%M:%S %Y, space-padded day, second precision — i.e. ps -o lstart
converted to UTC).
A periodic in-use sweep (it records its last run in~/.claude/plugins/.last_inuse_sweep) appears to:
- Drop marker files whose
pidis no longer a live process whose start time still
matches procStart (pid-reuse guard).
- Prune any plugin left with zero live markers — removing it from
installed_plugins.json and settings.json.
When a plugin is enabled inside a long-running interactive session, that session
holds a live marker for the whole session, so the plugin always has ≥1 live marker and
survives every sweep. (In practice a stable plugin accumulates many markers — one per
live session that has it enabled.)
When a plugin is installed by a short-lived process (e.g. a script that runsclaude plugin install and exits, or a daemon that shells out to install it), the only
marker written belongs to that installer process. The installer exits, its marker goes
stale, and on the next sweep the plugin has zero live markers → it is pruned, despite
being validly recorded as installed + enabled.
Steps to reproduce
- From a non-interactive, short-lived process (a shell script is enough — the key
is that the installing process exits), add a marketplace and install + enable a
plugin:
``sh``
claude plugin marketplace add <owner>/<marketplace-repo>
claude plugin install <plugin>@<marketplace>
- Confirm the plugin is correctly registered:
~/.claude/settings.json→enabledPlugins["<plugin>@<marketplace>"] === true~/.claude/plugins/installed_plugins.jsonlists<plugin>@<marketplace>
- Inspect the plugin's in-use markers:
``sh``
ls ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/.in_use/
The only marker is the installer process's pid — and that process has already exited
(the marker is stale). Compare with a plugin you enabled from an interactive session,
which has one or more markers belonging to live session pids.
- Trigger / wait for the in-use sweep (it runs periodically;
~/.claude/plugins/.last_inuse_sweep records the last run).
- Observe: the plugin is now gone from
enabledPlugins,
extraKnownMarketplaces, and installed_plugins.json, while
known_marketplaces.json still lists the marketplace. No error is surfaced.
Minimal marker-level demonstration (no full prune needed)
To confirm the liveness rule directly, give a single installed plugin two markers — one
for a live arbitrary process and one for a dead pid — then let a sweep run:
D=~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/.in_use
printf '{"pid":%d,"procStart":"%s"}' "$LIVE_PID" "$LIVE_PROCSTART" > "$D/$LIVE_PID"
printf '{"pid":999999,"procStart":"Thu Jun 18 14:15:10 2026"}' > "$D/999999"
After the sweep: the 999999 marker is removed, the live marker is kept, and the plugin
survives — demonstrating that the keep/prune decision is purely "is there a live marker",
independent of which process owns it.
Impact
- Any out-of-band plugin provisioning (install scripts, CI, MDM/fleet management, a
companion app or daemon that manages a plugin) is unreliable — the plugin silently
disappears between sessions.
- Failure is silent: the user sees the plugin's features simply stop working, with
no error and nothing obvious in logs.
- The post-prune on-disk state is inconsistent (
known_marketplaces.jsonretains an
entry for a marketplace no longer referenced by any installed plugin).
Suggested fixes (any one would resolve it)
- Don't prune explicitly-recorded plugins. A plugin present in
installed_plugins.json with enabledPlugins[...] === true should not be removed by
the in-use sweep — only genuinely orphaned cache directories should be garbage
collected. Enablement is an explicit user/operator decision; the sweep is overriding
it based on transient process liveness.
- Provide a supported persistent ("pinned") in-use claim that survives the sweep,
so an external installer/daemon can register durable intent.
- Document the
.in_usemarker contract (path, JSON shape,procStartformat) so
out-of-band installers can write a durable marker tied to a long-lived process they
own.
- Surface the prune. If the sweep removes an enabled plugin, log it where the user
can see it rather than silently editing settings.json.
Notes
- The marketplace itself is healthy in the failing case (reachable, valid
marketplace.json); this is not a broken-source prune.
- The sweep does not appear to be triggered by CLI session starts (print mode, stream-json
agent sessions, and interactive sessions were all observed not to run it, even with
.last_inuse_sweep backdated). It appears to run on a longer periodic cadence from the
desktop application, which makes the silent removal hard to correlate with any user
action.