Consolidated from PR #106543 (5 commits, final tree d2c4d908) by @Totoro-qaq.
publish_compression_child() fails closed on any non-automatic end stamp and
end_session() is first-stamp-wins, so a stale tui_close on a session the TUI
still routes turned every rotation into "compute the summary, then discard it"
(#106459). The host that still routes the session clears the stale explicit
close via SessionDB.reopen_if_explicitly_closed() before the turn starts;
publication never heals explicit closes. Review probes by @ehz0ah.
- `_target_matches_origin` docstring still claimed fan-out targets are
"deliberately NOT mirrored" and mirroring is origin-only; that has been
false since origin_fallback landed and is doubly so with the home lane.
Point to `_target_mirror_eligible` for the policy instead.
- cron.md DM-only bullet said "origin DM session"; the config comment in
the same change already says "target DM". Align.
- Remove two `_expand_routing_tokens` unit assertions bolted into the
eligibility test; the `" ALL , slack "` parametrize covers expansion
end-to-end through `_resolve_delivery_targets`.
A managed cron with `deliver: "slack"` (no captured origin) delivers to the
Slack home channel, but the brief was never mirrored into that session and
the in_channel seed never fired: bare-platform targets resolved with no
`_resolved_from` provenance, so `_target_mirror_eligible` treated them like
`all` broadcast expansions.
- `_resolve_delivery_targets` derives `from_broadcast` from the raw token
and passes it to `_resolve_single_delivery_target`; a user-written bare
platform token is tagged `_resolved_from: "home"`, `all` expansions stay
untagged (fan-out is never continuable).
- `_target_mirror_eligible`: `home` eligible under the same flags as
`origin_fallback` (per-job attach_to_session wins, else cron.mirror_delivery).
- `_MIRROR_PROVENANCE_RANK`: `home` == `origin_fallback` so token order
through dedup cannot strip eligibility.
- Docs, tool schema text, cron/gateway AGENTS.md updated; existing
exact-dict pins carry the new key.
Squashed from PR #101819 (commits 5fe612c9, 65e36926, 10e0af1d, f2cc4639):
they predate the cron/scheduler.py -> scheduler_delivery.py split and do
not cherry-pick individually onto current main.
Live pass showed the banner spanning list + detail and the save toast carrying a
second 'Restart gateway' button beside the banner's 'Restart now'. Render the
banner inside DetailColumn (content width, above the platform header) and let the
toast stay a plain confirmation. Also hide the redundant 'Messaging gateway
stopped' setup pill when the state pill already says exactly that.
Port the dashboard Channels flow into the Desktop Messaging page:
- Telegram "Quick setup": the Nous pairing service mints a bot; the page shows
the QR / deep link, polls until Telegram confirms, pre-fills the detected
owner id into the allowlist, and applies. The backend writes the token +
allowlist into the scoped profile and restarts the gateway; the page watches
the restart child's exit so a failed restart surfaces instead of a silent
"gateway stopped".
- Restart-needed banner: every credential save / clear / enable toggle keeps a
banner with "Restart now" up until a restart actually completes. Toasts
vanish; the credential still only loads on the next gateway start.
- runGatewayRestart now resolves a boolean (never rejected before, so the
Webhooks page's catch path was dead and its banner cleared on failure).
- New API helpers carry the page's profile scope on every pairing call so
start/status/apply hit the same backend that holds the pairing in memory.
- i18n keys in all six locales; `qrcode` added to the desktop deps (already
pinned identically in web/).
The linger hint explains a FAILED systemd unit restart. It ran unconditionally,
so on any Linux login session with linger off and no unit installed the
command printed the hint and exited 0 without stopping or starting anything.
The dashboard/Desktop "Restart gateway" action spawns exactly this command and
polls its exit code, so it reported success while the gateway never came back
with the newly saved credentials. Gate the hint on an installed systemd unit;
the no-unit path falls through to the detached restart as intended.
gateway_state.json preserves per-platform entries across restarts, so a gateway
that once ran without TELEGRAM_BOT_TOKEN and then stopped kept reporting
'fatal / No bot token configured' on the Channels/Messaging pages even after the
user saved a token — the Desktop showed 'Saved' on both fields next to the error.
Only a live gateway's verdict describes current config; when no gateway is running
the platform reads gateway_stopped with no error.
DeepSeek retired deepseek-v4-flash on 2026-09-10 (V4.1-Flash release); the API's
model name is now `deepseek-flash` and /v1/models lists only it. Hermes still
folded every non-V-series name onto deepseek-v4-flash, so `/model deepseek-flash`
on the DeepSeek provider was rewritten, then the validator "auto-corrected" it
back against the live listing: "Auto-corrected deepseek-v4-flash -> deepseek-flash"
on every switch.
Retired aliases (deepseek-chat / -reasoner and other fuzzy names) now fold onto
deepseek-flash; the curated catalog, profile fallback list, aux default, goal-judge
hint and pricing snapshot (2026-09-10 off-peak USD) follow the docs. Dated
deepseek-v4-* ids still pass through untouched.
Builds on YipTszkwan's #107126 (earliest fix in the cluster).
detect_provider_for_model() walked static catalogs and then the OpenRouter
catalog. Providers whose live catalog outruns the static list (Codex
early-access ids, Nous Portal slugs, Ollama Cloud) had nothing to stop the
ladder, so `/model gpt-6-astra` from a Codex session, or `/model glm-5.3-flash`
from a Nous-sub-only session, silently rebuilt the agent on metered OpenRouter
(or on a vendor the user has no key for).
The current provider's disk-cached live catalog (cached_provider_model_ids, 1h
TTL + SWR) is now consulted first: an exact or bare-name hit stays put and
resolves to the provider's own spelling. Fixes the whole class at the shared
choke point, covering /model, ACP, oneshot and the dashboard.
Refs #97487 (sj-unit72 diagnosed the ollama-cloud instance of this).
DeepSeek's 2026-09 Flash refresh introduced a version-less canonical id:
GET /v1/models now returns `deepseek-flash` (alongside `deepseek-v4-pro`), the
API accepts it directly, and the older `deepseek-v4-flash` is server-side
aliased onto it. Every DeepSeek model-id gate in Hermes keys off the
`deepseek-v<N>` prefix, so the new id silently missed all four:
* DeepSeekProfile.build_api_kwargs_extras classified it as non-thinking and
omitted `extra_body.thinking`. The server then defaults to thinking-on, so
the user's thinking toggle and `reasoning_effort` were quietly ignored.
* `_normalize_for_deepseek` folded it onto `deepseek-v4-flash` (it misses the
V-series regex), so the id a user picked never reached the wire and the
config stored a different model than the picker advertised.
* `DEFAULT_CONTEXT_LENGTHS` fell through to the 128K `deepseek` catch-all
instead of the real 1M window, capping the model at an eighth of its
context before compaction kicked in.
* `_REASONING_STALE_TIMEOUT_FLOORS` had no entry, leaving the stale-stream
detector at its 180s default instead of the 600s reasoning-model floor.
Verified live against api.deepseek.com: `deepseek-flash` answers 200 with
`model: deepseek-flash`, accepts image input (the refresh folds vision into
the Flash model), and the in-between id `deepseek-v4.1-flash` is rejected
with "The supported API model names are deepseek-flash, deepseek-v4-pro".
Adds the id to all four gates plus regression coverage for each site.
Plugins were split across two pages that each showed half the picture:
Settings → Plugins listed desktop plugins plus "Install from Git" and a
pointer saying agent plugins live elsewhere; Capabilities → Plugins listed
agent plugins plus the catalog picker but knew nothing about desktop
plugins. A user asking "what extends my Hermes and where do I add more?"
had to visit both and still could not see the whole set in one place.
Capabilities → Plugins is now THE plugins page:
- Agent plugins section (scoped to the profile selector) with the
"Install from Git" button in its header — installs target the scoped
profile, not whichever one is active.
- Desktop plugins section beneath it (same for every profile), with the
folder/rescan controls and the "agent half missing here" drift chip,
whose repair also lands in the SCOPED profile.
- The catalog picker underneath, unchanged.
Settings → Plugins is removed. `/settings?tab=plugins[&plugin=…]` and the
existing `?tab=mcp` redirect share one table (`settings/moved-tabs.ts`) so
old bookmarks and palette links land on the same row on the new page.
Command palette: plugins moved from the Settings group to the Capabilities
group; installed-plugin rows deep-link to `/skills?tab=plugins&plugin=…`.
Dead `settings.plugins.agent.*` and `settings.nav.plugins` i18n keys dropped;
docs and in-code pointers say Capabilities → Plugins.
The Windows hand-off script runs from the PRE-update checkout, spawned from
HERMES_HOME by the Desktop. e7eaff68 shipped the post-update verify step
without the cwd pin that landed a day later, so verify_windows_desktop_update(Path.cwd())
looked for apps/desktop/release under ~/.hermes and reported a healthy,
fully updated install as "The updated Desktop executable is missing" (exit 8,
error dialog, app relaunched fine).
Derive the root from the imported hermes_cli package instead; the ps1 no
longer passes a cwd. The cwd pin stays as belt for the other child steps.
Every GLM slug that missed a DEFAULT_CONTEXT_LENGTHS key fell to the "glm" 202,752 catch-all, so
compression fired at ~20% of the real window and the aux feasibility check auto-lowered the session
threshold to that number (the 202,752 in the Coatue compression report).
- Catalog: GLM entries from Nous + OpenRouter /v1/models (2026-09-09): 5.3 / 5.3-flash 1,310,720
(:batch/:US 1,048,576), 5.2 1,048,576, 5 / 5.1 / 4.7 / 4.6 204,800, *-turbo / 4.7-flash 202,752.
- _catalog_key_matches: version separators normalised on both sides so relay slugs like z-ai-glm-5-3
hit glm-5.3 (#97398; approach from PR #97412 by @shellybotmoyer, re-implemented on current main).
- get_custom_provider_context_length: entry-level custom_providers[].context_length backs every model the
entry serves when no per-model override exists, so /model switch stops dropping it (#98387; from
PR #98396 by @liuhao1024, re-implemented on the config_providers sibling).
- check_compression_model_feasibility: when aux compression is the main model on the main route, reuse the
main model's resolved window instead of re-resolving without its pin (#89500 mechanism 2, #45519).
Closes#97398, #98387, #89500, #87825, #97595, #97820 (suffix strip already on main; catalog values now match).
The Timezone field (and the voice/model ComboboxInput) pinned the popover to
--radix-popover-trigger-width. In the settings grid the trigger shrink-wraps to
its current value (~90-120px), so every IANA row was clipped to "Africa/A…" and
the picker was unusable by eye even though selection itself worked. Use min-w so
the list keeps its own width and only grows to cover a wider trigger.
Live-verified on a sandboxed Electron instance over CDP: popover 123px -> 288px,
rows no longer overflow; click, type+Enter and autosave to config.yaml all land.
The MCP-catalog noninteractive contract test asserts every subprocess spawned during a git
install carries GIT_TERMINAL_PROMPT=0 and a closed stdin; the gh token probe was spawned with
the inherited env. Use the same hardened env (plus GH_PROMPT_DISABLED) so gh cannot open a
browser/device flow either, and let the contract accept a stdin fed by input= (credential fill
writes its request and closes).
`hermes plugins install owner/private-repo` failed for every private repository
even when the same user could `git clone` it from their shell: the hardened
`noninteractive_git_env` disables credential helpers, askpass and global config
(so a hostile repo can't make our plumbing prompt or hang), which also blocks
the user's own stored credential. The result was "could not read Username" or a
60s hang on a GUI askpass, with no hint about how to authenticate.
New `hermes_cli/git_credentials.py` resolves a credential up front from sources
the user already owns — GITHUB_TOKEN/GH_TOKEN (profile-scoped), `gh auth token`,
then `git credential fill` against their configured helpers with prompting
disabled (any host) — and hands it to git as a one-shot
`http.<origin>/.extraheader` via the GIT_CONFIG_* env block. Nothing lands in
the URL, `.git/config` or install metadata. The same path covers
`plugins update`, catalog MCP git installs and profile-distribution staging,
which share the same hardened env and the same failure.
A private-repo clone with no credential now fails fast with an actionable hint.
The Electron composite project imported src/global.d.ts and followed its renderer-only dependencies. Share the notification payload from a dependency-free Electron module instead; all desktop typechecks and lint now pass.
Capture durable focus identity before runtime bindings disappear, retain native handlers, and keep runtime approval actions and plugin callbacks on their source renderer.
* fix(relay): accept a provision response that issues no secret
Lockstep with gateway-gateway #223 (finding F-004). The connector used to return
the stored per-gateway secret from POST /relay/provision on EVERY replay, to
anyone reaching the endpoint with the right gatewayId. It now returns credential
material only to a caller that proves recorded ownership or possession, and
answers secretIssued:false otherwise with secret/deliveryKey ABSENT. This
gateway treats that as a hard failure, so #223 cannot merge until this ships.
1. A secret-less response is a VALUE, not an error.
_post_provision() raised on any response without a secret. Two legitimate
responses hit that raise once #223 lands:
- every platform after the first in a multi-platform boot. All platforms share
ONE gatewayId, so the first POST mints the secret and the rest are replays of
an unchanged binding. self_provision_relay() catches RuntimeError per
platform and continues, so a Telegram+Discord gateway would front Telegram
and leave Discord unfronted with a warning.
- a re-provision by a caller that legitimately no longer holds the secret.
Refusing to hand it back IS F-004; /relay/rotate is the way back.
Only a malformed body raises now. No secret AND no secretIssued key is still the
old unexpected-response case (a pre-F-004 connector), so that keeps failing
loudly -- which is what makes this deployable in either order.
2. Only a response that CARRIES credentials may write them.
A separate bug that change 1 exposes rather than causes. The guard tested the
same env var it wrote: once a withheld response reaches it, it writes an empty
secret -- which then reads as "still unset" on the next pass, masking itself,
while GATEWAY_RELAY_ID and _DELIVERY_KEY have ALREADY been stamped from a
response carrying no credentials. Guard on the secret being present.
Tests: 5 invariants in tests/gateway/relay/test_provision_secret_optional.py.
Proven red on base -- reverting gateway/relay/__init__.py to origin/main turns 2
of the 5 red, one per fix, then green with the fix restored. My first draft of
the multi-platform test passed on base because it had the FIRST platform issue
the secret, which satisfies the old guard so it never re-enters and the bug
never fires; it proved nothing until the ordering was fixed and a direct
write-rule test added.
tests/gateway/relay: 303 passed, 0 failed. The 6 failures in the wider
tests/gateway run (systemd_notify, wecom_callback, shutdown_forensics,
scale_to_zero) reproduce identically on clean origin/main -- pre-existing.
* fix(relay): fail closed on the F-004 discriminator; a fully-withheld boot is not a provision
Two review findings on the parent commit, each with a red-on-parent test.
1. `_post_provision()` accepted ANY present `secretIssued` value when `secret`
was absent (`true`, `"false"`, `0`, …) and settled them as a successful
provision. The only credential-less shape the connector emits is literal
JSON `false`, so match the discriminator by value: `is not False` raises.
2. `self_provision_relay()` returned True and logged the INFO "self-provisioned"
line when every platform answered `secretIssued:false` — no credential in
hand, WS upgrade about to be refused, nothing in the boot log to say why.
Now: WARNING naming `/relay/rotate` / pin `GATEWAY_RELAY_SECRET`, return False.
tests/gateway/relay/: 308 passed, 0 failed.
scripts/run_tests.sh tests/gateway/ on a clean macOS checkout fails 7 tests
that pass on the Linux CI lane. The macOS CI lane runs only -m macos_only,
so these had never executed on a Mac. Each failure was diagnosed
individually, not assumed to share a cause:
* test_systemd_notify::test_notify_supports_systemd_abstract_socket
binds a NUL-prefixed abstract AF_UNIX name, a Linux kernel feature.
-> @pytest.mark.linux_only (the abstract socket IS the subject).
* test_shutdown_forensics::test_spawns_subprocess_and_writes_output
spawn_async_diagnostic() wraps a Linux-only script (ps --sort,
/proc/loadavg, dmesg, pstree) in GNU `timeout`, which macOS lacks.
Popen raises, the producer returns None by design (fail-soft).
-> @pytest.mark.linux_only, replacing a bare skipif(win32).
* test_scale_to_zero::test_suspend_self_{posts_suspend,non_2xx}
bound the fake flaps socket under pytest tmp_path, which exceeds
sun_path (104 B on macOS/BSD, 108 on Linux). Production connects to the
fixed /.fly/api so the code is not length-exposed; only the fake needed
a short home. -> short_sock_dir fixture (gettempdir -> /tmp fallback,
skip if neither fits). Verified with a 130-char TMPDIR on Linux.
* test_buzz_adapter::test_live_media_redacts_long_path_before_bounding
built 160-char path components; a single component is capped at 255 B
on both hosts and the full path hit PATH_MAX (1024) on macOS. The
invariant (path redacted BEFORE the 900-char bound) needs the path to
exceed 900 chars, not 1000+. -> grow 80-char components until the path
passes _MAX_CLI_MESSAGE_CHARS, assert < 1024. Mutation-checked: moving
the redaction after the bound fails the test.
* test_wecom_callback::{test_build_event_extracts_text_message,
test_poll_loop_dispatches_handle_message}
NOT environmental: `_build_event` parses with defusedxml, which ships
only via the `wecom` extra (or transitively via `youtube` -> [all] in
CI). A dev venv without it gets ET=None. -> dependency skipif on
DEFUSEDXML_AVAILABLE (not an OS marker; the 4 other tests in the file
still run).
Verified: scripts/run_tests.sh on macOS: 226 passed, 4 skipped (2
linux_only, 2 defusedxml). python:3.11-slim container with [dev,wecom]:
-m "linux_only and not integration" selects and passes both marked tests;
all 5 files 229 passed.
Desktop opens /auth/native/authorize without naming a provider. The empty-provider
auto-select filtered password providers out of the candidate set, so a deployment
with one OAuth provider plus username/password went straight to OAuth and never
offered the password option — even though native sign-in brokers password
providers through /login since 56f1afc834. The filter's rationale ("a password
provider can never be the target of the native flow", ed5e17f4b8) predates that
change.
Render a provider chooser when more than one interactive session provider is
registered. Each link re-enters the same validated authorize route with an
explicit provider, so the choice never leaves the native PKCE flow. A single
provider still auto-selects, and the chooser is emitted before any broker state
is allocated or any cookie set.
The desktop only shell-opens the authorize URL and never parses its response
(apps/desktop/electron/native-oauth-login.ts), so the 200 chooser page is safe on
existing desktop builds.
Supersedes #101713, #76941.
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
Co-authored-by: Phuong Lambert <vmphuongit@gmail.com>
* fix(desktop): filter expired Windows CAs and deduplicate trust roots
* test(desktop): verify CA expiry and deduplication with real certificates
* test(desktop): cover Linux alongside macOS CA no-op policy
Co-authored-by: Gabriel Stoltemberg <215755014+Stoltemberg@users.noreply.github.com>
* chore: map CA trust contributors for release attribution
* style(desktop): match CA filtering to lint rules
---------
Co-authored-by: wliu-dev <249166551+wliu-dev@users.noreply.github.com>
Co-authored-by: Gabriel Stoltemberg <215755014+Stoltemberg@users.noreply.github.com>
Use the existing opaque-surface contract on the search pill, which sits outside the raised card. Preserve the current theme tokens and palette handoff.
Co-authored-by: Moisés Valero <moisesvs84@gmail.com>
* fix(desktop): wait for session list before auto-draining queued prompts
Restored composer-queue entries were retrying as soon as the gateway
socket opened, burning the drain budget against reaped runtimes and
toasting "Queued message not sent" on every launch.
* test(desktop): cover composer-queue drain while sessions are still loading
* docs(desktop): clarify queue-drain discovery gates
* test(desktop): preserve hidden-session queues after failed drains
---------
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>