Follow-up to the salvaged #56724: the runtime's _generate_xai_tts reads
voice_id, language, speed, auto_speech_tags, optimize_streaming_latency,
sample_rate, and bit_rate — but never text_normalization, and the xAI
/v1/tts payload builder has no such field. Surfacing it in the desktop
GUI would be a dead knob, so remove it from DEFAULT_CONFIG, constants.ts
(labels/descriptions/SECTIONS), and the ja/zh/zh-hant locale catalogs.
The other six xAI keys are all verified against tools/tts_tool.py.
Consistent naming across the xAI TTS settings section. Speed and
sampleRate are shown only when xAI is the selected provider, so they
get the prefix too.
The Settings > Voice provider dropdowns (tts.provider / stt.provider) only offer
the built-in providers plus whatever value is currently set. Custom `type: command`
providers declared in config.yaml aren't selectable — and once you switch away from
one it drops off the list, so you can only return to it by hand-editing config.
enumOptionsFor now merges in the names of any `type: command` entries under the
tts/stt config sections, so local command-backed engines appear alongside the
built-ins and can be switched freely from the UI.
Enumeration mirrors the runtime's own resolution so the dropdown can only offer a
name the runtime would actually honour: the canonical `<section>.providers.<name>`
location plus the back-compat top-level `<section>.<name>` block, the optional
`type:` discriminator, and the built-in-name guard. The guard compares against the
runtime's built-in sets rather than the ENUM_OPTIONS display list, which is not a
substitute — it already omits `deepinfra` (TTS) and `deepinfra`/`local_command`
(STT), so a `providers.deepinfra` command block would otherwise be offered as
selectable while the runtime dispatches to the native backend instead.
- helpers.ts: add commandProviderNames() + the built-in guard; merge for
tts.provider + stt.provider
- helpers.test.ts: cover both sections, incl. that non-command config blocks
aren't offered and that built-ins absent from the display list are never
offered as command providers
Sibling-site fix for the flaw addressed in the global 'Configure all
platforms' flow: the per-platform checklist also returned to the menu
without opening provider setup when a selected toolset was already
enabled but lacked provider configuration. Adds a matching regression
test.
* fix(env): recognize export-prefixed .env lines in save/remove (#40041)
load_env() parses bash-compatible 'export KEY=value' lines (#6659), so a
hand-added 'export GITHUB_TOKEN=ghp_...' shows as set (green light) in the
desktop Tools & Keys page. But save_env_value/remove_env_value only matched
plain 'KEY=' lines:
- DELETE /api/env 404'd ('not found in .env') — the token could not be
removed through the UI
- PUT /api/env appended a SECOND line; a later delete removed the new line
while the export line silently resurrected the old value
Both writers now match assignments through a shared _env_line_defines_key()
helper that understands the export prefix. Commented-out lines are still
ignored.
Regression tests drive the real dashboard endpoint handlers against a temp
HERMES_HOME with runtime-constructed classic-PAT-shaped fixtures, covering
save-does-not-500, export-line remove, export-line replace-without-duplicate,
and the plain-line path staying intact.
Fixes#40041
* fix(credentials): unify provider key delete/update across .env, auth.json, config.yaml (#51071#59761#62269)
A provider API key can live in three stores at once: ~/.hermes/.env,
auth.json credential_pool (env-seeded 'env:<VAR>' entries persisted by the
pool loader), and config.yaml mirrors (model.api_key, auxiliary.*.api_key,
custom_providers[*].api_key). The desktop/dashboard endpoints and the TUI
gateway RPCs only ever mutated .env, so the stores diverged:
- #51071/#59761: DELETE /api/env removed the key from .env but left the
credential_pool entry (the loader is additive-only and never prunes),
so the provider kept appearing in the model picker — surviving restart
via the stale pool entry + provider_models_cache.json row.
- #62269: PUT /api/env rewrote .env but left the OLD key in config.yaml
(model.api_key wins over env at client construction), producing 401s
with a key the UI no longer showed.
New hermes_cli/credential_lifecycle.py is the single choke point:
- remove_provider_env_credential(): clears the .env entry, prunes
env:<VAR> pool entries across ALL providers (a shared var like
GITHUB_TOKEN can seed several), suppresses the env source so a lingering
shell export can't re-seed it (matching 'hermes auth remove' semantics),
drops the affected providers' model-cache rows, and scrubs value-matched
config.yaml api_key mirrors. Returns 'found' spanning every store so a
stale pool-only entry is cleanable through the same delete button.
- save_provider_env_credential(): writes .env, rotates any config.yaml
mirror that held the PREVIOUS value (value-matched — an unrelated inline
key is untouched), and lifts a prior env-source suppression so re-adding
behaves like 'hermes auth add'.
OAuth preservation: only entries with source == 'env:<VAR>' are pruned.
OAuth/device-code/manual/borrowed pool entries and providers.<id> OAuth
token blocks are never touched by a key-only delete. (model.disconnect in
the TUI gateway still clears OAuth via clear_provider_auth — that surface
is a full provider disconnect, which is the documented intent there.)
Rerouted call sites: PUT/DELETE /api/env (dashboard + desktop),
tui_gateway model.save_key / model.disconnect, save_env_value_secure
(TUI/gateway secret capture), and hermes config set/unset for env-shaped
keys.
E2E tests drive the real endpoint handlers against temp-HERMES_HOME
fixtures (.env + auth.json + config.yaml with runtime-constructed fake
keys) and assert cross-store consistency after delete/update, pool-reload
survival ('restart'), OAuth preservation, models-cache invalidation, and
the suppress/unsuppress round-trip.
Fixes#51071Fixes#59761Fixes#62269
* fix(desktop): compute truthful per-provider readiness for Capabilities tool config
Backend: GET /api/tools/toolsets/{name}/config now sends a per-provider
'status' field ('ready' | 'needs_keys' | 'needs_auth' | 'needs_setup')
computed by the new provider_readiness_status() in tools_config:
- env vars declared: all set -> ready, else needs_keys
- Nous-managed rows: Portal login + per-category tool-gateway entitlement
(MANAGED_FEATURE_COVERAGE_CATEGORY) -> ready, else needs_auth
- post_setup 'xai_grok' rows: Grok OAuth or XAI_API_KEY -> ready, else
needs_auth
- other keyless post_setup rows: installed-state predicate
(_POST_SETUP_READY: kittentts/piper/ddgs/langfuse via find_spec,
agent_browser via _has_agent_browser, cua_driver via PATH probe);
unknown hooks fall back to is_active as the setup-completed signal
- genuinely-free keyless rows (Edge TTS) stay ready
Existing fields are untouched; 'status' is additive so older desktops
keep working.
* fix(desktop): render server readiness status in Capabilities provider pills
The panel's providerConfigured() heuristic pilled every zero-env-var
provider 'Ready' — including logged-out Nous Subscription rows, xAI TTS
without Grok OAuth, and never-installed KittenTTS/Piper. Render the
backend's per-provider 'status' instead:
- ready -> existing 'Ready' pill
- needs_auth -> warn pill 'Needs sign-in'
- needs_setup -> warn pill 'Needs setup'
- needs_keys -> no pill (env-var fields are the signal)
Keyed rows keep deriving ready/needs_keys from local envState so saving
or clearing a key updates the pill without a refetch. Older backends
without 'status' fall back to the legacy env-var heuristic (narrow
compat path, desktop/runtime update on separate clocks).
Adds the warn tone to the settings Pill primitive and needsSignIn /
needsSetup strings to all locale catalogs (en, zh, zh-hant, ja).
The per-message ephemeral context prompt re-renders every turn, and
any byte change (Discord auto-thread rename, reset notes, voice
channel state) both breaks the provider prompt-cache prefix at the
head of every request and changes the gateway agent-cache signature,
forcing a full agent rebuild per message. Pin the rendered block per
session keyed by a hash of exactly the fields it renders, so only a
real input change (rename, topic edit, /sethome, redact_pii flip)
re-renders; deliver one-shot per-turn facts (auto-reset note,
first-contact intro, voice-channel changes) on the current user
message via the api_content sidecar instead of the system prompt; sort
get_connected_platforms for byte-stable ordering.
_manage_thinking_signatures treated every Kimi-family endpoint with the
#13848-era contract: strip signed Anthropic thinking blocks from replayed
history, assuming the upstream cannot validate Anthropic signatures.
Live probing shows that contract is outdated for the whole Kimi family:
- Kimi For Coding (api.kimi.com/coding) issues AND validates its own
thinking signatures (K3+): both verbatim and content-mutated signed
blocks replay with HTTP 200;
- Moonshot's Anthropic surface (api.moonshot.cn/anthropic) accepts signed
blocks the same way (200 on both verbatim and mutated);
- every other harness that replays signed blocks to KFC (Claude Code, pi,
Kilo Code) round-trips fine.
Stripping signed blocks there silently discarded the model's prior
chain-of-thought in multi-turn conversations — e.g. a two-turn recall
probe loses the reasoning between turns while the text answer survives
(agent.log: turn-2 input ≈ turn-1 input + a few dozen tokens instead of
+thinking). With this change, the same probe recalls the exact hidden
values from turn-1 thinking (+230 tokens on turn 2).
So: on _is_kimi_family_endpoint, keep signed and unsigned thinking blocks
unchanged on replay — one uniform rule for the whole Kimi family, no
/coding-vs-Moonshot split. DeepSeek keeps the #16748 contract (strip
signed, preserve unsigned). Third-party and direct-Anthropic behavior is
untouched.
Add tests/agent/test_anthropic_kimi_signed_thinking_replay.py pinning the
unified behavior (Kimi /coding + Moonshot keep signed and unsigned) and
the unchanged neighbors (DeepSeek strips, direct Anthropic keeps).
Two residual gaps in the #67369 salvage of #67214, found during review:
1. No timeout on the download client. Since #67369, mutable branch pins
hit the network on EVERY run, and the stale-cache fallback only fires
when download() returns Err. A black-holed connection (captive portal,
hung proxy, dropped packets) never errors, so the whole bootstrap hung
at resolve() instead of falling back to the cached script. Verified
live: a request to a non-routable address now errors at the 10s connect
timeout instead of hanging indefinitely.
2. The UTF-8 BOM was only written inside download(). Immutable commit-pin
caches take CachePlan::Reuse and are served untouched forever, and the
stale-fallback path also re-serves the old file - so a BOM-less .ps1
cached by a pre-fix installer kept reproducing the #67193 ANSI-codepage
parse failure on every retry (production builds pin BUILD_PIN_COMMIT,
so this is exactly the retry population). upgrade_cached_script() now
BOM-upgrades legacy .ps1 caches in place (atomic tmp+rename,
best-effort, idempotent) on both reuse paths; .sh untouched.
Follow-up to the cherry-picked fix: the same UnicodeEncodeError bind
failure was live at sibling sites the PR didn't cover —
- api_content sidecar (append_message, _insert_message_rows,
set_latest_user_api_content): bound raw; a surrogate in the composed
api_content aborted the whole row/UPDATE. Scrubbing is wire-accurate:
the conversation loop already scrubs every outgoing payload, so the
scrubbed form IS what was sent.
- tool_name (both INSERT sites): raw bind.
- sanitize_title: session titles from LLM title generation or /title
could carry surrogates; scrub before validation.
E2E-verified each site raised UnicodeEncodeError on main and persists
after this commit. Tests added for all five paths.
sqlite3 encodes bound str parameters as UTF-8 and raises
UnicodeEncodeError on lone surrogates (U+D800..U+DFFF), but
SessionDB._encode_content returned str content untouched. One such code
point anywhere in a message therefore aborted the entire message write.
The path is reachable with ordinary input — the same scraped web/social
text that crashed the guardrail hasher in fb0217c65:
1. a tool result carrying a lone surrogate is appended to the canonical
`messages` history unsanitized;
2. the proactive sanitizer only cleans the `api_messages` *copy*
(conversation_loop.py), so the API call succeeds;
3. because the API never raises, the UnicodeEncodeError recovery
sanitizer (guarded by `isinstance(api_error, UnicodeEncodeError)`)
never runs and the history keeps the surrogate;
4. the DB flush hits it and run_agent swallows the failure with
`logger.warning("Session DB append_message failed")`.
Because replace_messages re-sends the whole history every turn, the
poisoned row stays and every later save raises too: the session freezes
at its last good state while the live conversation grows, and everything
after that point is gone on resume. Observed: persisted rows stuck at 2
while history reached 12, with only a warning in the log.
Scrub at the DB write boundary with the canonical _sanitize_surrogates
(surrogate -> U+FFFD): in _encode_content, which both INSERT sites share,
and on the raw-bound reasoning / reasoning_content columns. The JSON
branch already defaults to ensure_ascii=True and was safe. Well-formed
text — accents, CJK, emoji — round-trips byte-identically, matching
_encode_content's stated intent that persistence never fails.
Adds regression tests for content, reasoning, the multi-turn freeze, and
benign-Unicode passthrough.
The salvaged helper cleared its line buffer on entry. Inside run_script's
tokio::select! loop, a stdout line arrival cancels the in-flight stderr
read (and vice versa); read_until had already consumed bytes into the
buffer, and the next call's clear() silently dropped that partial line.
Keep partially-read bytes across cancellation (clear only after a full
line is decoded) and emit an unterminated final line at EOF instead of
swallowing it. Adds a cancellation regression test (fails against the
clear-on-entry version) and an EOF-tail test.
Locks the #67193 invariants: localized PowerShell error bytes survive
decode_console_bytes / read_decoded_line (including CP1252-only 0x91/0x92
punctuation), cached .ps1 files get a single UTF-8 BOM for Windows
PowerShell 5.1 -File, .sh stays BOM-less, and mutable branch caches plan
a refresh with stale-cache fallback.
Windows PowerShell 5.1 emits ParserError text in the console ANSI code page,
but the GUI bootstrap aborted BufReader::lines() on the first non-UTF-8 byte
and Retry kept reusing a poisoned install-main.ps1 for branch pins. Decode
child output with a real Windows-1252 fallback, write a UTF-8 BOM on cached
.ps1 files for -File, and refresh mutable branch/tag caches on each run
(immutable SHAs stay cached).
Fixes#67193
Background-review forks share the live parent's session_id for prompt-cache
warmth but are _persist_disabled — they can never write to the transcript.
Without this, every review fork on an active session would (a) trip a false
cross-agent overlap warning against the parent's real turn, sending the
#64934 route investigation the wrong way, and (b) pop the parent's in-flight
slot at its own persist, making a real overlap right after a review go
unreported. Both legs now skip persist-disabled agents symmetrically.
+2 tests.
note_turn_start kept its in-flight marker on the agent object, but the
gateway caches agents per routing key (_agent_cache) while transcripts
are owned by session_id — and switch_session (/resume from a second
surface, CLI-continuity rebinding, async-delegation pinning,
topic-binding tip-walks) maps multiple routing keys onto one session_id
without any cross-key check. Two keys mapped to one session run
concurrent turns on two different agent objects, so the per-agent
tripwire could never fire for exactly the dispatch route #64934 is
waiting to identify.
Add a module-level session_id-keyed in-flight registry alongside the
per-agent marker. Same philosophy as the original tripwire: log-only,
takes ownership on overlap, under-reports rather than double-reports
(a same-agent overlap warns once, not twice). The persist-time clear
pops the session id stamped at turn start, so a mid-turn compression
rotation of agent.session_id cannot strand the slot.
Follow-ups on the salvaged unknown-root-key warning from PR #67345:
- Add image_gen, video_gen, plugins, smart_model_routing, platform_toolsets,
session_reset, multiplex_profiles, profile_routes, platforms,
require_mention, unauthorized_dm_behavior, and signal to
_EXTRA_KNOWN_ROOT_KEYS — all are read from the raw user YAML (gateway,
registries, plugin CLI) or written by our own setup wizard, but absent
from DEFAULT_CONFIG. Without this, doctor would warn on configs Hermes
itself wrote.
- Convert tests/hermes_cli/test_config_validation.py back to LF line
endings (the PR's rewrite introduced CRLF).
Extracted from the config-validation portion of PR #67345 (the token-cost
half was not salvaged). Unknown top-level config keys now warn (naming the
key) instead of being silently ignored; known roots derive from
DEFAULT_CONFIG.keys() plus a small extras set for valid-on-disk roots
absent from defaults.
Two config-hygiene improvements (warning-only, non-blocking):
1) Unknown top-level config keys now surface a warning naming the key (known roots derived from DEFAULT_CONFIG.keys() as single source of truth) so typos like 'skillz:'/'secrity:' are no longer silently ignored. Provider.* unknown-key behavior preserved.
2) hermes doctor reports deprecated/legacy config keys (display.tool_progress_overrides, delegation.max_async_children, compression.summary_*) and legacy env vars (HERMES_TOOL_PROGRESS*, TERMINAL_CWD, QQ_HOME_CHANNEL*) with their modern replacements, as non-failing warnings. No auto-delete/migrate.
Tests: config_validation + doctor suites green (100 passed).
The ineffective-compression counter is not durable; when it is the sole
reason the gate is blocked there is nothing in the DB that could unblock
it, so re-reading the guard rows on every gate check for the rest of the
session is pure waste. Guard test pins the no-DB-touch behavior.
Follow-up on the salvaged #64511 commit:
- _automatic_compression_blocked() now refreshes durable guard state
(cooldown + fallback streak) when — and only when — the in-memory
snapshot says blocked, then re-evaluates. The should_compress()
pre-gates (preflight/turn paths) consult this before ever reaching
compress_context, and a stale fallback streak has no expiry timer, so
without a gate-level refresh a cleared durable row could never unblock
a prebound agent. The unblocked hot path pays no DB reads.
- A refresh that finds no durable cooldown row no longer clears a live
local cooldown whose DB persist FAILED (_cooldown_persist_failed):
an empty row is not evidence another agent cleared it, and honouring
it would reopen the #11529 thrash window. A successful durable
round-trip (record or read) makes the DB authoritative again.
- Guard tests for both directions (red on the pre-fix code), including
a hot-path test asserting the unblocked gate never touches the DB.
A final response generated but not confirmed-delivered was the one
artifact the gateway could lose without a trace: crash or planned
restart between finalize and platform ACK dropped it silently, and the
resume path re-ran the whole turn at full cost (#58818 P1, #41696,
#63695's gateway half).
gateway/delivery_ledger.py records each outbound final response in
state.db (same conventions as the async-delegation ledger: WAL, owner
pid + process-start-time liveness, bounded retention):
pending -> attempting -> delivered | failed
startup sweep on dead-owner rows -> redeliver | abandoned
Contract (the lessons from the closed delivery-outbox attempt #61790):
- obligation recorded BEFORE the first send attempt; cleared only on
SendResult.success (destination acceptance, #51184)
- ambiguity is labeled, never silently retried: rows that were mid-send
when the process died redeliver with a visible '♻️ Recovered reply —
may be a duplicate' prefix (honest at-least-once)
- stable ids from session_key + inbound message id + content, so
distinct threads/topics can never collide
- poison rows bounded: 3 attempts / 24h freshness -> abandoned; claim
atomically re-stamps ownership so racing sweeps can't double-claim
- redelivery clears resume_pending for the session so the resume path
never re-runs a turn whose answer the ledger already holds
- best-effort everywhere: ledger failure can never block or delay a send
- slash-command/ephemeral/empty responses are not recorded; cron and
proactive delivery stay on DeliveryRouter (separate subsystem)
Config: gateway.delivery_ledger (default on; no version bump needed).
Validation: 30 ledger+producer tests; 352 blast-radius gateway tests
green; cross-process E2E (record in process A, kill it mid-send, claim
+ marker + redeliver in a fresh process B against the same state.db).
Wire the scoped translator into the plugin context alongside storage/rest/
socket, and export usePluginI18n + the bundle types from @hermes/plugin-sdk.
The runtime shim re-exports the barrel automatically, so disk/third-party
plugins get the same door as bundled ones.
A plugin ships its own locale bundles and registers them under its id — never
editing core en.ts — exactly like ctx.storage namespaces persistence. The
registry merges repeated registrations and drops a plugin's bundles on
dispose (tracked by the loader like register/socket), resolving through the
shared translator: active locale -> the plugin's own en -> the raw key.
Two consumers, symmetric with core: usePluginI18n(id) for React UI
(re-renders on a locale switch or a late registration) and a module-level t
for handlers/stores.
Collapse the fallback walk out of `translateNow` into a single `translateFrom`
that takes a per-locale message source, so any translator (core catalog or a
plugin's bundles) reuses the exact dot-path walk, interpolation, and
active-locale/English/raw-key chain. Adds `getRuntimeI18nLocale`.
Add an end-to-end developer guide for extending the native Hermes Desktop
app introduced in #60638: the HermesPlugin contract, PluginContext, every
contribution area (panes, routes, sidebar nav, status/title bar, palette,
keybinds, themes, composer, mount-scoped Contribute), the host API, the
React Query + nanostores data layer, the UI kit + theme variables, the
scoped ctx.rest/ctx.socket backend (plugin_api.py under /api/plugins/<id>)
and its separate enable gate, Settings/defaultEnabled/storage, bundled
plugins, the security model, pitfalls, and a full reference.
- Register the page in the sidebar under Extending -> Plugins.
- Disambiguate from the unrelated web-dashboard plugin SDK from both
directions, and add a map-table row + a desktop user-guide pointer.
- Fill the gaps in the agent-facing hermes-desktop-plugins skill
(ctx.rest/socket + backend, React Query, defaultEnabled, Contribute)
and point it at the new reference so agents know the SDK when writing
addons.
In the Focus layout `files` shares one tab group with `workspace`, so when the
first reply adopts a cwd the reactive files unhide fronted files and yanked the
active tab off the new session (~1s after the reply). #65375 fixed the sibling
"reactive unhide reopens a collapsed side" bug but frontPaneInGroup still stole
the active slot unconditionally.
Only take the active slot when the group's current active pane isn't itself
showable, so a reactive unhide can't steal focus from a pane the user is
viewing while still fronting a valid tab when nothing is shown.
Carries forward linfeng961's diagnosis + fix from #66870, reworked onto the
frontPaneInGroup path introduced by #65375.
Co-authored-by: linfeng961 <133505766+linfeng961@users.noreply.github.com>
* fix(desktop): don't auto-expand user-collapsed side on reactive unhide
When `setTreePaneHidden(paneId, false)` was called for a workspace-gated
pane like `files` (bound to $hasWorkspace), it auto-called
`revealTreePane(paneId)` — which expanded the parent column even when
the user had explicitly collapsed it via Cmd+J.
That meant every session create / resume — anything that flipped
$currentCwd from empty to non-empty — silently re-opened the right
sidebar and persisted that open state to localStorage, so the original
session also showed the sidebar as open on return.
`setTreePaneHidden` is a state primitive; user-intent semantics (open
the side, front the tab) belong to `revealTreePane`. Drop the auto-call
and replace it with a narrower `frontPaneInGroup` helper that only
makes the pane the active tab in its group — visible the next time the
column is opened, without forcing it open now.
* fix(desktop): preserve explicit review reveal
---------
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
A manual composer pick stays sticky across new chats (intended), but if that
model later disappears from the provider/config, every new chat kept trying the
dead model and 404'd. Fall back to the profile default in that one case only.
refreshCurrentModel now consults the model-options cache the composer already
populates (no extra fetch): a manual pick is preserved unless manualPickRemoved()
proves it's gone — provider present, non-empty catalog, model absent. An
unknown/absent provider, an empty (re-auth/unconfigured) list, or a not-yet-loaded
catalog all preserve the pick, so a still-valid selection is never clobbered.