Commit graph

2089 commits

Author SHA1 Message Date
liuhao1024
07f39cf9a6 fix(models): add Claude Sonnet 5 to curated model lists
Add claude-sonnet-5 to the static curated lists for Anthropic, OpenRouter,
Nous Portal, Copilot, GMI, OpenCode Zen, and AWS Bedrock so the model
appears in hermes model / /model picker discovery.

Fixes #55846
2026-07-20 02:25:44 -07:00
Ariel Bravy
e2561466c7 feat(models): add Claude Sonnet 5 support 2026-07-20 02:25:44 -07:00
Teknium
9bda6438d4
fix(config): remove unknown-top-level-key warning — top-level keys bridge to env (#67924)
The 'Unknown top-level config key' warning (f5bacee27) assumed a
closed-world allowlist of valid roots, but top-level scalars in
config.yaml are deliberately bridged into os.environ (gateway/run.py,
hermes send) so skills and external apps Hermes drives can read
arbitrary env-style keys (DISCORD_HOME_CHANNEL, MY_APP_TOKEN, ...).
An allowlist can never enumerate those — two widening follow-ups
(7c2ece53c, 3c7217706) already proved the whack-a-mole. Drop the
generic warning entirely; keep the targeted provider-like-field
misplacement hint (base_url/api_key at root).
2026-07-20 02:25:33 -07:00
Craig French
1c3a48965b fix(model-switch): keep same-endpoint custom providers with different names as separate picker rows 2026-07-20 02:22:37 -07:00
Alex López
7ed18dae90 test(model): isolate custom-provider grouping tests from live discovery 2026-07-20 02:22:26 -07:00
YAMAGUCHI Seiji
8590c2d0d9 feat(cli): make reasoning effort session-scoped 2026-07-20 02:22:22 -07:00
Teknium
8b6fde3a35 fix(model): default /model switches to session scope everywhere
Flip the resolve_persist_behavior() fallback from persist-to-config to
session-only. A plain /model <name> (typed or via any picker — CLI,
TUI/Desktop, gateway) now affects only the current session; --global
persists explicitly, and model.persist_switch_by_default: true restores
the old opt-out behavior for users who want switches to stick.

This is the root cause behind the recurring 'session switch applied
globally' bug class (#61458, #63083, #58290, #61190): every surface
funnels its no-flag default through this one function, so per-surface
patches kept missing paths. Fixing the default fixes all surfaces at
once: CLI typed + picker, TUI/Desktop config.set + slash, gateway typed
+ inline picker.

Builds on liuhao1024's #58371 (--provider session scoping, cherry-picked
as the previous commit) and supersedes the per-surface #61488.
2026-07-20 02:22:22 -07:00
liuhao1024
0d6d73525d fix(model): default --provider switches to session-only persistence
When /model is called with --provider but without --global or --session,
the switch now defaults to session-only instead of persisting to
config.yaml. Provider switches are typically exploratory — the user is
trying a different backend for this conversation, not reconfiguring the
default. --global can still force persist when desired.

This addresses a regression from fad4b40d9 where /model switched to
persist-by-default, causing /model xxx --provider xxx to overwrite the
global config when the user only intended a temporary switch.

Fixes #58290
2026-07-20 02:22:22 -07:00
Teknium
463c2ae255 fix: section-3 grouping follow-ups for salvaged PR #36998
- extra_headers participates in the section-3 group identity (mirrors
  section 4 — header-routed tenants behind one proxy URL stay distinct)
- model declarations go through _declared_model_ids() so
  models: [{id: ...}] rows keep working
- gateway model-switch handler moved to gateway/slash_commands.py since
  the PR branched — re-applied the display-form edits there (both the
  legacy picker closure and the current typed path)
- regression tests: same-endpoint fold, api_mode separation,
  header-routed separation, list-of-dict models, RID display stripping
2026-07-20 00:41:21 -07:00
kshitijk4poor
244dabbd9c test(cli): mock _cleanup_oneshot_runtime in all _run_and_exit tests
Phase 2c found 3 tests that called _run_and_exit_oneshot without
mocking _cleanup_oneshot_runtime, causing real cleanup (terminal,
browser, MCP, auxiliary) to run in the pytest worker. Add the mock
to all three for test isolation.

