Plugins that need the user's ChatGPT/Codex subscription (hermes-live-voice mints
an OpenAI Realtime client secret) vendored their own reader + refresher for
~/.codex/auth.json. Codex refresh tokens are single-use, so that second refresher
races Hermes's locked refresh and can revoke the user's whole token family.
hermes_cli.plugin_provider_requests.credentialed_provider_request() lets core make
the request instead: it resolves the credential through
resolve_codex_runtime_credentials (Hermes's locked refresh, profile-scoped),
attaches Authorization itself, sends only to chatgpt.com / api.openai.com, never
follows redirects, and returns status/headers/body. Callers are attributed by the
plugin directory on the call stack and must declare `requires_auth: [openai-codex]`
in plugin.yaml; the declaration shows at install, in `hermes plugins show`, and a
catalog re-pin that adds it asks for consent. A plain importable function so
dashboard plugin_api.py modules can use it.
The Desktop/TUI/dashboard toggle edited the saved list before the plugin
loaded, so a backend that scanned while the plugin was off found no toolset
key and left the toolset out: the plugin showed enabled with its tools off.
Enable now edits the list after activation; disable resolves the key before
the disable is written. hermes plugins enable/disable make the same edit.
Only saved lists are touched; a fresh config still gets no list.
Third-party drivers (e.g. a Codex-style computer-use reimplementation) could
only replace cua-driver by patching core. computer_use now follows the
single-select provider model Hermes uses for memory providers, context
engines and cron schedulers:
- plugins/computer_use/__init__.py discovers bundled
plugins/computer_use/<name>/ then user $HERMES_HOME/plugins/<name>/
(marker: register_computer_use_provider / ComputerUseProvider) and loads
ONLY the provider computer_use.backend names (default "cua"), read per
call so each profile gets its own. Unselected providers are never
imported; listing reads plugin.yaml only. An unknown/unloadable name
returns an error naming it and the installed providers; no fallback.
- The built-in cua-driver is an ordinary bundled provider at
plugins/computer_use/cua/ (driver implementation stays in
tools/computer_use). tools/computer_use/backend.py gains
ComputerUseProvider (create_backend(permission_mode=...), is_available(),
optional doctor()).
- The tool's check_fn and backend construction go through the selected
provider; the model-facing schema is unchanged for every provider
(prompt-cache invariant). The test-only HERMES_COMPUTER_USE_BACKEND env
switch is gone (tests patch _new_backend).
- General plugin discovery treats computer-use providers as exclusive
(not eagerly imported, not listed as toggleable) and skips the bundled
computer_use/ category, same as memory/, context_engine/, cron_providers/.
- hermes tools and the Desktop toolset panel list installed providers next
to cua; computer-use doctor/status/permissions and the Desktop status
payload skip cua-driver/TCC probes when another provider is selected.
`ctx.inject_message` could only reach an existing gateway session (`session_key`), so an
orchestrator plugin that opens a chat/thread itself (a bot-authored Discord forum post never
comes back as an inbound message) had no way to get an agent turn there.
`origin` takes the SessionSource.to_dict() shape. The gateway creates (or continues) that chat's
session through the adapter's normal message path, in the profile whose plugin manager loaded the
plugin, and the reply goes back to the origin chat:
- Profile: resolved from the manager's immutable home, never an ambient one. The origin may not
name another profile, the gateway's own routing for that chat through that profile's adapter
must land in the plugin's profile, and the profile needs its own connected adapter for the
platform (another profile's bot is never borrowed). Refusals are synchronous False + one log line.
- Authorization: same current-allowlist check as the session_key path (no bypass for host code).
- Multiplex: handle_message runs inside the profile's runtime scope so the spawned turn AND the
reply send after it inherit that profile's home and secrets (applies to the session_key path too,
which used to send from the default loop context).
- allow_gateway_injection is read from the plugin's own profile config.
- Plugin/system turns (event.internal) skip the human first-contact notes (onboarding intro and
the "no home channel" nudge), which were posted into the plugin's fresh thread.
Requested by canonicle-desk in #132609.
Closes#132609
Catalog category "memory" also holds hook plugins, context engines and
skill packages. Deciding from the category made `--enable` overwrite the
user's memory.provider with an unloadable name and never enable the plugin.
Decide from the installed tree (_is_memory_provider_dir), and save
memory.provider only when find_provider_dir resolves the name.
The install prompt for a memory provider now sets memory.provider; say so on
the Hindsight setup and plugin install pages and drop the claim that the
plugin is enabled under plugins.enabled.
One observer pair fires wherever the agent blocks waiting on a person, so a
notifier plugin no longer needs a hook per prompt type or a `sudo` regex over
terminal commands (#132333, consumer: pushover-hermes-plugin).
Fire sites are the existing choke points every surface already routes through:
- sudo: tools/terminal_tool_sudo.py::_prompt_for_sudo_password (CLI callback,
Ink TUI/Desktop sudo.request card, /dev/tty fallback)
- clarify: tools/clarify_tool.py::clarify_tool (CLI, TUI/Desktop, gateway)
- approval: tools/approval_prompt.py::prompt_dangerous_approval (CLI/TUI/ACP
callback and input() paths), ::_present_with_selected_transport (plugin
transports), tools/approval_gateway_wait.py::_await_gateway_decision
(gateway platforms)
Payload: kind, request_id (shared by the pair), session_id, session_key,
platform, prompt; resolved adds outcome. prompt is always force-redacted and
the typed password / clarify answers are never passed. Smart (aux-LLM)
approvals and coalesced gateway followers do not fire it: no new human prompt.
Closes#132333
install --force --ref is the documented way to move a pinned plugin, but it replaced the whole plugin directory: untracked and git-ignored files (plugin state, local config) were deleted and tracked edits were lost with no copy. A reinstall from the source the plugin was installed from now carries user files like update does and copies tracked edits to plugins-backup/. A different source still starts clean.
plugins.isolation is profile config, so a profile (or its agent) can set it
back to in_process. The managed scope already beats profile config and makes
`hermes config set` refuse, and the isolation reader goes through that
overlay; document it as the operator lock and pin the contract with a test
(red when isolation_mode() reads the raw profile file).
Reverts all seven commits of #119381 (59c54c78..476ff4dd), which
re-landed the native card-based Skills/Plugins catalog browser after it
was reverted twice (#113037 reverting #112669, #115787 reverting
#113128). Capabilities -> Skills and Plugins return to the list/detail
tabs with the embedded catalog picker they had before #119381.
Removed with it:
- Desktop catalog/ module (browser, cards, detail dialog, filters,
reels/masonry, install switch) and skill deep-link install
(hermes://skill/install); skills/embedded-hub-picker.tsx comes back.
- apps/shared catalog-browse.ts and catalog-install.ts.
- The `catalog` i18n namespace in every locale (+ regenerated
locales/_keys.desktop.json); the two i18n pack tests and the keys
test now exercise connectorsPage.searchPlaceholder instead.
- Website /skills "Open in Hermes Desktop" skill links and the
extract-skills install identifiers; docs return to the pre-#119381 text.
- Later fixes that only patched the reverted catalog code go with it:
5832a6eb93 + 7aba2cbbe9 (catalog download deadline),
9e63cdc4b8 (catalog lookalike rows), c756986db2 (catalog counts),
the catalog half of c13232e90c / f265273347.
Kept on purpose (later work that depends on code #119381 introduced):
- DialogContent `chrome` / `overlayClassName` / nested-dialog layering
(the image lightbox uses them).
- vitest.setup.ts / src/test/jsdom.ts stubs (other suites rely on them).
- Kanban toolset wiring on the Plugins Desktop switch (cd30fdadf6,
7ca2f5665f) and its tests, the `hermes_not_connected` server tone,
the SearchField flex-1 fix, skill provenance helpers. SkillDetail shows
a provenance pill only for agent/hub skills, since the pre-#119381
labels have no `external` entry.
The bare `hermes plugins` picker preselected only rows listed in
plugins.enabled. Bundled platforms, backends and model providers are active
without a list entry, so they opened unticked, and the save on exit wrote every
unticked row into plugins.disabled: opening the picker and leaving without a
change disabled every messaging adapter on the next gateway restart.
Rows now open ticked by the load-time rule (_plugin_status), and only rows the
user flipped are written.
Ported onto the plugins_cmd_toggle sibling and the admission-authority save
(the original targeted the pre-split plugins_cmd.py). The nested
canonical-key composite test now ticks its row explicitly: it handed the menu
a checkbox state that contradicted the config and relied on the old
rebuild-everything save. The original helper-level tests are rewritten
against real admission in a follow-up commit.
(cherry picked from commit 3a0d5f1b8bce3b23b115ba4995b8c23dfbba9ad0)
Ink TUI and desktop never registered an inject host, and sharing
set_gateway_message_injector with a live messaging gateway would let
the last writer win. A separate host queues the reported session_key
onto that session's prompt queue and leaves other keys for the gateway.
Fixes#87412
A plugin that lives in a monorepo subdirectory (the Hindsight catalog entry:
vectorize-io/hindsight#hindsight-integrations/hermes) was cloned with every
file in the repository even at --depth 1: 170 MB for a 2 MB plugin folder.
On a slow link that exceeds any reasonable deadline, so `hermes update`'s
Hindsight migration failed with "Git clone timed out" every time.
Subdirectory installs now do a blobless clone (--filter=blob:none
--no-checkout) and a sparse checkout of just that folder, written as the
classic info/sparse-checkout file so older Git clients work too. Servers
without partial-clone support ignore the filter and fall back to the old
download.
Because a partial clone downloads file contents at checkout time, the
checkout step (pinned and unpinned) now gets the configured network
deadline and the credential fallback, same as clone and fetch. Every
timeout error names plugins.clone_timeout_seconds so users can find the
knob.
Existing test fakes of _clone_plugin_repo accept the new subdir argument.
Auxiliary LLM calls (titling, compression, MoA advisors/aggregator, vision,
approval, ...) never reached any plugin hook: hook-based observability and
cost plugins were structurally blind to them. Teknium's ruling on #79733:
NEW events rather than reusing the turn-scoped pre/post_api_request pair,
so existing subscribers keep their per-turn semantics.
- agent/auxiliary_hooks.py (new sibling): builds the pre_api_request /
post_api_request payload shape plus `aux_task`, `api_request_id`
(`aux-...`, shared by every attempt of one logical call), `retry_count`,
`streaming`, parent-turn `session_id`/`task_id`/`turn_id` when a main
turn is in flight; fail-open (a raising/hung subscriber is logged and
the aux task proceeds); post carries `error`/`error_type` on failure.
- agent/auxiliary_client.py: the three relay funnels every physical
attempt shares (_relay_sync_completion / _relay_async_completion /
_relay_sync_stream) run under the hook pair — retries and fallbacks
included. Main-loop *_api_request events do not fire for aux calls.
- Catalogue: VALID_HOOKS, bounded-timeout hook set, `hermes hooks test`
sample payloads, hooks.md / plugins index / observer-hooks / plugins.md
tables, agent + plugins AGENTS.md.
- tests/agent/test_auxiliary_hooks.py: 2 invariants (pair fires with
aux_task and no api_request events; raising subscriber never breaks
the call). First is red on origin/main.
Supersedes #32416 (@zrmnelson), #68060 (@JonZal), #77518 (@hsy5571615),
#79826 (@webtecnica) — their relay-boundary placement, usage
normalisation and fail-open policy shaped this implementation.
Co-authored-by: zrmnelson <zacharynelson1@gmail.com>
Co-authored-by: Jonas Zalys <jonas@tryholo.ai>
Co-authored-by: saitsuki <nukuom976228@gmail.com>
Co-authored-by: webtecnica <webtecnica@gmail.com>
A plugin whose import or register() never returns (an infinite loop, a blocking
network call) held PluginManager.discover_and_load() forever, and with it every
synchronous caller: `hermes chat`, gateway startup, ACP session/new (#108139).
Each plugin's import + register() now runs under `plugins.load_timeout_seconds`
(default 10, 0 disables, max 600) on a daemon worker. On overrun the plugin is
recorded as failed with "load timed out after Ns" (same channel as every other
load failure: startup WARNING, `/plugins`, `list_plugins()`), its pre-hang
registrations are disposed, and discovery continues with the next plugin. The
abandoned worker's later `ctx.register_*`/`subscribe`/`on_unload` calls are
refused with a WARNING (the context is marked abandoned), so a late registration
can never land in a registry the failure path already swept. Abandoned loaders
are capped per process (8); past the cap further loads are refused with a named
reason rather than run inline, which would recreate the hang (#98382 shape).
Because the worker cannot own the caller's RLocks: the deferred-platform eager
fallback now runs outside the replacement transaction, discovery re-entered from
a loader worker returns on the already-set discovered flag instead of blocking on
the sweep's lock, and such a worker never joins the background discovery thread
that is waiting on it.
Annotated-tag pins (F8): a catalog `sha` recorded as `git rev-parse <tag>` names
the TAG object, while HEAD can only ever be the commit it points at. The scan
trust check compared HEAD against the unpeeled sha (so every tag-pinned entry
lost the reviewed-pin bypass and prompted on caution findings) and the sidecar
recorded the peeled commit, so `update_available` was true forever and every
`update` re-installed. The installer now peels the pin (`<sha>^{commit}`) for
trust, records `pin` on the catalog block only when the checkout satisfies it
(empty for an off-pin `--ref` install), and every at-pin check goes through
`at_catalog_pin(sidecar, entry_sha)` (repin, dashboard payload, TUI rows).
Re-pin consent (F10): `hermes plugins update` on a catalog install replaced the
tree without asking, even when the new pin declared new tools, hooks, Python
dependencies, host capabilities or a Desktop half. `repin_catalog_plugin` now
diffs the installed manifest against the staged clone BEFORE anything moves
(`_install_plugin_core(before_swap=...)`) and, on a widening:
- CLI: prints the delta and asks y/N (non-interactive → not applied, fail
closed); after a changed re-pin it runs the same `_run_capability_consent`
grant path as the git-pull `update`.
- `plugins.manage update` RPC and the dashboard REST route answer
`{ok: false, consent_required: true, delta, delta_lines}` with nothing
changed; a retry with `accept_capabilities: true` applies it. Desktop shows
the delta in its confirm dialog; the web dashboard uses `window.confirm`.
- Gateway contract regenerated (`accept_capabilities` param; `consent_required`,
`delta`, `delta_lines`, `error` result fields).
Catalog audit findings F8 and F10 (low severity, no issue filed).
`~/.hermes/hooks/` auto-loads every valid `HOOK.yaml` + `handler.py` at
gateway startup with no `plugins.enabled` gate. That is the documented
contract since 3988c3c245 ("Implicit (dir trust)" in the comparison
table), but the plugins page's "disabled by default" promise read as if
it covered gateway hooks too (#37963). Maintainer ruling: keep implicit
dir trust, fix the docs.
- hooks.md: new "Trust model" section stating exactly what loads, when,
how, and that placing the files is the opt-in; comparison-table cell
links to it and the plugin-hooks consent cell now says
`plugins.enabled`.
- plugins.md: note scoping `plugins.enabled` away from gateway hooks.
- developer-guide/plugins: one sentence at the gateway-hook recipe.
- security.md: "Trusted-by-placement extension points" section
cross-linked from hooks.md.
updating.md never said what requires-python >=3.11,<3.15 with every dep
gated on >=3.14 means for users: only 3.14 runs Hermes, the wider range
only lets pre-PM venvs run one more hermes update, and pip/brew installs
on older interpreters get a dependency-less package.
security.md described truststore but not that the startup CA guard,
SSLConfigurationError and HERMES_SKIP_SSL_GUARD are gone, or that the
CA env vars are still read by plain requests/urllib calls.
pm.plugins_state.read_home_selection raises on any unparsable profile
config.yaml and that blocks dependency prep for every home; plugins.md
and profiles.md now say so.
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
Every catalog card gets an 'Open in Hermes Desktop' link:
hermes://plugin/install?catalog=<name>. The app resolves the reviewed pin
itself, so the page hands it a catalog name and never a repo URL; the CLI
install command stays in the expanded card for people without the app.
In the in-app picker embed the existing '+ Add to this Agent' button is
unchanged.
A `catalog=<name>` deep link now resolves the name against the live plugin
catalog feed (the same `/docs/api/plugins.json` the Capabilities → Plugins
picker renders) and opens the Install Plugin dialog in its reviewed/pinned
catalog mode — identical to an in-app catalog pick, via one shared
`openCatalogPluginInstall` helper the Plugins tab now uses too.
The `catalog` param claims the link outright: an unknown, invalid, or
unresolvable name is a clear error toast and nothing else. It is never
reinterpreted as a git identifier, so a link cannot smuggle an unreviewed
repo behind a familiar-looking name (a `repo=` riding along is ignored).
`hermes doctor` reported "GitHub token configured (authenticated API access)" for any
value in .env, including an expired classic PAT — the very token that shadowed a working
`gh` login for every git-auth clone (#115257). The resolver half (fall through to the gh
CLI when GitHub refuses the .env token) landed separately; this adds the diagnostic half:
a "GitHub token" row under API Connectivity that sends the configured token to
api.github.com/user and, on 401, names the variable and the .env file so the user can
remove or replace it. No token configured: the row is skipped (the Skills Hub section
already reports gh-CLI / no-token state).
Resolved toward the branch: PM provisions uv/python (main's install.ps1 uv-shim
salvage + its test and workflow steps dropped), the shim re-exec stays retired,
package.json carries no electron-builder block (afterExtract identity stamp wired
into electron-builder.config.cjs instead; after-pack.mjs keeps signing only),
Desktop workspace-deps helpers stay retired. Main's scratch-dir bootstrap
(export_scratch_tmp_env) is taken and re-run after profile resolution.
Conflicts resolved toward the branch: PM owns dependency preparation, the
Windows shim re-exec/hand-off path stays retired (main's shim-parent wait,
gateway-resume env token and update_cmd_deps tests dropped), docs describe
the PM update flow. The docker workflow parks install-stamp.json around the
toolchain step instead of deleting it so tests/docker can compare provenance.
A plugin repository is a codebase, and the threat regexes were written for a SKILL.md
the agent executes verbatim. The same text in a README uninstall step, a refusal list,
a test fixture, a JSON scenery data URI, a redaction regex or a `gh api | base64 -d |
grep` dev script was scored as the plugin's own runtime behaviour: crypto-prices
(#115353) was hard-blocked by `rm -rf "$HOME/.hermes/plugins/crypto-prices"` in its
README, and a dozen catalog pins sat at `caution` on fixtures and prose alone.
`tools/plugin_guard_context.py` recognises each class of inert context and only ever
lowers a finding; nothing is deleted and every finding stays in the report:
- documentation prose (`.md/.txt/.rst/.html`, not `SKILL.md`, `after-install.md` or a
bundled `skills/` tree): command/path shapes step down once, so a doc line can never
be `dangerous`; the plugin's own-install-dir `rm` is a note. Injection, Markdown
exfil, agent-config edits, `curl | sh`, `authorized_keys` and leaked keys keep full
severity.
- test trees / fixtures (root test dirs, `__tests__`/`__fixtures__` at any depth,
`*.test.*`, `test_*.py`): quoted-only hostile strings and key-shaped corpora are
notes; test code that executes on import steps down once (caution).
- whole-line comments and CHANGELOG.md score as prose.
- `encoded_exfil` on base64 whose decoded head is a media magic (PNG/JPEG/WOFF/PDF...)
is informational.
- `sudo` / `env|` as an alternation member inside a regex or string literal is a note;
the same word in a command string is not.
- `base64 -d` piped into a text filter is a note; into a shell/interpreter it is not.
Bumps PLUGIN_SCANNER_VERSION to v5 so cached verdicts re-evaluate.
Closes-blocker-for: #115353
Mechanical `check_doc_links.py --fix` pass over website/docs (hand-authored
and generated pages) and the zh-Hans mirror: 1,868 route-style links
(`](/section/page#anchor)`, `](/docs/...)`) become `](../section/page.md#anchor)`.
Every target was asserted to exist on disk; anchors and query strings are
preserved; fenced code blocks and inline-code examples are untouched.
Two dead targets found by the converter were fixed by hand first:
memory-providers.md linked `/user-guide/plugins` (page is
`user-guide/features/plugins`), and the zh-Hans learning-path still linked the
removed `rl-training` page — ported the EN treatment (external Atropos link).
Docusaurus build after: EN locale 0 unresolved Markdown links, 0 broken links,
0 broken anchors.
Widen the anonymous-first decision from #114545 to the whole class. The
clone-only wrapper (_clone_with_auth_fallback) folds into one runner,
git_credentials.run_git_with_credential_fallback, used by every site that
attached a stored credential unconditionally:
- plugins_cmd._run_plugin_git -> clone, the pinned --ref fetch in
_checkout_exact_revision, and the pull in _git_pull_plugin_dir
(`hermes plugins update`), which the thread on #114526 showed still
failing with "could not read Username ... terminal prompts disabled"
after the clone succeeded anonymously
- mcp_catalog._do_git_install (catalog MCP git installs)
- profile_distribution._git_clone (profile distributions from a git URL)
Why the runner and not per-caller guards: the decision belongs to the
credential layer once; a rejected credential sent pre-emptively is what
turns a working anonymous clone into a 401 that git can only answer with
the prompt noninteractive_git_env disables. The credential-required
classifier also matches git's "returned error: 401" spelling (the picked
version needed a trailing space that git never prints) and bytes output.
Side effect: `gh auth token` is now shelled out only after a refusal, so
public installs no longer pay that subprocess at all.
Tests: the sibling verbs (fetch, pull) x (public, refused) and the negative
that a non-credential failure never resolves a credential; the SSH no-op
case from the pick is already asserted by the private-remote test. Docs:
website/docs/user-guide/features/plugins.md.
Fixes#114526
Co-authored-by: holny <holny@users.noreply.github.com>
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
Follow-up to the cherry-picked #112146 (@KoNit-K):
- Move the __main__ demotion to the end of _filter_findings behind a
severity == "critical" guard and a MAIN_GUARD_DEMOTIONS table (the
DOC_PROSE_DEMOTIONS idiom), so it is a one-step cap that can never
re-raise a finding an earlier remap already lowered.
- Treat ValueError from ast.parse (NUL bytes, undecodable text) like a
SyntaxError: no cap, the file is runtime code (fail closed).
- Bump PLUGIN_SCANNER_VERSION to plugin-guard-v4: the verdict semantics
for a plugin shape changed, and the version is what install output
and recorded provenance show.
- Trim to two invariant tests: the reporter's repro shape (token inside
the guard -> caution + confirmable/--force, same token above the guard
-> dangerous, --force refused) and narrowness (destructive payload and
a provider-shaped sk- key inside the guard, plus an unparseable file,
all stay critical -> dangerous).
- Docs: one paragraph on the __main__ cap next to the test-tree cap.
Co-authored-by: kokhlo <konstantin.khlopkov93@gmail.com>
Skipping `tests/`, `spec/`, ... in EXCLUDED_DIRS made those trees
invisible to the guard, but `plugins_loader._load_directory_module`
sets `submodule_search_locations=[plugin_dir]`, so a plugin
`__init__.py` doing `from .tests import evil` imports and runs whatever
lives there: a `tests/evil.py` with a destructive root remove scanned
`dangerous` on main and `safe` on this branch. `_walk` also matched the
names at any depth, so `src/spec/handler.py` — plain runtime code — went
unscanned.
Keep scanning everything; instead cap a critical finding located under a
ROOT-level test dir at `high`, so the verdict is `caution` (confirmation
required, `--force` overridable) rather than the un-overridable
`dangerous`. Fixture strings still cannot brick an install, which was
the reported problem, while a critical in any runtime file (`setup.sh`,
`src/spec/...`) still yields `dangerous`. Trade-off stated in the PR
body: hostile code deliberately placed under `tests/` is now
force-installable rather than blocked outright.
Docs no longer claim test code never runs.
User-visible scanner behaviour changed in this PR (test trees skipped, the block
reason names the critical rule ids), so the plugin docs say so in the same PR.
Reapplied onto current main. The branch had drifted ~3348 commits and a trial
merge produced 48 conflict markers, so this is the same change re-landed rather
than a rebase of the old history.
_interrupt_and_clear_session interrupts the running agent without signalling
plugins, so a plugin holding a per-turn external resource — an outbound RPC
waiting on a tool result the loop will never consume — has no way to learn the
turn is gone. Dispatch agent_loop_stopped immediately after
running_agent.interrupt(), gated on a real running agent: the pending-sentinel
/stop path has no in-flight work, so firing there would be noise.
Per review on #27208, the current helper's behaviour is preserved untouched —
multiplex-aware _adapter_for_source() resolution and cached-agent eviction both
still run; the hook is additive and its dispatch failures are swallowed so a
misbehaving plugin cannot break an interrupt.
Tests fail without the change (hook registration and dispatch) and pass with
it. The three failures in tests/hermes_cli/test_plugins.py::TestPluginDiscovery
are pre-existing on this checkout and reproduce with the change stashed.
Only the CLI could install a plugin at an exact commit (`--ref <sha>`); the
Desktop "Install from Git" dialog, the TUI gateway `plugins.manage install`
method and the dashboard `/agent-plugins/install` endpoint all called
`_install_plugin_core` without a ref, so a team that wanted everyone on the
same private-plugin commit had to leave the app for a terminal.
- `dashboard_install_plugin(ref=)` threads the pin to `_install_plugin_core`;
the 40-hex validation and HEAD verification are unchanged. The gateway
method and dashboard body accept `ref`.
- Desktop dialog gains an optional "Pin to commit" field (custom sources only,
client-side 40-hex check disables Install on anything shorter).
- `plugins.manage list` rows carry `pinned_sha`; the Desktop plugins tab shows
a `pinned @ <sha8>` badge and `hermes plugins list` prints `git pinned@<sha8>`
in Source, so a team can eyeball that everyone runs the same commit.