Two install routes refused a catalog memory provider that config already
names (memory.provider), so the install could never succeed and was
retried on every agent start / every click — the hindsight (91%) and
mnemosyne (87%) failed rows in hermes.extension.install.count.
1. Agent init (`recover_at_startup`) installs the configured provider
through the consent gate. Under the classic CLI that gate's input()
runs on the agent-build thread behind prompt_toolkit, so the turn
hangs on "Initializing agent..." forever; with no terminal (Desktop,
gateway, `chat -q`) it is refused — "Install declined: dependency
install skipped (non-interactive)" — and recorded failed on every
process start. Agent init now never asks: a provider that shipped in
core installs with its built-in's consent (security.allow_lazy_installs
still gates it), any other catalog provider is not attempted and the
user is told the one command that installs it.
2. The dashboard/Desktop Install click (`dashboard_install_plugin`) on a
plugin memory.provider already selects hits the publication veto that
is meant for replacing an active plugin, so it is refused every time
while the same plugin unselected installs and enables. The click now
carries the consent the fresh-install enable already acts on; a forced
reinstall keeps the veto.
cmd_install chose the memory.provider path with _is_memory_provider_dir, a
substring search for "MemoryProvider" in __init__.py. A hook plugin whose
docstring says "Not a MemoryProvider" passed, and --enable replaced the
user's memory.provider with a name load_memory_provider returns None for
(exit 0). _defines_memory_provider parses the package's modules (no import)
for what _load_provider_from_dir can load: a register_memory_provider(...)
call or a MemoryProvider subclass.
An explicit --enable on a memory-provider install now lifts a
plugins.disabled entry (the loader refuses a disabled provider, so the
"set as memory.provider" report was false), through the same admission
transaction plugins enable uses. It also selects the provider when PM
already treats the plugin as active: that state is exactly the old bug
(provider listed in plugins.enabled, memory.provider unset), and a
reinstall with --enable could not repair it. PM's already-active
shortcut still skips the dependency re-prompt; it no longer vetoes an
explicit memory selection.
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.
A memory provider is activated by memory.provider alone; plugins/memory never
reads plugins.enabled. cmd_install still asked "Enable now?" and wrote
plugins.enabled, so `plugins install hindsight --enable` printed "enabled"
while `hermes memory status` kept showing Provider: none (#119909).
For a catalog entry with category: memory (or, for a URL install, a tree
that satisfies the provider contract) the prompt becomes "Use '<name>' as the
memory provider now?"; yes / --enable prepares its Python deps through PM (as
`hermes memory setup` does) and writes memory.provider; no / --no-enable
points at `hermes memory setup` instead of the dead-end `plugins enable` hint.
The gateway hot-activation step is skipped for providers (new sessions pick
them up).
Re-ported from the pre-split plugins_cmd.py into plugins_cmd_install.py.
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.
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.
The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.
An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.
A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
Agent-start recovery (recover_at_startup) reported installed / refused / failed /
not-in-catalog only to agent.log, and so did a selected provider that reports unavailable,
so a user with security.allow_lazy_installs: false, offline, or who declined the dependency
prompt silently ran without external memory (#119769).
- Startup outcomes go through _emit_startup_warning: printed in the CLI; other drivers get
a sticky warn AgentNotice (Desktop toasts notices but drops warn-kind status.update), and
the messaging gateway, which wires notice_callback per turn, receives it on turn 1.
- hermes update labels each line with the profile it is about and gives the exact
`hermes -p <profile> plugins install <name>` command for non-active homes.
- Profiles sharing one environment get one set of dependency answers per provider
(shared_dependency_answers): the first install asks the normal Python/Node questions,
later homes reuse them; a decline or failure names the remaining profiles once instead of
failing (or re-prompting) one by one; Ctrl-C ends the migration with a message (#125794).
- The success line no longer names memory.<name> (#124038); docs follow.
Co-authored-by: chelsealong <chelsealong@126.com>
Desktop, the gateway and a scripted `hermes update` have no TTY, so the
dependency consent gate refused every catalog provider that declares Python
deps: both migration hooks failed with 'Reinstall declined: dependency install
skipped (non-interactive)' and the provider stayed missing. The agent-start
hook exists precisely for those surfaces.
The configured memory.provider plus security.allow_lazy_installs (read for the
home being migrated) is the consent that let the bundled provider install its
deps on demand; carry it through dashboard_install_plugin ->
install_catalog_entry(assume_deps_consent=...). A terminal still prompts, and
allow_lazy_installs: false still refuses with the install one-liner.
A non-interactive `hermes plugins install <name> --enable` had no path
forward (#122134): the y/N gate for Python deps refuses without a TTY,
the publication of an active replacement is declined the same way
("Reinstall declined: dependency install skipped (non-interactive)"),
and the suggested `hermes plugins enable` recovery cannot publish a
plugin that was never installed.
Add an explicit `--yes-deps` flag (mutually exclusive with `--no-deps`)
that carries the user's own answer through both consent gates: the
enable-time `_consent_python_deps` prompt and the publication-time
active-replacement veto in `publish_plugin`. The fail-closed default is
unchanged — an unattended install without the flag is still refused
rather than consented to on the user's behalf.
plugins_cmd_install.recorded_install wraps the install attempt in every
plugin install entry point: `hermes plugins install` (catalog name, git
URL or shorthand), the dashboard/TUI/connector install
(dashboard_install_plugin) and plugin packs. Each emits one
hermes.extension.install.count event:
- source: catalog for a catalog entry (name = entry name), local for a
file:// repo, url for any other git source (name None)
- outcome: failed when the clone/scan/publish raises, success otherwise
A reinstall over an already-installed plugin and `plugins update` (which
calls the installer core directly) are not counted.
cmd_install printed each known issue on its own line right after
entry_capability_summary(), which already ends with "Known issues: ...",
so the text appeared twice on the CLI. Keep the summary line only.
dashboard_install_plugin gets the same list through a 6-line
_known_issue_warnings(entry) helper, so neither function's cyclomatic
complexity grows past the main baseline (cmd_install 20, dashboard 11).
Follow-up to #124058 (happy5318); no behaviour change for the dashboard
result dict or the memory_provider_migration path.
Relaxes the fail-closed install gate from #124037 per teknium1 review
2026-09-27 (known_issues informational; guard belongs at mode-selection seam):
- dashboard_install_plugin: no longer refuses known_issues entries — the text
flows into the result warnings plus a machine-readable "known_issues" key so
the UI can show it; the memory-provider migration paths (migrate_all_homes /
recover_at_startup via _install_into) install hindsight again in any mode.
- cmd_install: prints the yellow "Known issue:" lines but drops the TTY
requirement and the y/N confirmation; non-interactive installs proceed.
- entry_capability_summary: includes "Known issues: ..." so install prompts and
the catalog UI surface the text.
- scripts/validate_plugin_catalog.py: register known_issues in KNOWN_KEYS.
- tests: keep parse round-trip + install-summary display; drop the live-catalog
prose pin (test_live_catalog_hindsight_declares_known_issues) and the
zoneinfo import hack.
- contributors: map 5318happy@users.noreply.github.com -> happy5318.
## Thinking Path
`plugin-catalog/hindsight.yaml` already documented in prose that
`local_embedded` mode is unsupported on PM-managed Hermes with the current
pin — it still calls the retired lazy-install path for `hindsight-all` and
loops update takeover on every conversation — yet `hermes plugins install`
and the dashboard install path accepted the entry with no gate at all. Users
only discovered the trap after the death loop started. The catalog knew;
the installers didn't act on it.
## What Changed
- `hermes_cli/plugin_catalog.py`: new `known_issues: List[str]` field on
`PluginCatalogEntry` (parsed from `known_issues` in each `plugin-catalog/*.yaml`,
serialized into `to_dict()`, defaults to empty for backward compatibility).
- `plugin-catalog/hindsight.yaml`: declares the documented local-embedded trap
as machine-readable `known_issues`.
- `hermes_cli/plugins_cmd_install.py`:
- `cmd_install`: for catalog entries with `known_issues`, prints each issue
and a bold-red notice, then requires explicit confirmation. Non-interactive
(non-TTY) runs fail closed; interactive `y/N` runs require `y`.
- `dashboard_install_plugin`: non-interactive path refuses the entry outright
(no GUI bypass, mirroring the existing kill-list posture) and returns the
issue list in the error payload.
## Why This Shape
The trap is machine-readable in the catalog, so the enforcement lives in the
install entry points rather than in hindsight-specific code — any future
catalog entry that documents a known trap gets the same gate for free. The
non-interactive path fails closed because it cannot ask for the confirmation
the interactive path requires.
## Verification
- RED/GREEN double proof via git stash: pre-fix 7 failed / 3 passed, post-fix
10/10 passed (catalog parsing round-trip, hindsight.yaml declares the trap,
non-TTY refuse, TTY yes proceeds, TTY no cancels, custom-source install not
gated, dashboard refuse + unaffected path).
- Adjacent suites: test_plugin_catalog.py, test_plugin_validate.py,
test_plugins_hub_live_catalog.py all green (36 passed). 9 errors in
test_plugins_cmd_catalog.py reproduce identically with the fix stashed
(existing local `pm` / uv-sync environment limitation, unrelated).
- ruff clean on all changed files.
## Contract Change
New optional catalog field `known_issues` (list of strings). Entries without
it behave exactly as before. Install behavior changes only for entries that
declare `known_issues`.
## Risks
- Catalog entries that declare `known_issues` become gated installs. Authors
of future entries should only declare issues they want surfaced — this is
the intended trade (a documented trap must not install silently).
- `cmd_install` non-TTY automation installing hindsight by name now fails.
That is intentional: the trap cannot be confirmed non-interactively.
## Model Used
jd-deepseek-v4-flash-0731 (main session); pytest + ruff in the isolated
worktree.
## Related
#124037 (issue). Related: #122326 (resulting takeover loop), #7718
(`local_embedded` needs `hindsight-all`).
64860c9c20 pasted the same try/except around the security scan in
_install_plugin_core and update_plugin, and plugins_transaction reached
into plugins_cmd_install for the private _preserved_files_note.
_scan_merged_tree now lives next to _scan_plugin_tree/PluginScanBlocked
in plugins_cmd and both sites call it once. Message, scan_result and the
chained cause are unchanged (checked for matched, unmatched, empty and
missing-scan_result cases). _preserved_files_note is typed
(PluginScanBlocked, list[str]) and drops the nested getattr/str() guards
for the module's usual `scan_result.findings if scan_result is not None`
form.
_install_plugin_core ran the security scan and the portable-package check
on the pristine clone, then ran both again after before_swap had merged in
user files. The second pass existed only because file-count/size limits
apply to the merged tree. before_swap needs only the manifest and the
staged tree, and both exist before the first scan. It now runs there, and
one scan/portable check admits the final bytes. Subdir updates scan once
(probe: 2 scans -> 1, and that scan sees the carried files).
A dangerous finding in carried user data (a cached page, a notes file)
blocked the update with a report that read as if the pristine upstream
revision were malicious. Carry callbacks now return the paths they
preserved. When a scan blocks, the message names the findings that sit in
those files, or, when none of the findings can be matched to them, notes
that the tree included preserved user files. Both the no-git reclone path
and update_plugin's catalog/git carry do this.
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.