Also remove redundant 'import logging' inside _exit_after_oneshot
(already imported at module level, line 729).
2026-07-20 12:59:28 +05:30
kshitijk4poor
97fc8a4a3c refactor(cli): apply /simplify-code findings to oneshot teardown
- Add idempotency guard (_oneshot_cleanup_done) to _cleanup_oneshot_runtime,
  matching cli.py:_run_cleanup's pattern
- Trim _exit_after_oneshot docstring from 22 to 8 lines (per-resource
  ownership enumeration already documented in _run_agent)
- Add comment clarifying cleanup ordering mirrors gateway/run.py, not
  cli.py (oneshot has no _active_agent_ref)
- Clarify session_db.close() comment: agent.close() calls end_session()
  but leaves the connection open

Findings skipped (follow-up scope):
- Extract shared 5-step cleanup helper from cli.py:_run_cleanup (widens
  scope into critical file)
- Extract _hard_exit helper (3 copies across cli.py)
- Extract shutdown_agent_resources helper (touches run_agent.py + cli.py
  + gateway/run.py)
- Test boilerplate dedup (test-only, non-blocking)
2026-07-20 12:59:28 +05:30
kshitijk4poor
2de60a3a7e fix(cli): expand oneshot cleanup to cover all process-global resources
The initial salvage from #43698 only shut down MCP servers and cached
auxiliary clients. The interactive CLI's _run_cleanup() also closes
terminal environments, browser sessions, and interrupts async
delegations — all of which can hold native-extension-backed resources
(aiohttp connectors, websocket clients) that SIGABRT during
Py_FinalizeEx.

Add the missing three sites to _cleanup_oneshot_runtime(), matching the
order in cli.py:_run_cleanup(). Update tests to cover the expanded
cleanup chain.

