Plugins installed/enabled out-of-band are silently pruned by the in-use sweep

Open 💬 0 comments Opened Jun 19, 2026 by jerrythomas

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.jsonenabledPlugins
  • ~/.claude/settings.jsonextraKnownMarketplaces
  • ~/.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 UTC asctime format).

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:

  1. Drop marker files whose pid is no longer a live process whose start time still

matches procStart (pid-reuse guard).

  1. 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 runs
claude 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

  1. 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>
``

  1. Confirm the plugin is correctly registered:
  • ~/.claude/settings.jsonenabledPlugins["<plugin>@<marketplace>"] === true
  • ~/.claude/plugins/installed_plugins.json lists <plugin>@<marketplace>
  1. 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.

  1. Trigger / wait for the in-use sweep (it runs periodically;

~/.claude/plugins/.last_inuse_sweep records the last run).

  1. 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.json retains an

entry for a marketplace no longer referenced by any installed plugin).

Suggested fixes (any one would resolve it)

  1. 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.

  1. Provide a supported persistent ("pinned") in-use claim that survives the sweep,

so an external installer/daemon can register durable intent.

  1. Document the .in_use marker contract (path, JSON shape, procStart format) so

out-of-band installers can write a durable marker tied to a long-lived process they
own.

  1. 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.

View original on GitHub ↗