Relay 0.10 starts gen_ai trace/log/metric export on its own whenever an
active configuration meets an OTEL_EXPORTER_OTLP_* endpoint in the process
environment, and those spans carry prompt and response messages. The
legacy HERMES_NEMO_RELAY_ATIF/ATOF variables never exported OTLP, so the
generated relay-plugins.toml now pins opentelemetry.enabled = false.
Also restore the discovery-only test's DISABLED-state assertions: they
pass on 0.10 and only trip on a host with a machine plugins.toml.
#118661's note only fired on the stored-prompt restore path, which a long-lived agent (classic
CLI, TUI, Desktop) runs once per conversation: those surfaces never saw a mid-conversation skill
change. The check now runs from build_turn_context every turn against the prompt the turn
actually sends.
- What was told is kept on the agent for its prompt; a fresh agent (gateway, -q --resume) reads
the newest note back from the WHOLE transcript instead of the last 200 rows, so a long session
no longer re-announces an unchanged delta (review by @kvnloo).
- A compaction rebuild that now lists the announced skills stays quiet.
- Index rows split on ": ", so qualified plugin skills (plugin:skill) parse.
- The plugin go-live note drops its own skills list: the per-turn note covers plugin skills like
any other, so a plugin install no longer announces its skills twice.
The persisted <available_skills> index is reused byte-for-byte across turns so
the provider prefix cache stays warm (#104414). A skill that lands after the
prompt was built (hub install, skill_manage from another session, org sync,
curator) therefore never reached the index the model actually routes on until
compaction rebuilt the prompt: skills_list is live, but the prompt tells the
model to scan the index, not to call it. cloudflare-os#267 found the same fault
in their chat-cached catalog ("a catalog states what a session can reach now,
which is not a fact a chat can hold").
Hermes' idiom for stored-prompt drift is a one-shot note behind the cached
prefix, not a rebuild, so agent/skills_index_delta.py stages the delta on the
same per-turn user-message channel the surface-switch note rides: the added
skills' own index lines (name + description, the routing signal), the removed
names, cumulative against the stored prompt and re-staged only when the delta
changes (read back from the api_content sidecar, so the gateway's fresh agent
per turn does not stack copies). An undone delta retires the stale note once.
The current index comes from the same cached builder the prompt did, so an
unchanged turn costs one LRU hit.
Owner ruling for gateway_pause_failed: extend package schema v4 in place
(additive only; ingest validates the envelope and every shape already sent
stays valid) instead of bumping to v5. The two sentences that said v4 was
extended only while canary-only now say so.
The exit is real (RuntimeError from require_readable_config_before_write), but the consent gate
reads the same config.yaml and reads it as collection off: a live run on a scratch install with
an unparseable config.yaml wrote no metrics store at all. A closed value nothing can emit only
widens the contract.
hermes.update.run rows with apply_mode=unknown still had three exits with no stop_class on main:
- The startup-syntax preflight (update_cmd_commit.read_target_files) runs `git cat-file --batch`
under a 120 s limit. On a blobless install (install.sh clones --filter=blob:none) every changed
blob lazily fetches in its own round trip, so a slow link times out and the uncaught
TimeoutExpired ended the whole update as `subprocess_failed` after 2+ minutes (31 CLI + 23
Desktop install-days). An unread batch now counts as unread files, which is the preflight's
documented contract; the post-move syntax check stays the backstop.
- An unreadable/unparseable config.yaml (require_readable_config_before_write RuntimeError) or an
invalid configured channel name (ChannelError) while picking the channel read `exception`. They
now record `config_unreadable` (new) / `channel_unresolved`.
- A failed Windows gateway pause (_abort_on_error / ServicePauseFailed RuntimeError) read
`exception`; it now records `gateway_pause_failed` (new).
Two closed UPDATE_STOP_CLASSES values, the v4 schema enum and the relay-shared-metrics doc row
change together; every legacy value stays valid.
On Windows the hand-off names the process it created as the marker's delegate, and
hermes_bootstrap's relaunch (subprocess.call) or the legacy .cmd's cmd.exe makes the updater
its child, so the delegate check now also accepts our parent. The docs sentence moves to the
hermes.update.run outcome row and names the checkout-lock case (another mutator holding it).
The hand-off quits the Desktop and launches `hermes update` as the marker's
delegate. When the update lock then refuses that claim (the pre-a28a5d03a9f
whole-second creation-time bug on macOS, still present on installs that cannot
pull the fix through the button), no other updater exists: the Desktop shows
"Hermes couldn't finish updating" and the user retries. The metrics row read
outcome `refused`, which the rollup treats as neither attempt nor failure, so
~320 Desktop update failures a fortnight vanished from the failure rate.
Record that one case (marker names this process as its delegate) as `failed`;
a refusal under another update's claim stays `refused`.
Without the opt-in, external recall is kept whole up to
max(10 x hooks.output_spill.max_chars, 100k chars) and still spills above
that, so one runaway provider can't overflow the context window and wedge
the session. Normal provider-budgeted recall (~16 KB for Honcho) is
unaffected.
Plugins can now put a short plain-text line in the core pet's speech bubble
instead of locating the pet canvas in the app DOM and floating their own
overlay over it (pet-wallet, #135178).
- ctx.pet.say(text, { id?, tone?, ttlMs? }) -> disposer; ctx.pet.clear(id?);
ctx.pet.visible (ReadableAtom<boolean>)
- host-owned rendering in PetBubble, in-window (plugin lines only) and in the
pop-out overlay (lines ride the existing pet-state IPC push)
- plain text, 120-char cap, control/bidi chars stripped, plugin-name label,
TTL 6 s default (1-30 s), 3 live lines and 10 says/10 s per plugin
- core error / waiting states keep the bubble; nothing shows without a pet
- every line a plugin has up is cleared on unload/disable/reload
- docs: API section, migration-table row for pet-wallet, SDK exports
A release that skipped bundles never writes the stable baseline pointer,
so its absence is normal at any point, not only before the first bundle
release. Drop the final-tag scan. A 404 on the default pointer now always
plans no upgrade arm. A supplied baseline-manifest that does not exist
still blocks, because that is a bad input.
No stable release has shipped signed packages yet. Every final tag so far
skipped bundles or predates the claim pipeline. So
releases/stable/release-candidates.json does not exist, and the three
"Pin actual OLD and NEW signed packages" jobs refused with "No published
stable package baseline".
transitions now treats a 404 as a first release only when no final stable
tag records a candidate manifest and no baseline-manifest input was given.
It still validates the receipt, emits empty matrices and baseline=none.
The packaged upgrade arms skip on that output. The gate accepts their skip
only when their own planner succeeded with baseline=none, so a missing
baseline after a real bundle release still blocks.
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.
The pipeline seeded its version line at 0.21.4, but the legacy CalVer line
already published "Hermes Agent v0.21.5" as v2026.9.24. A first `--bump patch`
cut therefore derived 0.21.5 again: installs already reporting 0.21.5 would
compare equal and never see the new build as an update, and the release page
would carry two v0.21.5 entries.
Seed at 0.21.5 so the first pipeline patch is 0.21.6 and the first minor stays
0.22.0. Entrypoint fixtures now pin their own published head instead of
tracking the production seed.
- record_stop_without_receipt reads the update marker raw and tags initiator=desktop only when the
marker names this run's hand-off (delegate line = us, or HERMES_UPDATE_HANDOFF_PID / our parent),
so a Desktop-started early refusal reads kind=desktop and a CLI run refused by someone else's
update stays cli.
- _move_checkout_to classifies its git error with the shared classifier (git_output_stop_class):
index.lock 'File exists' -> git_index_locked, 'Permission denied'/EACCES -> permission_denied
(now a stop class), else checkout_move_failed.
- _publish_shared_metrics: an interpreter that cannot import the config reader (the -I -S bootstrap
that finalizes a dependency-preparation failure) never emits; it parks the bounded receipt in
pending_updates unless it can tell collection is off (loaded config says off, or no config.yaml),
and the next normal start applies the existing gate (report_pending_updates / begin_process purge).
- The parked copy keeps a stop reason's label only when it is an exception type name (plus its
errno token) or one of Hermes' two fixed phrases; any other text before a colon reads '-'
(a free phrase like 'my project name:' used to survive).
- _exit_after_failed_branch_switch's branch_missing/checkout_move_failed probe cannot raise, so
the exit it names is unchanged even when git refuses.
- hermes update --plan/--check/--list-venv-holders on a managed install records no update run.
- Docs/contract say 'before the apply stage mark' and name the tokens that fire after git moved
and restored the tree; tests drive git_error_stop_class and _zip_stop_class directly.
- test_shared_metrics_update_stop_reasons: every stop token reads as its own class in-process
and through the parked copy (no free text kept), unknown tokens and post-apply tokens fall
back, a committed run that only owes parked changes is partial; the lock and Git-operation
refusals each record one row and leave the holder's latest.json byte-identical.
- test_shared_metrics_install_failures: a PermissionError stop reason now reads its own class
permission_denied (was os_error); ENOSPC reads disk_full; other OSErrors keep os_error.
- relay-shared-metrics.md: the new classes, partial outcome and parked fields.
- relay smoke: the failed update receipt names its fetch failure and the row asserts fetch_failed.
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.
Lead fold from the independent review (quality + thermo + 2c + efficiency
lenses, all Low): the fire startup gate and
APIServerAdapter._gateway_is_draining now share runner_is_draining, so a
future drain flag cannot be added to one and missed by the other; the gate
returns early on no runner instead of looping on an unchanging guard. The
cold-adapter test closes its loop and joins its thread, the slow-verify
test keeps the same ordering at a fifth of the wall-clock, and the Chronos
contract doc lists the startup 503.
The desktop's old recommendation inputs were the only client of
GET /api/mcp/catalog?detect_apps=true and the only sender of the guide_*
onboarding steps; both went with the old ::onboarding chain.
- The catalog route loses detect_apps; mcp_app_detection.py and its two
test files go. The manifest's suggest.applications validator moves into
mcp_catalog.py, which still validates applications / requires_app /
examples (documented in features/mcp.md).
- DESKTOP_ONBOARDING_STEPS and the v4 schema drop guide_connectors,
guide_first_build, guide_layout and guide_look. v3 stays as published.
- developer-guide/onboarding-recommendations.md described that pipeline
and is removed.
Brings the onboarding branch up to main 0c2ff854a5. Where a Wave A PR
already landed a reviewed version of a piece of this branch (free tier
#134049, keyless web #134048, app resolver #134050, bundle and uninstall
#134051, catalog card and toolsets #134052, built-in tour #134054, stdin
guard #134055, retired key #126146), main's version wins. The PM long-path
files also take main's version: that work lands through #130430.
Kept from the branch on top of main:
- the setup and start_chat toolsets, declared with main's platforms key
(desktop only), and the setup_choose server request contract;
- the setup-profile marker file (.setup-profile.json) instead of the
profile.yaml role, as c3f85bc54f decided;
- the first-contact note's message argument beside main's internal flag;
- the setup-profile gate on the shared-metrics consent dialog;
- window sizing (window-growth) in place of chat-onboarding-window.
Desktop i18n follows main's per-section sibling files. The onboarding
strings go into them (modelMenu.localSetup, handoffTour.localTitle and
localText) and setupChoose/startChat stay in en.ts. guidedGreeting is
gone. The shared-metrics copy uses the branch's rewrite in English, with
the pre-opt-in sentence corrected for the fresh-install note
(d3674513c8); locales keep main's translations, with installId and
consentWindow folded into the new sending key.
The built-in tour reads local-model eligibility from the local-setup
offer store, the one place that rule lives on this branch. The gateway
contract and the onboarding skill docs pages are regenerated.
MEDIA (2f1fabd4bf, the backend MEDIA: parser and attachment parts) is
deferred to its own PR. Its 53 MEDIA-only files go back to the merge-base,
so they take main's version; the 10 files it shares with onboarding keep only
their onboarding hunks (setup_choose and start_chat cards, the start_chat
contracts, the scripted prelude, the first-contact note, the local-setup
turn report, intro_resends, visible_text, the bigger chat headings). The
generated gateway contract is regenerated after the merge with main.
The Store variant's HERMES_GUEST_ONBOARDING bundle env (39af862aec) is
reverted here; it ships through Ari's bundle work.
The setup chat no longer scans the computer. The collector, its host_facts.py
hook, the inline-shell setting on the setup profile, the ${HERMES_PYTHON}
skill token and the insights/ cache are gone.
The first /initiate-setup turn now sends the skill plus one JSON block that the
model reads as it is: machine basics (OS, release, architecture, CPU, RAM, GPU
class, vendor, WSL, container), machine_kind, has_nvidia_gpu, is_spark,
suggested_name and locale, beside the session keys. A key that was not
measured is left out.
The non-scan parts of host_facts.py (account name, locale, Spark detection,
the fork card, the NVIDIA and Blender tasks, the handoff text) move to
agent/initiate_setup_facts.py and are imported normally instead of loaded by
file path.
The known_failure on the mid-merge launcher gap is gone: #132361's launchers import
hermes_bootstrap (the interrupted-pull repair) before any other checkout module, so the
merge-time kill must leave a runnable install with no gate.
(cherry picked from commit 96fd6724a86aa9b48ac7f88b56230e94f3447d7e)
(cherry picked from commit f975552309b97b7b2e4ac97b63ad278d185e2369)
Native run 37190464303 (first merge-time kill): git merge was held writing
hermes_constants.py with index.lock held and killed there. Every next
launch then died with ModuleNotFoundError: the launcher
(hermes_cli/_launchers.py::_launcher_script) imports hermes_constants from
the checkout before import hermes_bootstrap, whose
restore_interrupted_pull is the repair, so the repair never runs and even
hermes update fails. The bricked install then failed every later cell.
mid_git now runs last, and its recovery assertions sit under a
known_failure keyed on exactly that traceback (owner upd-txn: strict
acceptance fails on it). The hold precondition (index.lock left by the
kill) is asserted outside the gate.
(cherry picked from commit 7aca86f2341f6135b4da2666d58e24e3e14b7945)
(cherry picked from commit 95595e12f6a0e6ddc022a43e871c8e654149fbbe)
Every green native run killed the mid_git cell during git fetch (fetch was
in GIT_OPS and is the long step), so the merge-time index.lock schedule the
cell is named for never ran. A smudge filter the harness names in the
install's .git/config + .git/info/attributes (git's own filter hook; no
product change, no env var) now holds git merge while it writes
hermes_constants.py: index.lock held, the cell's new file and
hermes_cli/main.py already at the target, hermes_constants.py unlinked.
The kill point requires the hold, a merge/reset/checkout git child by argv
and index.lock; the cell asserts the lock was left. The fetch-time kill
keeps its own cell (mid_fetch).
(cherry picked from commit 592d57c3a9f78e04d617be659342e5338e080381)
(cherry picked from commit ba54f871bd580bb3fe9de7f840a7a21baf564dd2)
- mid_git: the xfail is gone; #132361 (this branch is stacked on it) fixes the stale
index.lock + bare-git interrupted-pull repair.
- orphaned_update: kill ONLY windows.ps1 (no /T) once its `hermes update` child runs
under the claimed marker; the update must finish, the marker must read LIVE (owner or
line-4 delegate, C1) for as long as it runs and be gone after. The marker assertions
are a message-gated known_failure until #132354 + #132365 land the delegate line.
- _marker_text parses the line-format marker (it ran json.loads: always <unreadable>).
- tree_moved reads HEAD from the ref files instead of spawning git rev-parse every poll.
(cherry picked from commit aa8a8734785fdcc2d7fe958ab15ae002afdd12d9)
(cherry picked from commit eaf540f555cc936e798edc84ee1fd18fa802beac)
Cell -> kill point -> test -> fixing lane, and when the real-update suites
are required on a pull request.
(cherry picked from commit 9a8d1406641ee1a66e755267677c2b029a38f067)
(cherry picked from commit 05d543c6a6e4da288939ab44b198cd3dd18a6f9a)
* 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.
- `skills_hub._registry_prefixes` builds the identifier prefixes from the adapters' own
SOURCE_IDs and `SkillsShSource._ID_PREFIX_ALIASES`, case-insensitively, so `skills.sh/...`,
`skils-sh/...` and `OFFICIAL/...` stop reading `unresolved`. A matched source is named by
`source_id()`, the method the router and the contract test use; two test fakes gain it.
- `update_failure_class`: exception type -> class and fixed stop-reason prefix -> class are
tables instead of an if-ladder. `Windows gateway recovery failed: ...`, the fixed prefix
update_completion writes when the post-update gateway resume fails, now reads
`restart_failed` instead of `other` (prefix only, never the message).
- `mcp.servers.add` records through `mcp_rpc_helpers.record_mcp_add`, dropping the 175-column
noqa line that kept methods_tools.py at its cap (2011 lines now).
- `_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).
update_cmd_fleet._verify_fleet_after_update writes `partial` for a failed build
(`not update_complete`) and for an incomplete restart as well as for a
verification gap. The relabel marked the synthetic verify stage failed for every
`partial`, so failed_stage moved from build/restart to verify and every such run
added a failed hermes.update.stage verify row. Verify is now failed by `partial`
only when no stage mark already failed (a stale/down fleet row still fails it).
The appendix row states the rule.
report_pending_installs recorded the fresh-install row at the first start,
usually the opt-in day itself (setup asks inside the install). The sender's
CONSENT_GATE_SQL needs period_start >= opened_at, so that day's package is
never sent: the metric mostly measured re-runs over already-consented homes.
- A receipt is recorded only when collection is on AND (sending is off, or an
open send consent window opened at or before today 00:00Z); otherwise it is
kept for the first start on a later day. Tested against the real store,
real reconcile_send_consent and the real CONSENT_GATE_SQL: kept on the
opt-in day, recorded the next day, and that package passes the gate.
- Every opt-out answer purges pending_installs AND pending_updates:
save_consent(enabled=False) (setup, pre-chat offer, dashboard banner,
`hermes setup telemetry`) and the tui_gateway shared_metrics.set RPC, via
one shared_metrics_process.purge_pending_receipts used by begin_process too.
- Receipts are claimed by a plain rename, so an unparseable or non-dict
receipt is deleted instead of sitting forever; a receipt no start counted
within 7 days is deleted unreported (it would count under a later version).
- With Relay instrumentation off the store "settles" a row without recording
it; the reporter now leaves the receipts for a start that records, instead
of latching and deleting them uncounted.
- Docs: the hermes.install.run row states the consented-day rule, the 7-day
cap, opt-out deletion and the interrupted rules.
Invariant tests red at 6246dd0f69d (5 in test_shared_metrics_install_run,
1 in test_shared_metrics_consent_rpc).
install.sh / install.ps1 leave one closed-token receipt per full ladder run
(installer, outcome, failed_stage, failure_class, start/finish epoch) under
$HERMES_HOME/telemetry/shared_metrics/pending_installs. Every fail()/Fail call
site passes a closed failure class; the installer never sends anything.
The next Hermes start records each receipt once (latched by id, deleted after
the row is saved) only while collection is on; a start with collection off
purges the receipts unreported. Out-of-contract receipts are dropped.
Inline `!`cmd`` expansion in SKILL.md bodies ran for every skill whenever the
global skills.inline_shell flag was on — a community hub skill and a builtin
were expanded identically. The hub's install gate (INSTALL_POLICY) blocks a
community install on the caution/dangerous verdicts the inline-shell DSL scans
as, but --force or a pre-scanner install puts that skill on disk anyway, and
the next view/slash/preload would auto-execute it on the host.
Gate expansion at the single preprocess_skill_content choke point (all three
surfaces route through it): community-trust hub lock entries never expand;
trusted/builtin hub entries and anything without a hub lock entry (bundled-
synced, user-created, project/external) keep the flag's contract. Lock read
failures skip the gate, like every other provenance consumer.
Fixes#63307
What: a "Behaviour changes on the git path" section in the source-update developer guide: ZIP
fallback only from an untouched checkout, arm failure stops before any move, startup syntax
judged at the target by an admitted interpreter in one cat-file spawn, moves by commit id with
HEAD verified, the upstream ff judged before it moves, and a gone-target marker kept over a
dirty tree.
Why: the reviewer asked which of these are intended; each is, and release notes need the list.