homeassistant, spotify and claude-subscription-directsdk now live as
subdirectories of NousResearch/hermes-official-plugins, the single repo for
Nous's official plugins (each old repo's history carried over). Plugin code
is unchanged, so the manifest surface is identical and the re-pin needs no
new consent.
Existing installs follow on their own: the left-core migration installs by
catalog name, and a catalog re-pin clones the entry's current repo/subdir and
rewrites the install record. Adds 1200x600 card images.
Review on #136313 (Greptile):
- The overlay offered "Change" to a custom-branch checkout and to runtimes
older than --set-channel, but Settings shows no selector for either, so
the link landed nowhere. The label stays; the action is passed only when
sourceUpdateChannel() allows a choice.
- An older checker omits aheadOfRelease yet may also pin an ahead checkout
to its own HEAD, so a missing field read as "on the release". source_check
now always sends the boolean, and the status line names the release only
on an explicit false.
The updates overlay (status-bar version click) showed no channel, so a
source install could not tell it was pinned to stable releases.
- VersionDetails (shared by About and the overlay) gains a Channel row for
source checkouts: "Stable releases", "Every commit (main)", or
"Branch: <name>". Packaged installs render no row; their channel is the
package's.
- The overlay passes a "Change" link that closes the dialog and deep-links
to Settings > About > Updates, where the selector and its confirm step
live. The channel is not switchable from the overlay itself.
- Current on stable, the status line names the release ("You're on the
latest stable release (v0.21.6)."). A forward-only checkout ahead of the
release keeps the generic line: source_check now reports
`aheadOfRelease`, since its pinned target is HEAD and would otherwise
look identical to being on the release.
baab4808d0 routed the stable lookup's GitHub reads through
source_check._request, so hermes_cli/source_check.py is now imported twice in
the probe (the CLI entry, then the module source_releases reads through). The
fixture's transport.py runs on every import of that file, so the second copy
wrapped the first and saw the already-rewritten 127.0.0.1 URL:
`assert parsed.hostname in ('hermes-assets.nousresearch.com', 'api.github.com')`
failed and the stable leg returned release-unavailable. main's JS & TS checks
have been red on this since 66605471e9.
transport.py now installs the redirect once (a marker on urllib.request). No
assertion changed. Locally: 1 failed on main, 1 passed with this.
configureProvider() rewrote config.yaml from scratch for every spec, after
the seed had pinned `update.installs.<id>: main`, so the three Desktop update
specs still saw the stable default (release run 38078988253: the update check
answered {"channel":"stable","error":"release-unavailable"}). It now carries
the seed's top-level `update:` block into the provider config it writes.
#135847 made an official-origin source checkout default to the stable channel,
which resolves only from GitHub's latest published release. The upgrade-network,
Windows install+update and Desktop update harnesses keep the official URL as
origin, publish commits on main and never a release, so every update in them
stopped at the stable default:
- e2e-upgrade (network): "Could not resolve the stable source channel: No
published stable release could be verified" (9 failed)
- Windows install + update E2E: "Release: stable v0.21.6; this checkout is
newer and waits for the next release", checkout never moved (12 failed)
- Desktop update E2E (app-update, update-concurrency, build-fail): the update
check answered {"channel":"stable","error":"release-unavailable"}
These suites test moving between commits, so each seeded install now records
`hermes update --set-channel main` right after its installer (new
_install_helpers.pin_main_channel; Windows Machine.install). Stable resolution
keeps its own coverage: test_release_channel_records sets stable explicitly on
the same seeded install, and tests/hermes_cli/test_source_release_channels.py.
Local: tests/e2e/core/upgrade/network on main plus this change, 21 passed
(4 failed in test_offline_and_flaky.py on main before it).
The notice re-checked store/HEAD inline; tools/checkpoint_manager.py already
exposes _store_has_head() for exactly that. Also pin the notice test to
"across 1 project(s)" so a count of 11 cannot satisfy it.
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.
The hand-off pid test only checked which pid the first update child was
told, and it was gated to Linux, so the macOS runner (where the refusal
was reported) never ran it.
It now drives the real posix.sh hand-off, has the update start a second
generation detached from it (the relaunch / takeover shape), and runs the
real UpdateLock there: on main that generation refuses with the custodian
as holder; with the fix it adopts. Gated "posix" so the macOS lane runs it
against marker.sh's whole-second creation times.
The salvaged test grepped posix.sh for the export line (tests must not read source). This
runs the real posix.sh hand-off against a launcher-backed stub install and records, inside the
update child, the HERMES_UPDATE_HANDOFF_PID it was given and marker line 1 at that moment:
they must be the same live custodian. Red on main: the child is told the script pid while
line 1 names the custodian.
The posix hand-off hands marker line 1 to a custodian subshell
(marker_refresher_start) and then exported HERMES_UPDATE_HANDOFF_PID="$$" —
the hand-off script's own pid.
`hermes update` adopts a live claim only when the owner is its own pid, the
HERMES_UPDATE_HANDOFF_PID value, or an ancestor
(update_lock.UpdateLock._is_partner). The custodian is a *sibling* of the
hand-off script, so it is none of the three and the ancestry walk can never
reach it: every desktop-initiated update refused the claim its own script had
just made and exited 2 ("Another Hermes update is already running ...
process <custodian>"), leaving the Desktop to relaunch and retry forever.
Export $MY_PID instead: the custodian once the handover succeeded, and the
script's own pid when it did not.
Fixes#134268
Review on #136086 (Greptile):
- The failure no longer travels through a module dict. `_resolve_release`
raises; `_resolve_stable` describes the exception; `resolve_source_release`
stays the `(None, None)` wrapper for its other callers.
- A token GitHub rejects (401) retries anonymously; a 403 on that retry was
described as the user's GITHUB_TOKEN quota. `_request` now marks the
raised error with whether that request carried a token.
- The test server closes its listening socket and joins its thread.
`_resolve_stable` read releases/latest + commits/<tag> through a bare
urllib call with no credential, so it spent the anonymous 60/hour per-IP
budget even with GH_TOKEN set or gh logged in (the rest of the update check
already uses github_api's token ladder). Behind one office, CI or container
exit that budget runs out, and the failure was then reported as
"No published stable release could be verified".
GitHub API reads now go through source_check._request (token, gh CLI,
anonymous; a rejected token retries anonymously). The R2 reads keep their
own path, so the token never leaves api.github.com. A lookup that fails on
transport now raises describe_github_failure's line (rate limit + reset
time + the GITHUB_TOKEN hint when anonymous) instead of "no release".
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.
Review thread on #136050 (agent/context_compressor_telemetry.py, "Refusals
report unsaved effects"). The compressor records a candidate's effect
fields before the commit checks run. When the growth guard refused the
candidate, or a database failure rolled the transcript back, the aborted
record still reported its `messages_after`, `tokens_after`,
`tokens_reclaimed` and `items_dropped`. That claimed reclaimed tokens for a
conversation that stayed unchanged.
Drop those fields from any record that neither committed nor rewrote the
history. The input-side counts stay.
Review threads on #136050 and #136051 (agent/conversation_compression.py,
"Overlapping calls erase attempt details" / "Skipped calls erase active
records"). `_begin_compression_attempt` cleared the compressor's three
attempt-telemetry fields and rewrote its seed at entry, before the
automatic gate and the session lease. A second automatic call that stopped
at the gate (e.g. a cooldown while overflow recovery ran with
`bypass_cooldown=True`) therefore erased the running attempt's record. That
attempt could still commit, but logged `method: none` with no counts and no
fallback class, so a fallback commit read as a clean shared-metric success.
The previous-attempt clear (#118580) now runs in `_run_summary_phase`, under
the lease, just before the summary dispatch. The same statement writes this
attempt's own seed, so the entry-time seed write goes. Calls that stop at a
gate, the lease, or a stale snapshot never touch the shared fields.
A rewrite that survives a later bookkeeping failure (`failed_not_indexed`
after the atomic archive) also gets its counts finalized from the saved
transcript, using the same `rewritten` result that names the Relay mark
(thread "Saved tails disappear from counts" on #136051).
Review thread on #136050 (agent/conversation_compression_telemetry.py,
"Refusals inflate batch counts"). Since #135807 every exit of
`compress_context` (blocked by a guard, skipped, aborted, refused) logs one
`context compression attempt telemetry:` line, and
`scripts/micro_compaction_report.py` counted every such line as a batch
compaction. Keep only records whose `commit_status` is `committed`.
Review thread on #136083 (agent/context_compressor.py:36). #135807 moved
`_safe_int` into `agent/context_compressor_telemetry.py` but imported it
back into `context_compressor.py` only so `micro_compaction` could keep
reading `_cc()._safe_int`. AGENTS.md forbids re-export shims for internal
moves: `micro_compaction` now imports it directly.
Review thread on #136083 (agent/conversation_compression.py, "Committed
rewrites look failed"). The commit-path attempt record decided
`history_rewritten` with `compressed is not messages`. A context engine may
compact its input list in place, so the returned list IS the input even
after `archive_and_compact` stored it. If a later bookkeeping write
(`update_system_prompt`) then fails, the record read
`history_rewritten: false`, the Relay mark was `compaction.attempt`, and
Relay skipped the history-freshness reset although the model now sees the
compacted transcript.
Use `commit.compacted_in_place`, which `_commit_compaction` sets on the line
after the atomic archive returns, alongside the identity check.
Overflow recovery calls the automatic gate with `bypass_cooldown=True`, so
the summary-failure cooldown cannot be what blocked it. But
`_compression_block_reason()` always reported the cooldown first, so with a
cooldown also active:
- the attempt record said `blocked:cooldown` while the structural backoff
or the ineffective breaker was the real blocker, and
- `_mark_compression_blocked_transient` marked an `ineffective` block as
transient, so overflow recovery deferred instead of counting toward
exhaustion, which #97488 says must never happen.
`_compression_block_reason` takes the gate's `ignore_cooldown` flag.
`_automatic_compression_gate_blocks` reads the reason once, with the same
flag the gate got (older engines keep the no-argument calls), and returns it;
the transient signal and the blocked attempt record both use that one
reason, so the telemetry module no longer re-derives the guard order.
#135807 makes the empty-transcript refusal emit its attempt record through
the shared-metric lifecycle (on main that exit recorded nothing), but
`empty_transcript` was not in the closed `COMPRESSION_FAILURE_CLASSES` set,
so the `hermes.compression.count` row read `failed / other`. Add the class
to the contract, the v4 package schema enum and the metric docs so the row
says what happened.
Follow-up to #136083 (salvage of #135807):
- `agent._compression_attempt_seed` had one reader, the `_attempt_seed`
fallback, which every production emit bypasses by passing `attempt.seed`;
the only seedless emit (pool saturation) passes `attempt_began=False` and
never matched it. Removed.
- Both attempt emitters log through `_log_attempt_record`, so the line
`scripts/micro_compaction_report.py` parses has one source.
- The in-place coverage call reuses `original_messages`, and the
`_commit_compaction` docstring lists the anti-growth guard before memory
extraction, matching the order #135866 introduced.
Keep aborted attempts' existing classification when the summary model was
benched. The new regression case proves the failed row remains other while
a committed fallback still maps to feasibility_skip.
Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>
The parametrized test covered four of the five fallback classes a
committed attempt keeps on hermes.compression.count, and missed
summary_overload_degraded: the class the compressor sets when sustained
summary-model overload ends in a deterministic summary. It takes the
same path as the other four, so this adds the fifth case to the same
test rather than a new one.
With the classifier and finish_compression_attempt restored to the
base, the case reads success/none and fails with the other committed
fallback cases.
Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>
finish_compression_attempt reported every committed attempt as
outcome=success with failure_class=none, so the metric could not tell a
clean summary from a commit that went through a fallback: a
deterministic summary (summary model failed, pre-LLM feasibility skip,
repeated stall, sustained overload) or the main model writing the
summary after a separate summary model failed.
A committed attempt still reads success (it did commit and break the
prompt cache), but now keeps the attempt log's own class when it has
one: summary_generation_failed, feasibility_skip,
stall_deterministic_fallback, summary_overload_degraded or
aux_model_fallback. summary_model_benched, the deterministic commit after
the summary model is benched for repeated fallbacks, maps to
feasibility_skip, which is the pre-LLM skip it is. Any other class reads
other.
No new enum values, so the v4 package schema is unchanged and a fallback
row still validates. Consumers that read failure_class != none as a
failure must now also check outcome: on a success row it means
"committed with a fallback".
Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>