Inside a packaged desktop build, bare `hermes` in the terminal tool resolved
to <payload>/venv/Scripts/hermes.exe. That venv was built on CI, and its
Windows redirector reads pyvenv.cfg `home`, which still names the CI path,
so every call failed with "did not find executable at C:\a\hermes-agent\...".
The working launcher in <payload>/bin carries the channel name and was never
on the agent's PATH.
- activate_dependencies puts the payload's own launcher dir (from the
manifest's runtime.commands) first on PATH, and on Windows no longer adds
the shipped venv's Scripts dir: every redirector in it names the CI
interpreter.
- _resolve_hermes_bin_dir answers with that launcher dir in a sealed payload,
so terminal and cron children do not depend on PATH order.
- stageDesktopLaunchers copies the canonical launchers to their qualified
names instead of renaming them. Only the qualified names are published.
- The "plugin already exists" hint names the real CLI command.
- Every manifest-relative path (venv, store, hermes launcher) goes through one
resolve-and-contain check, and a manifest.json that is unreadable or not a
payload manifest reads as "not a payload" instead of raising, since every
install now asks.
Source and dev installs are unchanged: they have no payload manifest.
An agent stores its session platform's gated toolsets in disabled_toolsets at load, so the tool_search listing, the bridge's search, MCP refreshes and delegate children drop manage_catalog too.
InstallPhase moves to the leaf hermes_cli/plugin_install_phase.py; the gateway contracts no longer import the installer.
The per-home enable lock moves from the plugins_cmd facade to plugins_cmd_install._enable_placed.
Reusing an installed catalog tree reads the install record through catalog_install_record, so older top-level catalog_name records match.
A failed row no longer redacts the Advanced choices (profile name, commit); only credentials are redacted.
The profiles.py role comment no longer says a role grants the setup toolset.
Catalog plugin installs pass the commit approved under Advanced to the clone; a disabled tree on disk is reused only when it is at that commit.
A settled Try again on a plugin row draws the tools, setup notes and enabled state from that retry's install result.
A manage_catalog search row that reuses an install's call id no longer draws the install card.
Connection cards kept for a session are cleared when its runtime is dropped and on a gateway re-home; turnHasReply moves to lib/chat-messages to keep session-states.ts under its size cap.
Toolsets that exist only for one kind of session now say so on their spec:
`platforms=frozenset({"desktop"})`. `TOOLSET_SESSION_PLATFORMS` is derived
from the specs that carry the key, `all` / `*` / no selection skip those
toolsets, and `toolsets.agent_tool_drops(agent)` removes their tools from any
agent whose own session platform is not listed. `agent_init._load_tools` and
`tools/mcp_tool_agent.py` (MCP refresh and prefix restore) call that one
helper. `tool_search._DIRECT_SURFACE_TOOLSETS` is derived from
`CLIENT_SURFACE_TOOLSETS` and `TOOLSET_SESSION_PLATFORMS`.
This replaces the profile-role gate: `profile_role_toolsets`, the role code in
`_with_session_toolsets` and the explicit-pin branch, the strip step in
`_select_tool_names`, and main's `setup` toolset (whose only tool moves out)
are deleted. `manage_catalog` moves into a new `catalog` toolset, which the
desktop client folds into every desktop chat like `project` and `desktop_ui`.
It is deferred behind tool_search by default (`config_defaults` defer list).
The shared-metrics schemas' toolset enum follows (`setup` out, `catalog` in).
The install card works outside the setup chat (#129650, with review fixes):
- Parallel installs: `_set_plugin_enabled` holds a per-home lock around the
read-then-admit section, so rows enabled at once no longer refuse each other.
- A catalog plugin already on disk and not enabled is enabled in place, so Try
again after a refused enable can finish.
- A failed catalog row counts as resolved (`contract.resolves`), so it no longer
holds the turn to the 300 s deadline. The snapshot carries `resolved`, and the
card reads it instead of a hand copy of the backend's rule.
- Each card draws its own operation by op id, because models reuse call ids.
- The row carries the approved non-secret choices (`approved`: force, enable,
ref) and the chosen profile, so Try again after the card settled repeats them.
- Install phases are ids (`InstallPhase`, wire `CatalogInstallPhase`) and the
installed-row notes are fields (`enabled`, `missing_env`, `server_errors`,
`already_installed`, `requires_hermes`); the card words them in English
strings (translations follow later). Failure reasons stay raw text, and
`detail` keeps the English notes for the model.
- Installs and search target the calling chat's own home, carried as a path;
the row's profile name is only a label, with no `'default'` fallback in
Python or TypeScript.
- `dashboard_install_plugin` is split into `_resolve_source`, `_place_tree` and
`_enable_placed`.
Desktop English strings for the card move to `en_catalog_install.ts` /
`types_catalog_install.ts`, which offsets the growth of `en.ts` and `types.ts`.
- `_publish_failure_class`, `probe_failure_class` and `exception_failure_class` read a raise
site's `failure_class` with `inspect.getattr_static` (`tagged_failure_class`), and the MCP
type walk falls back to `connect_failed`. A third-party exception whose `__getattr__` or
property raised replaced the user's PluginOperationError with RuntimeError (they run at the
raise site, collection on or off), and the metric row was dropped; it now reads `exception`.
- `counter_dimensions_are_valid` adds the cross-field rule the per-field enums cannot state:
failure_class in the kind's own set, `none` exactly on success, registry only on skill rows.
Pre-split rows (no failure_class/registry) still validate.
- A refused dependency consent returns its reason as `_ConsentRefusal`, a str carrying the
closed class (`non_interactive`, `deps_declined`, `manifest_invalid`), so publication no
longer classifies by matching the user-facing copy. The text the user reads is unchanged.
- `extension_registry` imports the hub's `_SOURCE_ID_ALIASES` instead of a hand copy.
- test file: schema read uses utf-8-sig (windows-footguns).
hermes.extension.install.count gains failure_class (closed per-kind set named at each failure exit
of the skills hub, plugin installer and MCP catalog emitters; raised exceptions classify by their
tagged class or TYPE, exception:<Type> collapses to exception, never the message) and registry
(the skills-hub adapter SOURCE_ID that resolved/served a skill; none/unresolved/other otherwise).
hermes.update.run gains failure_class derived from the FINAL receipt only (admission step,
stop_reason exception type, exit_code, stage marks, fleet states, gateway_restart). failed_stage no
longer reads 'other' for runs whose every mark passed: a 'partial' receipt (written only by the
post-restart verification) synthesizes a failed verify stage, and a failed run ending on a skipped
restart reads restart.
Package schema v4 is extended in place (canary-only): the pre-split field sets stay valid as the
second oneOf shape and in _LEGACY_METRIC_FIELDS so pending rows drain.
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.