Credit: @konsisumer (#67768) identified the full cleanup surface.
2026-07-20 12:59:28 +05:30
harjoth
54eea80bf7 test(cli): cover termux oneshot usage file 2026-07-20 12:59:28 +05:30
harjoth
fbfe89871b fix(cli): guarantee hard exit after cleanup interruption 2026-07-20 12:59:28 +05:30
harjoth
b82ffdaa4d test(cli): preserve oneshot usage file through hard exit 2026-07-20 12:59:28 +05:30
harjoth
bfa7a794cb fix(cli): avoid one-shot SIGABRT during teardown 2026-07-20 12:59:28 +05:30
kshitij
2ae195673e fix: widen metadata-preserve guard to list-of-dicts models form
The dict-form guard from PR #67878 only covered the mapping shape
({model: {context_length: ...}}). The list-of-dicts shape
([{id: model, context_length: ...}]) is also a supported config form
(per _declared_model_ids) and was still being replaced with a flat
list of strings, destroying per-model metadata.

Sibling site for #67841.
2026-07-20 12:13:37 +05:30
kyssta-exe 25470058+kyssta-exe@users.noreply.github.com
311bacb572 fix(model-switch): preserve per-model metadata dict in _save_discovered_models_to_config (#67841)
When custom_providers[].models uses the mapping form to store
per-model metadata (e.g. context_length), _save_discovered_models_to_config
must not replace it with a flat list of strings.  Add a guard that skips
entries whose models value is a dict, preserving the user's curated
metadata.

The regression was introduced by PR #65652, which added the auto-save
helper without considering the dict form.
2026-07-20 12:13:37 +05:30
Floze
cd0219da86 fix(gateway): stop slow restart redelivery loops 2026-07-20 12:11:07 +05:30
kshitijk4poor
3c72177061 fix(config): widen doctor allowlist to all gateway-bridged top-level keys
Salvage of PR #67447 — the original PR fixed 3 of 7 missing keys.
gateway/config.py reads 4 more top-level keys (stt_echo_transcripts,
reset_triggers, always_log_local, filter_silence_narration) that
produced the same false 'Unknown top-level config key' warning.
Add all 4 and extend the regression test to cover them.
2026-07-20 11:53:10 +05:30
HexLab98
54157da9ee test(config): cover doctor allowlist for Hermes-written root keys
Regression for known_plugin_toolsets / group_sessions_per_user /
thread_sessions_per_user so validate_config_structure no longer
false-positives on keys Hermes owns.
2026-07-20 11:53:10 +05:30
Austin Pickett
3d97893571
feat(desktop): custom endpoint settings (supersedes #42745) (#67759)
* feat(desktop): add custom endpoint settings (supersedes #42745)

Salvages PR #42745 (elashera:custom-endpoints-desktop), which could no
longer merge cleanly against main. Re-integrated the work onto current
main and reconciled the conflicts:

- Settings nav: wired the new 'Custom Endpoints' provider sub-view into
  main's data-driven navGroups/OverlayNav layout (PR predated that
  refactor) and added it to PROVIDER_VIEWS.
- providers-settings: kept BOTH main's LocalEndpointRow affordance and
  the PR's fuller CRUD panel; unified ProvidersSettingsProps to carry
  onClose + onConfigSaved + onMainModelChanged.
- web_server: kept main's _normalize_main_model_assignment + api_key
  propagation AND the PR's provider base_url lookup in
  _apply_model_assignment_sync.
- model_switch: dropped the PR's bare direct-custom-config picker block;
  main already implements it (source='model-config', with live model
  discovery). Updated the salvaged test to assert main's behavior.
- Merged additive import/type blocks in hermes.ts and types/hermes.ts.

Backend endpoints, i18n labels (en/ja/zh/zh-hant), and the
custom-endpoints-settings.tsx panel carried over. 28 custom-endpoint
tests pass.

Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>

* chore(contributors): map elashera's commit email

Salvage of #42745 (superseded by #67759) preserves @elashera's
authorship, whose corporate commit email had no contributor mapping.
Adds contributors/emails/ mapping so check-attribution passes.
Verified: GitHub user 'elashera' id=135239963 matches their own
noreply commit email (135239963+elashera@users.noreply.github.com).

---------

Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>
2026-07-19 19:59:46 -04:00
ajzrva-sys
54459e76ed
fix: speed up CLI /model picker by skipping non-current custom provider probing (#65652)
* fix: speed up CLI /model picker by skipping non-current custom provider probing

The CLI /model picker calls build_models_payload() with default
probe_custom_providers=True, which live-fetches /v1/models from every
saved custom endpoint on every open. The GUI/desktop picker already
passes probe_custom_providers=False for snappiness.

Match the GUI behavior: skip probing non-current custom providers, but
still probe the current one so its model list stays accurate. Users can
force a full re-fetch with /model --refresh.

Fixes #65650
Related: #63583

* fix(cli): forward force_refresh to model picker probe flags

When /model --refresh is used, the CLI model picker must probe all
custom providers to refresh their model lists — not skip them.
Normal bare /model still skips non-current probes for speed.

Mirrors the existing desktop/TUI behavior. Add regression test for
both normal and refresh flag forwarding.

Fixes #65650

* fix: auto-save discovered models to config for discover-once caching

After a successful /v1/models probe, persist the discovered model list
back to config.yaml under the matching custom_providers entry. This
makes discover_models: false meaningful out of the box — users get a
populated cache after the first probe instead of a stale 1-model list.

- Add _save_discovered_models_to_config() helper
- Call after successful fetch_api_models in section 4 probe path
- Skip config write when model list hasn't changed
- Idempotent — no-op on empty api_url or model_ids

Tests: 4 new tests covering auto-save, empty-probe skip, unchanged
skip, and no-op-on-empty-args. All 4 pass.

Refs: #65652, #65650

---------

Co-authored-by: ajzrva-sys <302567740+ajzrva-sys@users.noreply.github.com>
2026-07-19 19:33:49 -04:00
teknium1
786df3ca6c fix(cron): resolve provider with the job's effective model; default dashboard cron creates to the backend's own profile
Two follow-ups to the per-job model pin surface (#67472 / #49948 review):

- cron/scheduler.py: pass target_model=<effective job model> to
  resolve_runtime_provider() on the primary path, so providers with
  model-specific api_mode routing derive the mode from the model the job
  actually runs (per-job pin > env > config default) instead of the stale
  persisted default. The auth-fallback path already did this for its
  fb_model.

- hermes_cli/web_server.py: POST /api/cron/jobs (and its sync worker) no
  longer hardcodes profile="default" when the request carries no profile
  param. A pool backend scoped to a named profile now resolves its own
  profile via get_active_profile_name(), so pre-profileScoped desktop
  clients can't write a named profile's job into ~/.hermes. Unscoped /
  custom HERMES_HOME keeps the legacy default fallback.

Tests: target_model capture test on run_job; two profile-default tests on
the create endpoint.
2026-07-19 09:57:21 -07:00
Teknium
2ae0d67f63
feat(desktop): five Capabilities-tab UX fixes from live testing — hints, vision link, web split, key deep-links (#67482)
* fix(desktop): stop contradicting the Ready pill with the one-time-install hint

When a provider's server-computed status is 'ready' (post_setup install
verifiably satisfied, e.g. cua-driver on PATH), the PostSetupRunner row
still said 'This backend needs a one-time install (…)'. Swap the copy for
a muted installed-confirmation one-liner and keep the Run setup button for
repair re-runs. Gated purely on the provider status prop so it composes
with the server-driven resting state work in the sibling lane.

* feat(tools): surface the web search/extract capability split in the Capabilities UI

The runtime has dispatched web_search and web_extract to independently
configurable backends for a long time (web.search_backend /
web.extract_backend overrides with web.backend as the shared fallback),
but the Capabilities tab still presented one monolithic 'Web Search &
Extract' choice that only wrote web.backend.

Backend:
- GET /api/tools/toolsets/web/config now returns active_search_backend /
  active_extract_backend resolved via the REAL runtime getters
  (tools.web_tools._get_search_backend/_get_extract_backend), plus each
  provider row's web_backend key and supported capabilities (from the
  registry's supports_search/supports_extract flags).
- PUT /api/tools/toolsets/web/provider accepts an optional capability
  ('search'|'extract') that writes web.<capability>_backend without
  touching web.backend; validates the provider actually supports the
  requested capability (ddgs/brave-free are search-only). Omitted →
  unchanged legacy apply_provider_selection path.
- New tools_config.web_provider_capabilities() helper reads the plugin
  registry's capability flags.

Frontend: 'Search: <backend>' / 'Extract: <backend>' pills above the web
provider matrix, per-row 'Search backend'/'Extract backend' assignment
pills, and 'Use for Search'/'Use for Extract' actions gated on each
backend's declared capabilities.

Tests: endpoint tests assert the runtime getters resolve to the written
backend (searxng for search, firecrawl for extract) after the endpoint
write; vitest covers badges, capability-gated buttons, and non-web
toolsets staying untouched.

* feat(desktop): deep-link Capabilities key rows to Settings → API Keys

Set env-var rows in the toolset config panel now offer 'Manage in API
Keys' in the row actions menu — an internal route change to
/settings?tab=keys&key=<ENV_KEY>. KeysSettings consumes the ?key= param
via the shared useDeepLinkHighlight hook (same mechanism as the command
palette's ?field= config deep links and ?session= archived-session
links): scrolls the credential card into view, flashes it, and expands
it. Applies generically to every env-var row, and only when the key is
set (unset keys are managed inline via Set). i18n in en/zh/zh-hant/ja.

* feat(desktop): point the vision Capabilities detail at Settings → Models

The vision toolset has no TOOL_CATEGORIES provider matrix — its
provider/model resolution runs through the auxiliary model config
(agent/auxiliary_client.py), so the Capabilities detail pane looked
empty with no hint of where the model choice lives.

Add a short explainer + an internal deep link
(/settings?tab=config:model&aux=vision) rendered only for
toolset.name === 'vision'. ModelSettings consumes the ?aux= param via
the shared useDeepLinkHighlight hook and scrolls/flashes the matching
auxiliary task row (rows now carry aux-task-<key> anchor ids). No
external URLs. i18n in en/zh/zh-hant/ja.

* test(desktop): use type-alias imports for the react-router mock (lint)

* chore: drop accidentally committed node_modules symlinks

* chore: drop remaining committed node_modules symlinks (apps/desktop, apps/shared)
2026-07-19 05:45:01 -07:00
teknium1
3fc006ebe1 fix(web): compute voice provider schema options per-request, align guards with desktop (#40338 follow-up)
Refactor the cherry-picked #40338 backend half:

- Move option merging from import-time _SCHEMA_OVERRIDES mutation to a
  per-request overlay in GET /api/config/schema — options now reflect the
  current config.yaml (no restart needed) and the module-level
  CONFIG_SCHEMA is never mutated. The endpoint gains an optional
  ?profile= param scoped via _config_profile_scope.
- Keep builtin display order first, customs appended (drop the
  sorted(set(...)) re-sort) — matches desktop enumOptionsFor.
- Only command-type provider blocks count (type absent or 'command' plus
  non-empty command string), enumerated from the canonical
  <kind>.providers.* location AND the legacy top-level <kind>.<name>
  fallback — the same dual resolution as _get_named_provider_config /
  _get_named_stt_provider_config. Builtin-name collisions are excluded
  case-insensitively against the RUNTIME builtin sets (not the display
  shortlist), mirroring apps/desktop/src/app/settings/helpers.ts
  commandProviderNames (#67209).
- Drop the plugin.yaml 'provides: [tts]' manifest scan — that convention
  does not exist (manifests carry provides_tools/provides_hooks only);
  plugin TTS/STT providers register at runtime via
  ctx.register_tts_provider(). Instead, opportunistically include names
  from agent.tts_registry / agent.transcription_registry when plugins
  happen to be loaded in this process.
- Current tts.provider/stt.provider value preserved in options.
- Tests: custom command provider merge (tts+stt), builtin-order
  preservation, EDGE collision exclusion, non-command block exclusion,
  current-value preservation, per-request freshness, legacy top-level
  block support.
2026-07-19 05:36:19 -07:00
Teknium
aa1ad32191
fix(desktop): Windows browser-setup journey — console flash, idempotent setup, Nous Portal activation (#67473)
* fix(windows): suppress console-window flash in tools post-setup subprocess spawns

The desktop GUI runs post-setup hooks via a detached, console-less
'hermes tools post-setup <key>' child (spawned with windows_detach_flags).
But the hook implementations in tools_config.py ran their inner installers
(npm install, agent-browser install, uv/pip installs, ensurepip, cua-driver
version probes and installer) without Windows creationflags — and on
Windows a console-less parent spawning a console/.cmd child materializes a
brand-new console window, the 'terminal flash' reported on the
Capabilities > Browser Automation setup journey.

Add _post_setup_no_window_flags(), a local wrapper around
windows_hide_flags() (CREATE_NO_WINDOW only — DETACHED_PROCESS would sever
stdio and break capture_output), and pass it at every post-setup subprocess
call site. Spawns that stream live output to the user's console
(verbose cua-driver install) only hide when stdout is not a tty, so
interactive CLI installs keep their output. POSIX behavior is unchanged
(the helper returns 0 off-Windows).

* fix(desktop): make Capabilities post-setup idempotent — Installed state instead of unconditional Run setup

The GUI panel rendered the primary 'Run setup' CTA whenever a provider
declared post_setup, ignoring the server-computed readiness status the
config endpoint already serves. Users on Windows clicked 'Run setup' on
an already-installed Local Browser and watched it 'install' again.

Frontend: PostSetupRunner now takes installed (provider.status === 'ready')
and renders an 'Installed' pill + small 'Re-run setup' text button in that
state; onComplete still refetches the toolset config, so a fresh install
flips the row to Installed once the endpoint reports ready.

Backend:
- _POST_SETUP_READY extended: agent_browser now tracks the FULL local
  install (_local_browser_runnable: CLI + Chromium-or-Lightpanda) instead
  of the bare CLI check; new entries for the cloud 'browserbase' hook
  (CLI only — cloud rows host their own Chromium) and camofox (npm
  package present).
- _run_post_setup prints distinct 'already installed, nothing to do'
  messages for the agent-browser/Chromium/Camofox early-exits so the GUI
  action log tells the truth on re-runs vs fresh installs.

i18n: new postSetupInstalled/postSetupRerun/postSetupInstalledHint strings
in en, ja, zh, zh-hant + types.

* fix(desktop): let managed Nous Subscription rows activate from the GUI via the Portal sign-in flow

PUT /api/tools/toolsets/{name}/provider intentionally skips the Nous
Portal auth gate the CLI runs inline (ensure_nous_portal_access) — but no
desktop surface handled it. Selecting 'Nous Subscription (Browser Use
cloud)' from Capabilities wrote browser.cloud_provider=browser-use +
use_gateway=true and then silently never activated: _is_provider_active
requires feature.managed_by_nous, which stays false without the
entitlement, and the credential was never used.

Backend: after apply_provider_selection, the endpoint now checks the
managed row's entitlement (get_nous_subscription_features force_fresh +
the same per-category coverage gate the CLI applies) and reports the gap
with additive response fields {needs_nous_auth: true, feature}. The
selection is still persisted — activation is what's gated.

Frontend: handleSelect surfaces a 'Sign in to Nous Portal' warning toast
with a Sign-in action instead of the misleading success toast. The action
drives the EXISTING Nous Portal OAuth device-code flow (provider id
'nous' in _OAUTH_PROVIDER_CATALOG): POST /api/providers/oauth/nous/start,
open verification_url, poll /poll/{session}; on approval the panel
refetches the toolset config so is_active/status flip.

i18n: nousAuthNeeded*/nousAuthSignIn/nousAuthDone*/nousAuthFailed strings
in en, ja, zh, zh-hant + types.
2026-07-19 05:22:10 -07:00
Teknium
027243eb46
fix(credentials): suppress re-seeding when a pool entry is deleted via API (#55217) (#67429) 2026-07-19 03:48:48 -07:00
teknium1
49167ffe05 fix(tools): apply missing-provider setup pass to per-platform configure flow too
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.
2026-07-19 03:02:47 -07:00
Nick S
6912e93478 fix(tools): configure selected global tools missing provider setup 2026-07-19 03:02:47 -07:00
Teknium
9a987f142d
fix(credentials): unified provider key delete/update across .env, auth.json, config.yaml (#67213)
* 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 #51071
Fixes #59761
Fixes #62269
2026-07-19 03:02:21 -07:00
Teknium
833bae3203
Merge pull request #67206 from NousResearch/lane/c3-memory-panel
feat(desktop): declarative memory provider panel + built-in fix (salvage #51020, fixes #49513)
2026-07-19 03:00:52 -07:00
Teknium
8b6714556b
feat(desktop): terminal execution backend picker with health probes in Capabilities (#67203) 2026-07-19 03:00:08 -07:00
Teknium
c372c4220b
fix(desktop): truthful per-provider readiness in Capabilities (no more false Ready) (#67201)
* 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).
2026-07-19 02:59:55 -07:00
teknium1
4f67c33383 fix(config): whitelist real non-DEFAULT_CONFIG roots + normalize test line endings
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).
2026-07-19 01:09:04 -07:00
loes5050
336c3b13aa feat(config): warn on unknown top-level keys + report deprecated keys/env in doctor
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).
2026-07-19 01:09:04 -07:00
Brooklyn Nicholson
40160e2a04 perf(desktop): batch sidebar session slices into one profile-DB pass
The sidebar refresh fired three /api/profiles/sessions calls (recents, cron,
messaging), and each one reopened every selected profile's state.db and re-ran
list_sessions_rich + session_count — ~3N DB opens/counts per refresh, on every
turn/broadcast/reconnect.

Add GET /api/profiles/sessions/sidebar: one pass that opens each profile DB
once and runs the three source-scoped queries together (recents scoped to the
active profile; cron + messaging cross-profile), returning the three windows in
one payload. Same read-only projection, 300s active heuristic, and caller-
supplied source taxonomy (recents_exclude / messaging_exclude / source=cron) as
the per-slice endpoint.

Renderer refreshSessions now makes one listSidebarSessions call and distributes
recents/cron/messaging to their stores (cron *jobs* stay a separate getCronJobs
API). Electron splices remote profiles per slice via fetchProfilesSessionSlice
(reusing the proven per-slice merge) so remote correctness is preserved; the
no-remote common case gets the single-open fast path.

From the Desktop performance audit (P1: "Batch sidebar session slices").
2026-07-18 21:45:18 -04:00
teknium1
54f5696bbb
Merge origin/main into lane/c3-memory-panel 2026-07-18 16:26:30 -07:00
Paulo Nascimento
90d3ba5be9 fix(cli): warn once per path for UTF-32 .env refuse-to-mangle
Hot-reload and multi-entry load_hermes_dotenv can hit the same UTF-32
file repeatedly; gate the refuse-to-mangle warning on a module-level
seen-set (house style: _WARNED_KEYS sibling) so logs are not spammed.
2026-07-18 19:01:10 -04:00
Paulo Nascimento
7d597cc5d4 fix(cli): sanitize UTF-16 .env without corrupting the first key
Notepad "Unicode" saves write UTF-16 with a BOM. The sanitizer decoded
those bytes as utf-8-sig with errors=replace, glued U+FFFD onto the first
key name, stripped NULs, and rewrote the mangled content permanently.

Sniff leading BOMs before any text decode (UTF-32 before UTF-16, because
UTF-32-LE's BOM starts with UTF-16-LE's FF FE). Decode UTF-16 correctly
and rewrite as clean UTF-8. Refuse UTF-32 (leave untouched + warning).
After errors=replace, do not persist a first line that starts with U+FFFD.

Does not touch _load_dotenv_with_fallback (#65124's surface).
2026-07-18 19:01:10 -04:00
Paulo Nascimento
4441e11c77 fix(cli): quote .env values with internal whitespace in save_env_value
_quote_env_value previously left internal spaces unquoted (only #/"/'
and leading/trailing whitespace triggered). Spaced macOS paths written
via hermes setup SSH / Google Chat SA path / hermes config set produced
lines that python-dotenv still parsed but shell `set -a; . file` word-split.

Extend needs_quoting with any(c.isspace()); escaping dialect unchanged.
2026-07-18 19:01:10 -04:00
teknium1
651cff4273
fix(desktop): treat built-in memory as built-in in provider panel (#49513)
Built-in memory (MEMORY.md/USER.md) is controlled by memory_enabled, not
memory.provider — but the desktop dropdown offered 'builtin' as a normal
provider-plugin value and gave it plugin-shaped affordances (config panel,
OAuth connect row), and the empty sentinel rendered as '(none)' even though
built-in memory was active.

- Label the empty memory.provider option 'Built-in only' (all locales).
- Drop the literal 'builtin' option from the desktop ENUM_OPTIONS and the
  backend config-schema select; _normalize_memory_provider_name already maps
  legacy builtin/built-in/none values to ''. A stored legacy literal stays
  visible via enumOptionsFor's current-value passthrough.
- Gate MemoryConnect and ProviderConfigPanel behind a new
  isExternalMemoryProvider() helper so built-in aliases never get
  provider-plugin affordances.
2026-07-18 15:39:39 -07:00
teknium1
7ced2ee394
test(web): unpin HERMES_HONCHO_HOST in profile-param memory config test
The suite-wide conftest now pins HERMES_HONCHO_HOST=hermes (597615ade, after
this branch was written), which preempts profile-driven host resolution and
made the salvaged test write to the 'hermes' host key instead of
'hermes_worker'. Drop the override inside the test like the other custom
host-resolution tests do.
2026-07-18 15:26:50 -07:00
teknium1
c84c0c5277
Merge branch 'pr-51020' into lane/c3-memory-panel 2026-07-18 15:13:04 -07:00
Austin Pickett
ca0703feae
fix(cli): try bundled TUI before requiring ui-tui workspace (#67116)
_make_tui_argv() called _ensure_tui_workspace(tui_dir) unconditionally
before checking for a prebuilt bundle. That function sys.exit(1)s when
ui-tui/ doesn't exist, which it never does on a pip/pipx install — the
wheel ships hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all
(that directory only exists in a git checkout).

Every dashboard Chat tab connection on a pip/pipx install therefore
hard-exited before ever reaching _find_bundled_tui(), surfacing as the
unhelpful "Chat unavailable: 1" banner despite having a fully valid
bundled entry.js on disk.

Move the bundled-wheel/HERMES_TUI_DIR shortcut ahead of the workspace
check. --dev is unaffected (it never uses the bundled path and still
requires the workspace), and the checkout-without-bundle path is
unaffected (bundled lookup returns None, falls through to the existing
git-restore/npm-install/build flow).

Adds a contributors/emails mapping for the original author.

Fixes #56665

Co-authored-by: lucaskvasirr <lucaskvasir@duck.com>
2026-07-18 17:17:25 -04:00
deusyu
3f84b7a163 feat: add /model --once one-turn model override (#29914)
Adds --once to /model across CLI, TUI, and gateway: switch model for the
next turn only, restoring the previous model in a finally block so
success, exception, and interrupt all revert. Parsing extends
parse_model_flags_detailed(); resolve_persist_behavior() treats --once
as a persistence opt-out; --global + --once is rejected.

Salvaged from PR #29923 (image-generation lane split to #59815 per
review; conflict resolution against current main by the maintainers).
2026-07-18 14:01:56 -07:00
Teknium
2278f2cb7e fix(discord): harden reconnect message recovery
Route recovered messages through the live Discord ingress policy, preserve dedup and completion invariants, bound and retain the recovery ledger, and expose the opt-in config with docs and backup coverage.
2026-07-18 14:01:33 -07:00
StellarisW
f57157a128 fix(gateway): recover Discord websocket and event-loop stalls
Replace REST-based Discord liveness probe with local WebSocket/heartbeat
state detection. REST success doesn't prove Gateway event delivery — a
half-closed WebSocket can leave Bot.start() alive while REST returns 200.
Now samples ready/open/ACK state and heartbeat latency; consecutive
unhealthy samples emit one retryable fatal code so GatewayRunner rebuilds
the adapter through the existing reconnect path.

Also fixes three lifecycle gaps in the recovery path:
1. asyncio.wait_for() can remain blocked if adapter cleanup swallows
   cancellation — now uses bounded asyncio.wait() with task detachment.
2. Multiplexed secondary-profile adapters had no profile-scoped reconnect
   owner — now uses one runner-owned reconnect slot per profile.
3. An in-flight turn could send its final text through the disconnected
   adapter after a replacement was registered — now resolves the live
   same-profile replacement for unsent final responses only (message IDs
   never migrate, edits/deletes stay on the old transport).

Adds an opt-in Linux/systemd event-loop watchdog (gateway.systemd_watchdog_seconds,
default 0) for the failure mode where the whole asyncio loop stops making
progress and no in-process liveness task can run. stdlib-only sd_notify,
Type=notify/WatchdogSec generation, READY/STOPPING lifecycle.

Co-authored-by: 王鑫 <wx.xw@bytedance.com>
2026-07-18 20:01:55 +05:30
Siddharth Balyan
7668d289d6
Terminal-billing client hardening: shared wire types, wire-layer tests, dead RPC removal (#61067)
* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.
2026-07-18 07:05:58 -07:00
Teknium
d93c905808 fix: /browser connect times out when another app squats the CDP port
On Windows (and some Linux setups), an application like VS Code's
js-debug can hold 127.0.0.1:9222 while a Chromium browser launched
with --remote-debugging-port=9222 silently binds [::1]:9222 only.
The IPv4-only probe then (a) missed the live browser entirely and
(b) hung against the squatter — which accepts TCP but never answers
the /json/version HTTP probe — repeatedly, driving the whole connect
past the desktop GUI's RPC deadline:
'error: request timed out: browser.manage'.

Fix, applied to both the gateway browser.manage RPC and the CLI
/browser connect path via shared helpers in browser_connect.py:

- discover_local_cdp_url(): probe BOTH loopbacks (127.0.0.1 first,
  then [::1]) and adopt whichever actually speaks CDP.
- local_port_in_use() + find_free_debug_port(): when neither loopback
  speaks CDP but the port is held by another application, report the
  squatter explicitly and launch the debug browser on a nearby free
  port instead of fighting a bind conflict on 9222.
- Bound the gateway's post-launch wait to a 10s deadline (was up to
  20 unbounded probe cycles) so connect always answers inside the
  client RPC timeout.
- _wait_for_browser_debug_ready_or_exit() also probes dual-stack so a
  successful launch pushed onto [::1] is classified 'ready'.

Verified on a live Windows repro (VS Code holding 127.0.0.1:9222,
Chrome 148 on [::1]:9222): connect now resolves http://[::1]:9222
in ~4.5s instead of timing out.
2026-07-18 02:49:28 -07:00