Independent-review findings on the plugin PCM streaming path:
- stream_sample_rate was checked, then re-read and int()-ed in the
adapter: NaN/inf raised out of resolve_streaming_provider (the speaker
then played nothing) and 0.5 became sample_rate=0. Validate once
(finite, >= 1 Hz) and hand that int to _PluginPCMStreamer.
- `nous` is an alias of the openai path in _get_provider but not a
reserved name, so a pinned `tts.streaming.provider: nous` could stream
a plugin registered as `nous` while the sync path speaks OpenAI.
- Document that stream() must be thread-safe: the speaker prefetches up
to 3 sentences, unlike the serialized synthesize() path.
Follow-up to the salvaged plugin-streaming commit. The adapter read
voice/model from the plugin's own tts.<name> section, so a streamed reply
spoke the plugin default while synthesize() got tts.voice: the voice
changed with the surface. It also skipped the sync dispatcher's routing:
a same-named `type: command` provider (which wins for synthesize) lost
to the plugin on the streaming path, and an is_available() == False
plugin was streamed anyway.
- tools/tts_tool_plugins: one routing guard (_plugin_route_key) and one
voice/model/speed builder (_plugin_voice_kwargs), shared by
synthesize() dispatch and the new _plugin_pcm_streaming_provider
(streams_pcm + positive stream_sample_rate + is_available(), read at
resolve time)
- tools/tts_streaming: _PluginPCMStreamer stays a thin adapter;
stream_channels/stream_sample_width dropped (the contract is int16
mono, which every sink assumes)
- agent/tts_provider: streams_pcm / stream_sample_rate declared on the
ABC with the contract
- tests: two invariant tests next to the plugin dispatch tests (the
streaming test module skips without numpy); docs for plugin authors
Before the type-ahead fix, an Enter pressed while `hermes chat` started
could save "No thanks" before the offer was on screen, and every surface
then treated the profile as answered. Every answer now also writes
`telemetry.shared_metrics.offer_version`. An "off" answer without it reads
as `reask`: undecided, so the CLI, Desktop strip and dashboard banner offer
it once more with a one-line reason. Any response settles it, including
Esc in the terminal and the banner close, which keep "No thanks", so
nobody is asked a third time. Opt-in answers were always deliberate picks
and are never re-asked.
Generic plugin-OAuth support that the Solstice provider needs. None of it
names a provider:
- OAuthPKCEConfig.token_request: a confidential client whose secret lives
behind a broker hands the grant to the plugin in place of a token_url
POST. The old refresh_token is kept when the response omits one, which
Google does on refresh.
- Plugin OAuth rows rotate 2 minutes ahead of their stored expires_at_ms
(shared with the anthropic rule). Opaque bearers such as Google's ya29.*
carry no JWT exp, so this stored expiry is the only clock they have.
- A row whose refresh grant just went DEAD stays DEAD on the inference 401
that follows; before this it was re-benched EXHAUSTED and the dead token
was replayed every cooldown.
- The auxiliary client serves an OAuth plugin that ships create_client from
its pooled row on the main runtime's endpoint (compression, titles). It
used to return "not directly supported".
- A 401 on a plugin OAuth row prints a re-sign-in hint, not the API-key
advice.
- The Gemini native adapter exposes its method names, auth headers and
error builder so a Bearer-auth subclass can reuse it without the API-key
remedies.
Review follow-ups for the failure_class split: finish_compression_attempt now
decides skipped/failed from the normalized class it records (a padded or
mixed-case skip name no longer reads failed with a skip reason, and a
non-string class no longer raises); the pre-chat consent offer names the new
reason like the setup copy; the setup copy keeps 80 columns; the doc says a
refused batch's op rows carry the class of the op that stopped it.
Fleet telemetry shows built-in memory writes failing 34-47% and automatic
compression failing ~15%, and several merged fixes each target one cause
(#133038 for memory, #133054/#133040/#133051/#133032 for compression). Both
metrics only carried outcome, so the fleet could not tell which cause a fix
moved.
- hermes.memory.op.count and hermes.compression.count gain one closed-enum
dimension, failure_class; `none` on success.
- Memory: every refusal/failure return in tools/memory_tool.py and
tools/memory_tool_store.py names its class (no_match, ambiguous,
over_budget, would_empty, drift, read_failed, retry_cap, scan_blocked,
missing_content, missing_old_text, invalid_args, stale_entry, disabled,
staged, gate_refused) through a context variable; the result the model
sees is unchanged. Plugin providers report provider_error / exception.
- Compression: the attempt log's own failure_class, collapsed onto the
compressor's closed names (exception:<Type> / rollback:<Type> keep only the
prefix, anything else reads other, a classless failure unknown). Skipped
rows carry the skip reason, and the skip set is now defined once in the
contract.
- Package schema v4 / event schema v4; v3 field sets of both metrics stay
valid so pending rows drain. Doc table rows and the consent copy (CLI
setup and every Desktop locale's collectedActivity) name the new reason.
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.
Salvage follow-up for #129189 on top of #129209:
- An unknown or self-referential `inherit_from` now logs a warning and
registers the task without inheritance instead of raising, so one bad
base no longer fails the whole plugin load (the #129191 approach).
- `provider: auto` on an inheriting task means "no preference", like the
empty strings: "Reset all to auto" (CLI + dashboard) writes exactly that,
and before this it pinned the slot to the main provider while the model
was still inherited from the base (mismatched route).
- Once the user pins a route on the task (non-auto provider, model or
base_url), provider/model/base_url/api_key/api_mode/key_env/effort all
come from the task's own block. Mixing per key let a pinned provider pick
up the base's base_url and api_key.
- The CLI picker resolves the inherited view only for inheriting tasks.
Tests collapse to behavior contracts: base followed and changes
propagate, picker placeholders keep following, a pin wins whole, bad
bases warn and are ignored, re-registration cycles are cut.
Co-authored-by: Yuan Li <dskwelmcy@163.com>
`PluginContext.register_auxiliary_task` already lists a plugin task in the `hermes model`
picker and layers its defaults into `auxiliary.<key>`, but every REST/UI surface behind the
Models page enumerated only the built-in `_AUX_TASK_SLOTS`:
- `GET /api/model/auxiliary` never listed the task
- `POST /api/model/set` (scope=auxiliary) rejected it: `400 unknown auxiliary task`
- `__reset__` and the empty-task broadcast skipped it; `_stale_aux_pins` ignored it
- Desktop Settings → Models and the dashboard ModelsPage hardcoded the built-in list
So a plugin's side model could only be pinned by hand-editing config.yaml per profile.
Fix, at the existing seams:
- `web_server_config._aux_task_slots()` = built-ins + plugin keys from
`get_plugin_auxiliary_tasks()`, fail-soft; every consumer in that module uses it.
- `GET /api/model/auxiliary` reads plugin tasks INSIDE `_profile_scope`, so a request for
profile B enumerates B's plugins (the plugin manager keys on the same context-local home) —
the profile-scope gap flagged on #40922. Built-in rows are byte-identical; plugin rows are
appended with `label`/`hint`/`plugin` from the registration.
- Desktop `model-settings.tsx` and dashboard `ModelsPage.tsx` render served plugin rows after
the built-ins using the server label/hint (pure `auxTaskRows`; no new i18n strings, no-op on
older backends).
Tests: real plugin loaded from a temp HERMES_HOME through PluginManager discovery, two
profiles in one process (listing scoped per profile; foreign task still 400), assignment /
broadcast / reset / stale-pin over a plugin slot, discovery failure leaves built-ins intact —
all red on main. Vitest covers the pure row merge and the rendered plugin row.
Fixes#40880. Salvages #40922.
Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
register_auxiliary_task's defaults start from a fixed shape and every key
is a full replace, so a plugin whose side model is a near-copy of a
built-in has to restate the whole block and silently drifts when that
built-in's shape changes.
inherit_from names a built-in or already-registered task whose effective
config becomes the base, resolved at read time so the plugin tracks the
base's current values instead of snapshotting them. Precedence is base,
then plugin defaults, then user config.
Self-referential inherit_from is rejected at registration rather than
left to the read-time guard: same-owner re-registration is allowed, so
the bad value would otherwise surface as unbounded recursion in a config
read. The guard stays for a hand-edited registry.
Closes#129189
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.
Plugins that drive the agent's browser below the browser tools (Secure ENV,
catalog review on #132272) had no public way to send a raw CDP command over a
task's supervisor connection, so they bumped CDPSupervisor._next_call_id,
inserted futures into _pending_calls and sent on _ws. That breaks silently on
any internal change and collides between plugins.
SUPERVISOR_REGISTRY.capture(task_id) now returns a CapturedCDP pinned to the
supervisor's current socket and default page session:
call(method, params=None, *, session_id=None, timeout=10.0) -> raw reply
is_valid() / page_session_id / task_id / cdp_url
The validity check and the send run in one supervisor-loop step, so a call
never reaches a newer connection: after a reconnect, stop or registry
replacement every call raises CapturedCDPInvalid. Calls in flight when the
socket drops now fail at once (the supervisor fails pending replies on
teardown) instead of sitting out their timeout.
No model tool, config default or new capability: in-process Python could
already reach this. The handle enforces no origin/consent/ownership policy.
Reduced-scope salvage of #132752 (API shape and names from that PR); its
attach-reply handoff, old-wire cleanup and dispatch fences are not needed
because the supervisor closes the old socket on every reconnect, which drops
its sessions with it.
Co-authored-by: web3blind <264741654+web3blind@users.noreply.github.com>
hermes.compression.count recorded every non-committed attempt as outcome=failed
except lock contention. Structural no-ops (insufficient_messages,
no_compressible_window, empty_post_handoff_window: the transcript is all
protected tail, no model call is made), a user stop (explicit_interrupt) and
superseded/stale attempts all landed in `failed`, so the fleet dashboard read a
session that simply had nothing summarizable as a broken compactor.
The outcome now comes from the resolved failure class (the previous commit keeps
the compressor's class instead of the caller's generic `no_progress`), and those
classes count `skipped`. Real failures (summary errors, would_grow, no_progress
after a real summary, stalls, commit errors) stay `failed`.
Review fixes for the once-per-episode connect_failed dedupe:
- The episode was latched before the collection-consent gate, so a failure
seen while shared metrics were off suppressed the first consented day's
connect_failed. _record_connect now returns without latching when
collection is off: only a recorded failure opens an episode.
- Local episode state was keyed by the privacy-projected platform name, so
two custom adapters (both emitted as "plugin") shared one episode and one
adapter's recovery closed the other's outage. The key is now the adapter's
real platform value; projection happens only at emission.
- Docs: the hermes.platform.health row describes the episode semantics.
hermes.provider_setup.count recorded every user cancel as event=failed
failure_class=cancelled, on every surface: Esc/Ctrl-C/Back in the CLI pickers,
Cancel on a Desktop/dashboard OAuth sign-in (DELETE /sessions), a cancel that
landed before the start response, and consent declined on the provider page.
A device code left to run out was also failed/other on the Nous, xAI and
Codex dashboard pollers (only the free-tier `timeout` reason and Codex
`expired` status were abandoned). Every provider's failure rate was inflated by
walk-aways (openai-codex desktop: 64 of 75 failed rows were `cancelled`).
- provider_setup_fields: failed + cancelled/expired is recorded abandoned, so
every emitter (CLI, web routers, pollers) gets the same answer.
- setup_failure_class: a lapsed device code (DeviceCodeExpired on the Nous poll,
AuthError code device_code_timeout/timeout, RFC 8628 expired_token) is
`expired`; access_denied is `cancelled`; an error whose __cause__ is a network
error is `network`.
- Dashboard/Desktop pollers keep the exception's closed class on the session
(note_oauth_failure) so `status=error` no longer collapses to `other`;
free-tier anon_unreachable is network, a start-route 504 is network.
- DELETE /sessions settles the metric immediately: a Nous/xAI poll blocks for
the code's lifetime, so the cancel row used to land minutes later or never.
Live (real dashboard server, Desktop-owned, isolated HOME, real providers):
base: nous/openai-codex cancel -> failed/cancelled (xai cancel never
settled); nous code left to lapse -> failed/other
fix: nous/xai-oauth/openai-codex cancel -> abandoned/none, each at cancel
Live (real `hermes model` in tmux, Ctrl-C at the OpenRouter key prompt):
base failed/cancelled, fix abandoned/none.
Review fixes for Settings ▸ Plugins:
- schema form is bound to one profile: keyed by (profile, plugin), renders
only rows loaded for that profile ($agentPluginsProfile), and its save
writes that profile or refuses; a scope switch discards the draft
- Capabilities gear carries its selected profile (&profile=), which
Settings adopts into its scope before resolving the page
- overview / missing / empty states keep the Applies-to selector
- desktop pages and automatic agent pages use disjoint identities
(kind-tagged uids; ?plugin= vs ?agent= routes)
- the folded schema form is addressed by agent key, so no plugin-chosen
sub-page id (e.g. config) is reserved
config_schema pages rendered raw keys as labels, ran the description into
the secret note without punctuation, floated Save mid-canvas and laid
fields on a two-column grid with an orphan cell.
- one ListRow per field (label + helper left, control right; JSON full width)
- label: manifest label/title, else the key in sentence case with acronyms
kept (maps_api_key -> Maps API key)
- helper sentences joined with punctuation (joinSentences)
- Save on the page heading row (Passwords & Logins' pattern), disabled
until dirty, spinner while saving
- only required fields are marked (settingsForm.optional -> .required)
- overview: Manage plugins as a heading-row button
- rail: a plugin row whose sub-page is open is named, not filled
- hello-runtime example: readable Chip style / gateway status copy
The hello-runtime runtime fixture registers a page with two sub-pages backed
by ctx.storage. The SDK guide gets a 'Plugin settings pages' section, plus
contract and export-table rows.
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
Plugins could only see a cron run as the task_id string `cron:<job>:<execution>`,
with no way to tell a scheduled fire from a manual `hermes cron run`. A
security plugin that may only act inside a real scheduled occurrence had
nothing to check.
The scheduler now binds a frozen CronExecution (job id/name, execution id,
source, scheduled_instant, started_at, profile) in a ContextVar once the run
wins its claimed->running CAS (or a restart-safe worker adopted the row), and
resets it when the fire ends. ContextVar scoping keeps concurrent jobs and
profiles apart and follows the agent onto the threads the scheduler already
copies context into. Delegated children read None.
Also trims duplicated comments in _run_one_job_body to keep the facade within
its code-health line cap.
Closes#130722
Core already owns a chat text size preference ($chatTextScale, Appearance ->
Chat Text Size, presets 90..175). Add it to the host.settings allowlist with
read, write and subscribe, bound to the same store atom and setter the native
control uses, so a theme/plugin like Hermes Quiet can follow or set reading
size without owning layout. Writes accept only the exact presets: the store
would otherwise snap a typo to the default and silently reset the user's size.
Docs: document the key, and list chat width / turn spacing / row geometry as
not-yet keys (no core preference exists; chat width tracks #55287).
Refs #131943#131944#131945
An agent working in an area gets root + that area's AGENTS.md as one project-context
block, so the area files must leave room beside the root. hermes_cli (24.2k -> 18.0k):
update-pipeline stage contract and free-tier long form -> cli-internals.md. gateway
(20.9k -> 17.2k): background notifications and /login long form -> gateway-internals.md.
apps/desktop (16.3k -> 9.4k): five judgment sections summarized with every invariant
kept, the reasoning -> new apps/desktop/ENGINEERING.md, the onboarding rehearsal ->
BUILDING.md.
The root file is project context and was 38.7k chars, past the context-file budget
(20k floor, 30,720 on a 128k window), so every session lost its middle: routing table,
testing rules, code-shape rules. It now keeps the two invariants, a condensed rubric
(every rule kept), environment, structure, one-line rules that apply everywhere and the
routing table (11.8k).
Moved verbatim to where they load: Testing -> new tests/AGENTS.md; dependency pinning and
PM ownership -> new pm/AGENTS.md; machine facts -> new hermes_platform/AGENTS.md;
session-scoped tool availability -> tools/AGENTS.md; the full rubric with its real-close
examples -> developer-guide/contributing.md. Pointers in lint, checker messages, docs
and test comments name the new homes.
Every failed auxiliary model_route row read error_class=unknown because the
aux logical scope only carried outcome/model/provider and the projection
hard-coded unknown for any non-success. Every exception escaping call_llm,
including Hermes' own aborts (Ctrl+C / /stop mid-compression, interrupted
aux streams, task cancellation), was recorded as failed.
- classify the error that ended an aux call (and the last one a successful
call recovered from) with agent.error_classifier; HTTP-200 bodies that
carry a provider error object are classified from that object
- Hermes aborts (non-Exception BaseExceptions, InterruptedError) close the
call as cancelled/none, matching the contract
- MoA aggregator streams report the same way
- aux calls that finish under the turn's own live scopes (title generation
runs beside the turn) were never counted: the scope could not be popped
and the turn-close drain closed it as an orphan without a route. The
drain now closes it with the stored real result.
Fleet telemetry labelled every `hermes -z` / `chat -q` / `-Q` run as
entrypoint=interactive because the cli surface mapped straight to
interactive and no caller declared anything else, so 95% of CLI sessions
read as one-turn humans and scripts could not be told apart.
- New closed entrypoint `one_shot` (contract + v3 schema enum). The task
scope start declares it for cli-surface runs carrying the
HERMES_SINGLE_QUERY_SESSION marker the one-shot paths already set (the
signal session source and cache_ttl: auto read). Children Hermes
dispatchers spawn (kanban worker source=kanban, A2A forward source=a2a)
read the existing `background`. Delegated children, other surfaces and
the REPL are unchanged.
- Engagement keeps treating one_shot like interactive (its prior label).
- `hermes -z` exits via os._exit, skipping the atexit hook that closes the
metrics session: its pre-exit cleanup now runs the runtime shutdown, so
task_run.finished and session.count are actually written (before, only
task_run.started survived and the store kept a hot journal).
- Smoke expectations move to one_shot (it drives `chat --query --quiet`).
- relay-shared-metrics.md documents the entrypoint vocabulary.
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>".
Remove the "Source channels and install identity" section from Updating &
Uninstalling, and stop documenting stable/canary as selectable for source
installs. `main` is the only valid source channel right now, so the
updating, CLI reference, and stable-releases pages say that instead.
Keep newly observed sleep credit until the following poll because the supplied idle duration can predate that sleep. Extend both contributor regression tests across the sampling boundary and qualify the platform guarantee.
Add AwakeIdleMeter, which reads the wall clock and time.monotonic() on
each watchdog check. monotonic pauses during sleep on macOS
(mach_absolute_time) and Linux (CLOCK_MONOTONIC), so the gap between the
two clocks is time spent asleep, and that sleep is not charged as idle
time until the agent records new activity. The cron inactivity watchdog
and the turn liveness watchdog both use it, because on main both guard a
cron run with a 600 s wall-clock bound. A job that is really idle for
600 s awake is still killed.
Fixes#131523
(cherry picked from commit 07d9ddc24736e243d72327ff90a24f95e86119d8)
`release.py abandon` deleted the draft and wrote the marker but left the
attempt's Stable Release run going. A live run keeps building, and with
autopublish on it can publish the draft that abandon just cleared.
abandon now lists the claim ref's runs of stable-release.yml and runs
`gh run cancel <id> --force` on every one that has not completed. This
happens before the draft is deleted and before the marker is written: the
marker removes the attempt from the outstanding set, so a cancel failure
after it could never be retried. A published release is still refused
before anything is cancelled. The `delete` callable is renamed `execute`
since it now runs cancels too.
memory-providers.md gets a "Plugin catalog" note on the Supermemory section,
`hermes plugins install supermemory` in its setup, and a "Migrating from
bundled Supermemory" section that mirrors the Hindsight one. memory.md,
overview.md, integrations/index.md, cli-commands.md and the developer guide
no longer list Supermemory as bundled.
Mirrors the English pages: catalog note, install row and upgrade section on
memory-providers, catalog wording in tools/tools-reference, and the plugin's
cli.py link (blob URL, also fixed in the English developer guide).
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.
Both seams are public contracts for plugin/theme authors (first consumer:
t3-code-theme, #129600); the page documents the arbitration, failure
isolation and lifetime guarantees so a theme never needs DOM reads.
`hermes status --full` printed two lists for the same platforms: a legacy
table judged by one env var, then "(plugin)" rows judged by the gateway.
Slack configured in config.yaml showed "✗ not configured" and
"✓ configured (plugin)" in the same section, and the legacy verdict could
disagree with the summary's Platforms line.
Render one list from the gateway config instead: registry entries bring
their labels, a small label table covers built-in adapters without one,
"configured" is get_connected_platforms() (the summary's verdict), and the
home channel comes from the loaded config, which also fixes the legacy
table reading QQ_HOME_CHANNEL where the gateway reads QQBOT_HOME_CHANNEL.
Dedupe idea from #54131; root-cause analysis of the check_fn verdict from
#96200.
Co-authored-by: igparkernz-coder <297591502+igparkernz-coder@users.noreply.github.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Submitters only saw a stale 5-point summary on the catalog page (it even listed
a 'Released' requirement nothing enforces), while reviewers applied ten README
rules plus criteria written nowhere public (credentials, approvals, disclosure,
compatibility metadata, forks of bundled plugins).
- plugin-catalog/README.md: rules 11-15 write down those review criteria; the
rule list sits between admission-rules markers.
- New developer-guide page 'Submitting to the plugin catalog': before you
submit, the PR flow, the full rule block (verbatim mirror), pin updates,
delisting vs removal, asking for a hook.
- The /plugins 'Submit' links and the user-guide section point at it.
- tests/website/test_catalog_rules_mirror.py fails when the two rule blocks
differ; classify_changes makes a README-only edit run it.
- AGENTS.md, plugins/AGENTS.md and CONTRIBUTING.md reference both files.
Catalog policy (Teknium): a listed plugin extends Hermes only through public
surfaces. A plugin that replaces core functions, methods or Desktop UI in place
collides with every other plugin patching the same seam and breaks on any core
release.
- New `no core override` admission check (hermes_cli/plugin_validate_core_override.py):
static taint pass over the import closure of __init__.py and dashboard/*.py.
Core values are Hermes modules (plus hermes_plugins.*) and attribute chains off
them, reached by import, importlib/sys.modules lookups, getattr, iteration, or a
plugin helper that returns one. Rebinds are attribute assignment/deletion,
setattr/delattr, sys.modules writes, writes into a core module's tables,
mock.patch, and passing a core value to a helper that setattr's its parameter.
Tests, benchmarks and CI/smoke scripts outside the import closure are not judged.
- Desktop lint: new `app DOM reach` rule (document-level queries of the app's own
data-slot/data-tour/data-sidebar/data-testid markup, and document.body
childList/subtree MutationObservers).
- Desktop lint bug: the comment stripper did not skip string literals, so a string
holding `/*` (a glob like '**/*.md') opened a fake comment and hid every line up
to the next `*/` from all rules. Strings are now matched first and kept.
- README rule 9, the plugin-catalog user guide and the plugin developer guide's
compatibility contract state the rule.
A/B over 466 pinned trees (121 open catalog PR entries + 349 listed entries +
humalike): the new checks flag exactly the hand-verified overrides; zero crashes;
no previously reported Desktop finding disappears.
An invalid_encrypted_content 400 (or the Codex 401 token_expired twin)
used to strip every cached codex_reasoning_items blob AND turn
replay off for the rest of the agent's life, so a single rotated
sealing key cost all cross-turn reasoning continuity in a long CLI
session or a cached gateway agent.
The first rejection now only strips the stale blobs (persisted messages
and the request copy the retry is rebuilt from); blobs the route mints
afterwards are sealed with its current key and keep replaying. A second
rejection means the route cannot round-trip its own fresh blobs, so
replay is disabled for the session as before: a hostile route costs one
extra 400 compared to main.
The request-copy strip is required: main's global kill switch hid that
the retry was rebuilt from api_messages, which still carried the blob.
#128248 registered gpt-6.1-sol but left it out of the -900k opt-in because
nothing was verified above 272K on Codex. Live probe on the ChatGPT OAuth
route (2026-09-29, streaming /responses, server-reported input_tokens, effort
low): gpt-6.1-sol completed at 684,566, 898,681 and 918,137 input tokens with
all three needles recalled, and was rejected with context_length_exceeded at
about 931K and above, in line with the model page's 922,000 max input. The
live catalog advertises 272K with an 872K ceiling, which still caps the bump.
Eligibility is by exact slug, not the gpt-6-sol family prefix, so the dotted
slug is its own verified line and no -pro variant is synthesized. The variant
is billed as the base model.
(cherry picked from commit 4772e9db5d7b0fe22da80dea4f3c662672ea5302)
Both keys shape the dynamic image_generate schema but neither the
ImageGenProvider.capabilities() docstring nor the provider-plugin guide
mentioned them.
One answer per profile, asked once, with the Desktop strip's three equal
choices (Send to Nous / Local only / No thanks) now offered by:
- `hermes setup`: at the end of every flow (Quick, Full, Blank Slate,
Portal, --quick), via the setup-completed record they all funnel into.
- `hermes` / `hermes --tui`: once before an interactive chat on a profile
that never answered. Skipped for -q, piped/JSON output, spawned actions
(HERMES_NONINTERACTIVE), Desktop-hosted panes and managed installs.
- Web dashboard: a banner for the profile being managed, backed by
GET/PUT /api/shared-metrics/consent.
"No thanks" is the terminal default so Enter never opts anyone in; Esc and
the banner's X leave the question open.
Also fixes the answer that could not stick: a CLI "no" equals the shipped
defaults, so save_config stripped it and every surface (Desktop strip
included) kept asking. The answer is now written to config.yaml directly,
and `decided` has one definition shared by the RPC, the CLI and the router.