Admission rule 18 (README and the mirrored developer-guide copy): a plugin that registers a
browser_vault login backend is listed only at tier verified, from the password-manager
vendor's own repository, or at tier official when Nous maintains it, and states that Hermes
enforces the same rule at load. browser-vault-plugin.md gains "Who can register a backend".
post_llm_call now carries a single-use side_question_token when a loaded
plugin holds the new llm.side_question capability. ctx.side_question(token,
prompt) redeems it: ONE text-only request on a cache-parity fork through
the turn loop's own request phases (assemble_api_request, build_api_request,
perform_api_call), never the tool loop. The parent's system prompt, tools[],
reasoning config and max_tokens are replayed verbatim, so the request reads
the warm prompt cache. Tool calls in the reply are dropped, nothing is
persisted, and usage lands on the session as side_question.
Lease rules: one attempt per turn shared by every plugin (consumed even on
failure), 60 s, invalid once the parent starts its next turn, prompt capped
at 4096 chars, timeout capped at 60 s. The lease holds its parent weakly and
runs under the turn's captured contextvars (profile and secret scope).
Refused with None: MoA, agent-as-provider runtimes, requests that carry
provider-executed tools.
has_capability moves with side_question into a PluginContext mixin
(hermes_cli/plugins_capability_ctx.py) so hermes_cli/plugins.py shrinks.
Fixes#143788
A portable plugin's skills registered as agent-plugin-<slug>-<sha8>:<skill>
(agent-plugin-blender-5a80e4ba:blender) because one manifest field named both
its plugin-data directory and its skill namespace. The hash only matters on
disk. Skills now take the plugin's own name (blender:blender), the form every
native plugin skill uses; a clash with an already-registered qualified name is
skipped with a warning naming the owner, as before. The data directory keeps
its hashed name, so no plugin data moves, and skill_view still resolves names
saved in the old form.
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.
Rewrite the browser feature page, configuration, tools/toolsets/env-var/pm
references, docker, windows, bot-screen, termux/installation and the secondary
developer-guide/integration/feature pages (EN + zh-Hans): drop agent-browser,
the built-in browser_* tools, browser.backend and /browser use; document
browser.executable_path / browser.user_data_dir / browser.chromium_args and the
AGENT_BROWSER_* migration; point Camofox users to 'hermes plugins install camofox'.
PluginContext.ask_choice(question, choices) -> str | None lets a sync
slash-command handler ask the user who ran it to pick one option and get
the picked value back. Choices are strings or {value, label, description}.
Each surface binds an asker around the handler call
(hermes_cli/plugin_choices.py), and every asker is that surface's
existing clarify prompt. No surface gets a new card type or wire shape:
- messaging chats (gateway/plugin_command_choice.py): adapter.send_clarify
plus the clarify tool's own delivery (_clarify_send_then_wait, text
fallback, card retire). Telegram/Discord/Slack show buttons, other
platforms a numbered list. The typed reply reaches the clarify
text-intercept while the command still holds the chat.
- local sessions on the gateway (classic CLI, Ink TUI, Desktop): the
`clarify` server request to the session's viewers. Two fixes make the
answer arrive while slash.exec is still waiting for the handler:
* tui_gateway/ws.py: a viewer's answer to an open server request (a
response frame, `clarify.lock`) is settled at once instead of queuing
behind that socket's running slash.exec.
* hermes_cli/gateway_plugin_commands.py: the classic CLI answers the
session's `clarify` requests with its clarify panel while it waits for
slash.exec. No turn pump reads the session's frames between turns.
- in-process classic CLI and the legacy `hermes serve` backend: their
clarify panel / `clarify` server request.
Returns None when nothing can ask (hooks, cron, async handlers on the
event loop), when the user cancels or answers with something that is not a
choice, or when the clarify timeout passes. The handler then prints its
text usage.
delegate_task asks the active delegation provider (delegation.backend, a new
provider category registered with ctx.register_delegation_provider) for each
task's route: model/provider/endpoint/reasoning resolved by the existing
delegation credential resolver, or a Hermes profile the child runs as. A
provider may add enum-checked per-task schema parameters built once with the
tool definitions. Core keeps the whole lifecycle.
- agent/delegation_provider.py + agent/delegation_registry.py: ABC + registry;
the built-in 'default' strategy is today's behaviour and the fallback for an
unregistered/unavailable backend.
- tools/delegate_tool_routes.py: per-task routes, parameter validation, and the
profile scope binding (home override + secret scope + multiplex semantics +
terminal policy) around a profile child's construction and conversation.
- Host isolation: HOST_OBJECT_BASES row, so providers run under
plugins.isolation: host.
- delegation.backend in DEFAULT_CONFIG; registrar table moved to
hermes_cli/plugins_registrars.py to keep plugins.py under its cap.
- Default config: delegate_task schema and every hermes-cli tool definition
are byte-identical to main.
Enabling or updating a plugin whose pyproject.toml declares `dependencies = []` (the official
agent-importers plugin) ran "Resolving / Installing Python dependencies" and built a new
environment generation although nothing gets installed, and every update of the plugin did it
again.
Workspace membership keyed on "has a pyproject", so such a plugin became a uv workspace member,
and members_stamp hashes a member's whole tree into the venv stamp: enabling it, or any change to
its files, made the recorded environment stale. A plugin is now a member only when it installs
packages (`PythonDeclaration.installs_packages`, the predicate dependency consent already uses
since the sibling fix for the empty consent step), so membership, requires-python checks and the
stamp all follow what the plugin actually puts in the environment.
Test fixtures that used a dep-less pyproject as a cheap stand-in for a member now declare a real
dependency (a local wheel through tool.uv.sources, resolvable offline, or a requirement where no
resolution runs); their assertions are unchanged. New: enabling a `dependencies = []` plugin
through the real admission path keeps the selected generation and recorded stamp, a later change
to its files keeps the venv current, and a plugin with a real dependency still joins.
The rule could not tell a bundled library's own classes from patching a renderer global: it kept
failing legitimate bundles (three.js once minified to $-names) and still missed a bare $ receiver.
Prototype writes in a Desktop plugin are now read by the catalog reviewer instead; the policy in the
catalog docs stays, with a note that review checks it by hand. eval/new Function, non-SDK imports,
script injection and app-DOM reach are still linted.
Independent review of #142470:
- Compression (CLI, TUI/Desktop) and /branch continue a chat under a new session id, which reset its
spend total: a chat at its limit got a fresh budget. The continuation hook every rotation site already
calls (transfer_session_yolo) now moves the total too.
- Every cron job in a process shared one tally (their approval session key is ""), so one job's spend
refused the next and stayed spent until restart. The cron run scope binds its own run id.
- tool_costs keys the server does not offer (a typo, or Hermes' mcp_<server>_ prefix) are named in a
warning at registration instead of silently carrying no budget.
- Untrusted over-limit prompts lead with the limit notice (the gateway text fallback keeps ~200 chars);
"1 credit", not "1 credits"; the multiplex rekey moves the prompt slots with the totals.
- Tests: rotation + per-run cron tally + unknown-key warning (red on the previous head), and parallel
over-limit prompts asked one at a time (red with the prompt slot made a no-op).
- Docs: where a total survives and resets, the counted-on-start wording, the same-name override note.
The "prototype patching" admission rule matched any `X.prototype.y =` line. A
bundled library setting fields on classes it declares itself trips it: three.js
alone hits it 29 times (`var Vector2 = class _Vector2 { constructor() {
_Vector2.prototype.isVector2 = true } }`, `KeyframeTrack.prototype.ValueTypeName
= ...`), so no Desktop plugin could ship a three.js viewer.
The write now fails only when the receiver is not a class/function the file
declares, or is a renderer/browser global (Storage, HTMLElement, Array, ...),
which keeps failing even when the file shadows it with a same-named class (a
regex cannot see scope). Every attack shape the rule exists for still fails:
`Storage.prototype.setItem = ...`, an alias of a global, a shadowed global.
A portable Agent Plugins v1 package (plugin.json) had no way into Settings >
Plugins: the schema reader only looked at plugin.yaml, and v1 rejects unknown
top-level fields. It now reads extensions.com.nousresearch.hermes.config_schema
(the vendor namespace that already carries server trust and timeouts) for the
form, the save, the loader's schema warnings and catalog admission.
Two gaps any plugin with settings hit once the form existed:
- The plugin's Desktop half had no way to learn a save happened. Every
plugins.manage settings save now broadcasts plugin.<id>.settings.changed
({written}); a secret-only save still makes the (empty) settings call so the
event fires for a new API key too.
- A secret the form stored never reached the plugin's running MCP server (its
env ${VAR} was rendered at spawn). After a save, Hermes reconnects the plugin's
servers whose resolved launch inputs changed (identity digest, rendered the way
the connect path renders it); a toggle-only save leaves them alone.
A revoked grant kept its dead access and refresh tokens on disk; it now keeps only the marker
and fingerprint. hermes plugins remove drops the plugin's grants, so a later plugin installed
under the same key does not inherit the sign-in, and hermes auth logout <plugin>:<id> still
removes a grant after the plugin is gone. hermes auth add refuses to run from the gateway, cron or
an agent session and says which hosts get the code, the client secret and the token first.
Maintainer requirement on #139985: authorize_url, token_url and hosts are https at manifest
validation and catalog admission, with no loopback http exception. client_id_env and
client_secret_env may not name a variable Hermes itself uses (a provider API key, a platform
token): Hermes POSTs those values to the manifest's token_url, so a manifest naming
OPENROUTER_API_KEY would have sent it there. A host outside the IdP's domains is a reviewer
warning, and the install notice names the sign-in host.
Microsoft and Notion were named without being tried against this route, and
Notion's token endpoint expects HTTP Basic client authentication, which the
broker does not send.
Refs #139077
The authorize URL is the page the user's browser opens to sign in. A catalog
re-pin that points it at another host now shows up in the consent delta, next
to the token host, hosts and scopes. A new path on the same host still only
changes the fingerprint, so the user signs in again.
Refs #139077
A plugin that calls a third-party API with the user's own OAuth client
(Google, Microsoft, Notion) declares it under `oauth:` in plugin.yaml
instead of running the consent flow and keeping the refresh token itself.
- `hermes auth add|status|refresh|logout <plugin>:<id>` runs the PKCE
login of auth_oauth_pkce_plugin and keeps the grant in the active
profile's auth.json (section `plugin_oauth`, keyed by the plugin's key)
- `credentialed_provider_request(<id>, ...)` attaches the bearer for the
entry's declared hosts only (https, port 443) and refreshes the grant
under the auth-store lock; the calling plugin's own entry is resolved
before `_PROVIDERS`, so a later core provider of the same name cannot
take it over
- each grant stores a fingerprint of the entry it was made for: a plugin
version that changes the token URL, hosts, scopes or client variables
gets ProviderNotSignedIn until the user signs in again
- the client secret is read with get_secret right before each token POST
(OAuthPKCEConfig.token_request) and never sits in the frozen config
- grant-dead codes retire the grant; invalid_client, unauthorized_client
and invalid_scope surface as AuthError codes; a sign-in granted fewer
scopes than declared saves nothing
- `hermes plugins validate` checks the block and the declared variables
and warns when plugin code names the token URL; install/show notices
and the catalog re-pin consent delta list the entries
Refs #139077
Follow-up to #141379 from an independent post-merge review:
- the MCP SDK re-adds its inherited set (PATH, HOME, TERM, ...) at spawn,
so popping an unset override never unset those keys. Treat the entry as
absent instead: the key keeps its baseline value, one rule for every key,
matching what the child actually receives.
- carry resolution provenance from interpolation (_RenderedStr) instead of
guessing from the rendered text, so a set variable whose value is
literally '${OTHER}' is passed through, not dropped.
A stdio MCP server env entry like "GHIDRA_INSTALL_DIR": "${GHIDRA_INSTALL_DIR}"
was passed to the child as the literal text "${GHIDRA_INSTALL_DIR}" when the
variable was unset. Servers that validate the value refuse to start (REA:
"GHIDRA_INSTALL_DIR must be absolute"), so a portable plugin could not forward
an optional variable, and Hermes' filtered MCP env means forwarding is the only
way a user's variable reaches the server.
_build_safe_env now drops an env entry whose whole value is still one
unresolved ${VAR}/${env:VAR} reference. Partial references, context vars and
url/headers keep their existing behaviour. Applies to config.yaml mcp_servers
and plugin mcp.json alike (same launch path).
Co-authored-by: Rodrigo Gomes <2362425+rodrigogs@users.noreply.github.com>
The salvaged hook fired from ContextCompressor.compress(), which is not where
a compaction commits: compress_context can still refuse or roll back the
candidate (snapshot stale, commit fence, split failure), the gateway's
/compress runs its own compressor and commits elsewhere, and Codex-native,
micro-compaction and proactive-prune rewrites never call compress() at all.
session_id was also the compressor's bound id, which is stale after rotation.
Fire it from the compaction-record seam (agent/compaction_events.py) that
already sees exactly one committed record per rewrite on every agent path,
and from the gateway session.mutate compress commit. Payload: session_id (the
session holding the compacted transcript), old_session_id, kind, reason,
before/after token estimates, messages_removed. Bounded by
plugins.hook_callback_timeout, since it runs inside the commit window.
Tests trimmed to one invariant (fires once per committed compaction, never
for an aborted one, raising plugin isolated); docs in hooks.md and the
plugin guide.
Finn's state-layer hook missed the paths Desktop actually uses: single
delete and archive go through the canonical mutation receipt
(mutate_runtime_session), not SessionDB.delete_session, so a plugin never
heard about either. Archive (the "this is done" signal #132639 asked for)
fired nowhere.
- mutate_runtime_session fires after its write commits for operation
'delete' and for a real unarchived -> archived sidebar flip; an exact
retry replays the receipt and stays silent.
- set_session_archived fires reason="archive" on a real flip, so TUI
session.archive, `hermes sessions archive` and deferred TUI archives are
covered from one place. Repeats, un-archives, the idle auto-archive
sweep and profile adoption (notify=False) stay silent.
- Payload is now session_id + reason + profile on every path (profile
from the store's home), deleted_ids when more than one row went;
primary id is the one the caller asked for, not the alphabetically
first.
- One fail-open seam (emit_session_deleted) calls lifecycle.invoke_hook
directly; the extra notify_session_deleted wrapper is gone.
- Tests trimmed to two invariants against the real hook registry.
Co-authored-by: Lucas Piera <pieralukasz@gmail.com>
Widen the contributor's opt-in `source=` to the calling-session context plugins
asked for (#140199, #91526, #123245): a `ctx.register_command` handler that
declares any of `session_id`, `session_key`, `platform`, `source` (or
`**kwargs`) receives them; one-argument handlers keep `handler(raw_args)`.
- One call boundary (hermes_cli/plugin_command_dispatch.py) used by the gateway
(messaging platforms and local CLI/Desktop sessions via
`_hm_run_plugin_handler`), the TUI/Desktop RPC (`_run_plugin_command`) and
the classic CLI's off-gateway fallback. The first positional parameter
always gets raw_args whatever its name.
- Messaging plugin commands look up the chat's current session entry (never
create one), so HERMES_SESSION_ID / `session_id` are the id a later /new
reports as old_session_id instead of "" (#123245).
- Fold the contributor's separate gateway adapter module back into
`_hm_run_plugin_handler` so the local-session path shares it.
- Tests trimmed to two invariants, both red on origin/main.
Plugins that show the agent's skills (catalog views, "open this skill", drift checks against the
installed copy) need the same walk, order, search roots and lookup gate the skills_list / skill_view
tools use, plus the package hash the bundled-skill sync stores. All seven were private.
Rename, with every internal caller moved and no underscore aliases (same shape as the platform
helpers in e6cf2a9):
- tools/skills_tool.py: find_all_skills, sort_skills, skill_search_dirs, locate_skill,
skill_lookup_path_error
- tools/skills_sync.py: dir_hash
- tools/skill_usage.py: read_skill_name
The names join _PUBLIC in the private-import lint and are documented under "Read the skill catalog"
in the plugin developer guide.
Builds on #129698 by @nekwo, which proposed the same names with legacy aliases kept.
Closes#129696
Co-authored-by: nekwo <beastant1@gmail.com>
The rule-9 pointer to the platform helpers section broke the docs/README rules mirror test;
the section stays reachable from the platform adapter guide.
hermes plugins validate warned on every platform plugin's use of
OwnAccessPolicyMixin, tools.approval.resolve_gateway_approval, the clarify
button API, the SSRF client pieces and the setup-wizard helpers. Those names
join _PUBLIC and get a "Public helpers for platform plugins" section (plus
the two new PlatformEntry authz fields) in adding-platform-adapters.md.
A plugin `/command` typed in the classic CLI now runs in the gateway that
owns the session, so `os.getcwd()` there is the gateway's directory and
`print()` goes to the gateway's stdout. The plugin docs did not say so.
The user guide (features/plugins.md) and the build-a-plugin guide now say
to return text instead of printing, to take the session's directory from
`agent.runtime_cwd.resolve_agent_cwd()`, and to read session identity with
`gateway.session_context.get_session_env()`. The real-daemon test's plugin
gains a `/probe-cwd` command that checks both helpers return the session's
values while `os.getcwd()` is the gateway's.
Refs #134889 (CLI-13)
Refs #134889 (PLG-7)
Refs #139523
The portable loader raised on any key in extensions.com.nousresearch.hermes
.servers.<name> it did not know, so a plugin using a key added in a later
release could not be installed at all on an older one: REA's per-server
`timeout` broke every v0.22.2 install. Ignore unknown keys with a diagnostic
(logged at load, listed by `hermes plugins validate`), the same treatment
unknown top-level manifest fields already get. Known keys, `trust` included,
are still validated and applied.
#137110 pinned every core-locked package to its exact locked version in
the generated plugin workspace (`_trust_core_lock`). That overrode PM's
deliberate rule that a plugin may move a transitive core package within
core's declared ranges (only core's exact pins are fixed), and #137110
rewrote PM's test for that rule to assert the opposite. Five catalog
plugins stopped installing: cognee, loreconvo, mnemosyne, omnivoice and
radio-dm-gateway.
Remove `_trust_core_lock` and restore
tests/pm/test_workspace_build_inputs.py byte-for-byte from before #137110.
What #137110/#137159 got right stays: generated workspaces carry no
release-age cutoff, so users' machines never check upload dates and
mirrors without them keep working; the 3-day check still runs at
`hermes pm lock` and in CI.
tabro bundles a prebuilt Windows .exe (relay-native-host-0.4.2.exe) in its
plugin tree. The install scanner cannot read machine code, and the SHA-256 the
plugin checks it against is committed in the same commit, so it only proves the
file was not changed after that commit, not where it came from. A reviewer
cannot tell it matches the in-repo C++ source. That is a precedent worth
closing before someone ships a binary that is not benign.
- Delists tabro (deletes plugin-catalog/tabro.yaml). Existing installs keep
working; not added to removed.yaml — there is no evidence of malice, only no
way to verify the binary. An attested build (rule 17) is the way back.
- Adds admission rule 17: no compiled binaries, committed Python bytecode, or
archives hiding either, detected by file header not extension. A native
component is allowed only with a GitHub build-provenance attestation tying it
to the plugin repo's own workflow at the pinned commit; a setup-time download
is allowed only from the tool's official source, pinned by checksum and
disclosed. Mirrored into the docs submission guide (the mirror test enforces
identical text).
A full-content scan of all 543 pinned entries found tabro is the only one with
a native executable. The enforcing gate ships in a follow-up PR.
Plugins read declared secrets with agent.secret_scope.get_secret and use a
subscription sign-in only through credentialed_provider_request
(requires_auth), so Hermes attaches the token. They never read .env,
Hermes's auth.json, another tool's login or any credential store directly,
and never refresh, rotate or write OAuth tokens. Missing route = ask Hermes
for one. The plugin developer guide's own examples switch from os.environ to
get_secret to match.
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 loads a unified package's desktop half from the copy in
desktop-plugins/<name>/, not from the package. Editing the source shipped
nothing and nothing said so:
- a package symlinked into plugins/ (the in-place dev layout the Python
loader already follows) was never copied at all — Dirent.isDirectory()
is false for a symlink;
- an installed package edited in place stayed stale until Rescan/restart.
Electron now follows symlinked package dirs, marks their copy linked, and
re-syncs it on any byte difference. The renderer watches a linked copy's
source plugin.js and, on save, runs the reconcile and hot-reloads. Catalog
and git installs keep the mtime rule. `hermes plugins doctor` warns when
the copy differs from the source and says how to refresh it.
With no saved platform_toolsets, _toggle_plugin_toolset seeded
platform_toolsets.cli: [<plugin_ts>]. A list naming only plugin keys
replaces the hermes-cli composite, so the model lost every built-in tool
(26 -> 1, no terminal). The seed is dropped: an unknown plugin toolset is
already on by default when no list is saved.
plugins remove now clears the plugin's toolset from saved lists even when
plugin.yaml has no provides_tools (the toggle already no-ops without a
toolset); before, hermes config check warned about an unknown toolset.
Docs: say what provides_tools actually drives.
Agent Plugins v1 has no trust field, and Hermes's portable mcp.json reader
accepts only type/url/headers for a remote server, so a package server always
ran at the default trust: full. A package whose tools trade, spend or send
(senpi resolving trade approvals on a user's Hyperliquid account) could only
ask the model to check with the user; Hermes itself never prompted.
plugin.json's Hermes extension now accepts
extensions.com.nousresearch.hermes.servers.<name>.trust: untrusted
which is applied to that mcp.json server exactly like a config.yaml
'trust: untrusted': every write-capable call asks first and fails closed
when unattended. 'full' (the default) is accepted and changes nothing, any
other value refuses the package, so a package can narrow access but never
widen it, and a config.yaml server with the same name still replaces the
package's entry. 'hermes plugins validate' lists the gated servers.
* feat(platform): app declarations list every place an app may be installed
An app block's location may now be one path or an ordered list. A list
item is a path, where a `*` segment stands for a versioned folder and the
highest version is tried first, or a mapping naming a location kind:
command (PATH), uninstall_registry (InstallLocation plus a file, Windows),
app_bundle (/Applications, ~/Applications), flatpak (app id) and snap.
The directories behind each kind live in known_dirs.
Each kind's OS, key, presence and locator are one LOCATION_KINDS entry in
resolver/app.py. The declaration parser validates against that table, so
a new kind is one entry instead of three coordinated edits in two modules.
With requires.min_version, availability reads the version of every
present copy and returns the first that qualifies, so an old copy found
first does not hide a newer one; if none qualifies it reports the highest
readable version. A Linux block may omit a version source (Linux has
none) and is then gated on presence only; Windows and macOS blocks still
need one, and a Linux-only declaration cannot set a minimum.
Brought over from #129246. The Blender 0.2.0 catalog pin stays out: older
clients reject a list location, so it follows a release that ships this.
* fix(platform): bad app declarations and vanished registry entries no longer break app lookup (review)
A location with a non-string kind raises DeclarationError instead of TypeError.
A location's file and key value are checked after stripping, so a leading / or \ or a drive cannot escape the install folder.
An uninstall registry entry that cannot be opened or read is skipped instead of ending the scan of its root.
Plugins can now call ctx.register_automation_blueprint(key, *, title,
description, schedule_template, prompt_template, category, slots,
deliver_default, skills, tags). The fields mirror the built-in
AutomationBlueprint, and slots are BlueprintSlot field dicts, so the call
also crosses the plugin-host boundary.
- One lookup, cron.blueprint_catalog.list_blueprints() / get_blueprint(),
merges the built-ins with the active profile's plugin blueprints at call
time. /blueprint (CLI/TUI/messengers), /api/cron/blueprints and the
instantiate route all use it. Each dashboard route resolves inside the
?profile= scope, off the event loop.
- Keys are namespaced <plugin>:<key>, so a plugin cannot shadow a built-in.
A ':' in the key, a duplicate key, an unknown placeholder, a bad slot type
or a schedule that does not parse is warned and ignored at registration.
- Catalog entries carry source/plugin. Desktop fetches the catalog for the
profile it creates jobs in, keyed per profile, and shows the plugin name
on the blueprint row and in the "Start from" select. The dashboard shows
a plugin badge.
- A created job is an ordinary cron job with the prompt and schedule already
filled in, so it keeps running if the plugin is later disabled.
- The ctx content registrars (register_skill, now joined by
register_automation_blueprint) move to hermes_cli/plugins_content.py.
The dashboard's blueprint types move to web/src/lib/automation-blueprints.ts.
Closes#132373. Requested by @enckequity.