A subdirectory plugin install has no .git, so `hermes plugins update` and the
dashboard/TUI update re-clone it through _install_plugin_core. That core now
checks the kill list, but the re-clone did not pass on the install record's
saved allow_removed exemption, so updating a recalled plugin the user had
installed with --allow-removed was refused.
_pull_plugin_update now forwards the record's allow_removed into
_reclone_plugin_update and on to the core, which writes it back on the new
record. Installs without the exemption are still refused before any re-clone.
- 2.1 hold recorded during the check: update_one/dashboard_update_user_plugin(unattended=True) re-read
the install record under the per-plugin update lock and skip a --ref hold (HeldWithRef / held);
_install_plugin_core takes the same lock, so an install --ref cannot publish between the re-check
and the updater's publication.
- 2.2 custom Git widening: the unattended gate compares the staged candidate's surface (tools, hooks,
capabilities, ...) with the installed one before publication (git pull and subdirectory re-clone)
and returns needs_review; a named update still applies it.
- /plugins update <name|all|--yes> refuses extra arguments with a usage line instead of updating
every plugin.
- hermes plugins update (cmd_update_all) skips catalog installs held with --ref, like every other
Update all.
main already ships `hermes plugins update` (no name = update all) and the
opt-in `plugins.auto_apply` cadence. This makes both reachable from every
surface and closes the gaps reported on the original PR:
- Catalog installs now ride `plugins.auto_apply` too (it moved only git
rows, so catalog plugins stayed on their install-day pin). The update
path still refuses a widened pin or a too-new requires_hermes unattended.
- One updater per plugin: a cross-process lock around update_one and the
dashboard/Desktop update, so the gateway's auto-apply tick and a manual
update never run on the same plugin at once; the loser changes nothing.
- `hermes update` names, per profile, the catalog installs its new catalog
pins left behind and re-arms that profile's plugin check, so an opted-in
gateway applies them on the next tick.
- New CLI verb `hermes plugins auto-update [on|off]`; `/plugins update` and
`/plugins auto-update [on|off]` in the classic CLI and the TUI (slash
worker).
- RPC `plugins.manage` actions `update_all` and `auto_update` (contract +
regenerated apps/shared outputs) for Desktop.
Off by default (legacy behaviour); all writes are profile-scoped through
get_hermes_home()/the scoped RPC.
The catalog entry's requires_hermes was display-only: `hermes plugins
install <name>` cloned and published a pin the catalog says needs a newer
Hermes, and `hermes plugins update` re-pinned an installed plugin onto it.
Only the plugin's own plugin.yaml floor was checked, and only at load.
- install_catalog_entry (the one seam every install by catalog name goes
through: CLI, dashboard/Desktop/TUI plugins.manage, `hermes setup`'s
importer step, the left-core and memory-provider auto-installs) refuses
with "<plugin> needs Hermes <spec>; this is <version>. Update Hermes
(`hermes update`) and install again." before any clone. The connector
card's pre-check uses the same function.
- repin_catalog_plugin (every catalog update) keeps the installed pin and
says the update is available after updating Hermes; `hermes plugins
update` with no name lists such a plugin as held instead of applying it,
and check-updates marks it.
- `hermes plugins search` / `info` still list incompatible entries,
marked as needing a newer Hermes.
Portable packages (plugin.json) have no version field, so the catalog
entry's requires_hermes is their only floor; the gate is kind-agnostic and
covers them (tested), and the agent_plugins comment and docs say so.
The verdict is plugins_manifest.requires_hermes_error itself (same
running_hermes_version + version_satisfies), so a dev checkout, canary
build or tagless checkout is judged exactly as the plugin.yaml gate judges
it. No override flag.
record_check_results read the install records after the network checks
finished. An update that landed during the check (A to B) was stamped
with B's record, so the A to B result matched and Desktop kept offering
an update that had already finished. Both callers now snapshot the
install records before the check and pass that snapshot in.
- Dashboard: the update route answered a scan block with a bare HTTP 400,
so the browser page could not show the findings or send force. It now
passes scan_blocked through (update route only; install is unchanged),
and the page's update button runs the same consent-then-scan flow as
Desktop through web/src/lib/plugin-update.ts. AgentPluginUpdateResponse
moves to web/src/lib/api-plugins.ts (api.ts is over the size cap).
- Saved update checks now record the install they were checked against
(source, revision, pin). A result is dropped once any of those changed,
so a --ref pin or a reinstall from another source no longer shows an
Update button that update refuses. An update_url feed's version target
is offered too (the old match compared it to a commit and never hit);
the row field is update_target, a commit or a version.
- Each finding in the Desktop dialog shows its description.
- Tests: a real subdirectory install exercises the re-clone path (caution
needs force, dangerous refused even with it, refusals leave the tree and
record alone); the dashboard route returns findings and applies with
force; the saved signal drops on update, new source and pin.
- locales/_keys.desktop.json regenerated for the new Desktop strings.
Desktop's plugins.manage update refused every non-catalog install ("not a
catalog install — update it via the CLI") and kept its own third copy of
the re-pin logic. It now calls dashboard_update_user_plugin, the same
function the dashboard route uses, so catalog and git installs update the
same way on every surface.
A scan block comes back as ok=false + scan_blocked + scan_verdict +
scan_findings (the shape install already returns) with nothing changed;
the client retries a caution verdict with force. The dashboard route reads
force from its body next to accept_capabilities.
The Plugins tab can only offer Update when it knows an update exists. Git
installs had no such signal, so the row never showed a button. The gateway
already runs the update check every plugins.auto_update_check_hours, and
`hermes plugins check-updates` runs it on demand; both now save the results
next to the cadence marker. The plugin list reads that file (no network per
listing) and sets update_available + latest_sha on git rows. A saved result
is ignored once the install's revision moves past the one it compared.
`hermes plugins update NAME` blocked a caution scan verdict with no way
past it, and the error told the user to pass a `--force` flag the update
command did not have. The scan ran with force=False and no prompt
(plugins_transaction.py update_plugin), unlike install, which asks.
- update_plugin takes force and scan_decision_cb and passes them to the
scan. A dangerous verdict still always blocks.
- `hermes plugins update NAME` asks "Update anyway? [y/N]" on a terminal,
with the same prompt install uses (now a shared helper), and --force
accepts a caution verdict without asking.
- The re-clone path (subdirectory installs with no .git) used
_install_plugin_core(force=True), where force meant both "replace the
tree" and "accept a caution scan", so it skipped the caution check
silently. A separate accept_caution parameter keeps the replace and the
scan decision apart, so both update paths follow the same rule.
Split cmd_update into update_one(name, target, console, interactive) -> bool,
which raises RepinConsentRequired / PluginOperationError on a refusal, and a
thin cmd_update that maps those to exit 1 as before. cmd_update_catalog now
returns whether the pin moved and raises instead of exiting. The update-all
loop calls update_one and catches the typed refusals, so the stdout tee,
ANSI stripping and metadata re-read are gone. Addresses Greptile review.
Running `hermes plugins update` without a name now runs the check-updates
probe, lists the plugins the update path can actually move (catalog re-pin,
git pull), names every one it skips and why (pinned, needs fixing, unknown,
pip distributions Hermes never upgrades — #132494), asks once, and updates
each through cmd_update so per-plugin consent prompts still apply. One
failure does not stop the rest; the summary quotes each failure's reason.
Without a terminal nothing moves unless --yes is passed; --yes runs the
updates with interactive=False, so consent stays fail-closed.
This reverts commit 0f0b0a12aa.
#134189 refused plugin update, remove/uninstall and force-reinstall on every
platform whenever the profile gateway was live, so uninstalling a plugin
(notably on Windows, from Desktop) required shutting the gateway down. The
#70473 hazard it guarded (deferred imports in a loaded plugin hitting a
swapped checkout) does not justify blocking uninstall; it will be re-landed
scoped to the paths that need it.
Git plugin update/remove/force-reinstall ran git pull or tree swaps
directly in the installed checkout while the messaging gateway could
still be running with that plugin's callbacks loaded. A callback that
defers a relative import until first invocation then fails with
ModuleNotFoundError even though the command reported success (#70473).
One shared guard (hermes_cli.plugins_cmd._refuse_live_gateway_mutation)
now fails every mutating path BEFORE any git operation when the active
profile's gateway is live:
- plugins update (CLI) and dashboard_update_user_plugin
- plugins remove / rm / uninstall, dashboard_remove_user_plugin and the
plugins.manage RPC (shared _remove_user_plugin tail)
- plugins install --force (a forced reinstall replaces a loaded
checkout; a brand-new install is not gated)
The liveness read is the canonical profile-scoped probe
(profiles._check_gateway_running -> resolve_gateway_liveness), never
mutating the profile's identity files; a probe failure fails open so an
uninstallable plugin cannot strand. The message gives the
stop/apply/start recovery sequence; --allow-live-gateway on
update/install/remove is the explicit CLI-idiom override.
Integration test (tests/hermes_cli/test_plugin_live_gateway_guard.py)
spawns a real child holding gateway.lock with a gateway-shaped command
line against a temp HERMES_HOME, so the guard is exercised through the
platform's actual liveness probe — not a mock. Stopped-gateway behaviour
is asserted unchanged.
plugins_cmd.py had grown to 2,858 lines, past the ~2,000-line gate. Move
each verb family into a plugins_cmd_<topic>.py sibling: git (install
metadata + git plumbing), install, update (plus adopt / trust-update-url /
check-updates), remove, capabilities, toggle (composite UI) and listing.
The facade keeps the shared primitives, enable/disable selection,
discovery and the dispatch table, and re-exports the names other modules,
tests and the old-updater surface import (978 lines now).
Siblings never import the facade at module level; they read facade names
through _pc() at call time, so monkeypatching plugins_cmd.<name> still
intercepts calls made from a sibling. No behaviour change. Tests that
imported three sibling-only helpers now import them from the defining
module, and two subprocess.run patches target subprocess directly.