The native Electron desktop app shipped (PR #20059 and follow-ups) but the
docs only told people how to download it, not what it is or how to use it.
Adds website/docs/user-guide/desktop.md covering install (installer +
prebuilt + Windows GUI), the chat-first UI and management panes, the
hermes desktop CLI flag reference, self-update, how-it-works, and
troubleshooting. Sourced from apps/desktop/README.md, routes.ts, and the
real argparse. Wired into sidebars.ts under Interfaces after the TUI.
The install overlay had no way to stop a running install — the runner already
supported an abortSignal, but nothing drove it. Wire it end to end:
- main.cjs holds an AbortController for the active runBootstrap and aborts it
on a new hermes:bootstrap:cancel IPC and on app quit, so quitting/cancelling
mid-install actually kills install.sh/ps1 instead of orphaning it.
- runBootstrap bails before spawning anything if the signal is already aborted.
- Install overlay gains a "Cancel install" button while a bootstrap is active;
a cancel surfaces the recovery overlay (retry/repair).
Test: electron/bootstrap-runner.test.cjs asserts the already-aborted early
return (no spawn) via `node --test`.
The model picker now matches `hermes model` for OpenAI, and OpenRouter
stops appearing as authenticated when only OPENAI_API_KEY is set.
- models.py: provider_model_ids() for the default api.openai.com endpoint
intersects the live /v1/models dump (120+ entries incl. embeddings,
whisper, tts, dall-e, moderation, legacy chat) with the curated agentic
list, preserving curated order. Custom OpenAI-compatible endpoints keep
the live list verbatim so discovery still works.
- providers.py: drop extra_env_vars=("OPENAI_API_KEY",) from the openrouter
overlay. list_authenticated_providers reads extra_env_vars to decide
whether a provider is authenticated, so any OpenAI user saw a phantom
OpenRouter row. Runtime OpenRouter credential resolution still falls back
to OPENAI_API_KEY (runtime_provider.py), independent of the overlay.
- Regression tests for both paths.
Streaming quality differs sharply by platform: Telegram has native animated
draft streaming (sendMessageDraft) which is smooth, while Discord/Slack only
have edit-based streaming (repeated editMessage) which visibly flickers. Ship
defaults that match reality instead of one global flag.
- hermes_cli/config.py: DEFAULT_CONFIG display.platforms now ships
telegram.streaming=true and discord.streaming=false (was empty {}). These
are gap-fillers — config deep-merge has user values win, so anyone who
explicitly sets discord.streaming=true keeps it. The global
streaming.enabled master switch still gates everything; these per-platform
flags only take effect once streaming is on.
- Dashboard exposure comes for free: the web settings schema is generated
from DEFAULT_CONFIG, so display.platforms.telegram.streaming and
.discord.streaming now surface as editable boolean toggles in the UI with
no frontend change. (Previously the per-platform tree was {} and invisible.)
- tests: pin the defaults, the resolver outcome (telegram on / discord off /
unlisted platforms follow global), user-override-wins, and dashboard schema
exposure.
No _config_version bump: deep-merge fills the gap for existing installs; no
value migration needed.
Adds a search box above the session list. Loaded sessions match instantly
client-side; a debounced full-text search (existing /api/sessions/search FTS)
covers the rest so all sessions stay findable at 699+. Results replace the
pinned/agents sections while a query is active and resume on click.
- Sidebar: rows within a workspace group now sort by creation time instead of
last activity, so they stop reshuffling every time a message lands (muscle
memory). Groups still float up by recency.
- Sessions only persist a workspace cwd when one was explicitly chosen; an
auto-detected launch directory is no longer stamped on the row, so untargeted
sessions group under "No workspace" instead of "desktop". The agent still
runs in the detected directory.
Long-running sessions auto-compress: the gateway ends the original session
and surfaces the live continuation under a new id (list_sessions_rich projects
the root forward to its tip). Two symptoms fell out of the id rotation:
- A pinned session "vanished" — the pin is stored as the pre-compression root
id, but the sidebar only matched on the live id, so it was filtered out.
Pins now resolve on the durable lineage-root id (`_lineage_root_id`, already
surfaced by the projection): the sidebar indexes sessions by both ids, pin/
unpin and reorder operate on the durable id, and `sessionPinId()` is shared
with the Cmd+P toggle. Existing pins keep working with no migration.
- A freshly-continued session was missing from the list until you ungrouped +
"load 50 more" — the list paginated by original start time, so an old-but-
active conversation sat past the first page. The desktop now requests
`order=recent` (GET /api/sessions gains an `order` param backed by the
existing recency CTE), surfacing live continuations on the first page.
* feat(dashboard-auth): rotate dashboard sessions via refresh token
The dashboard auth-code grant now issues a 24h rotating refresh token
(server side: NousResearch/nous-account-service#293). This wires up the
Hermes client half so an expired access token is transparently refreshed
instead of bouncing the user to /login every 15 minutes.
plugins/dashboard_auth/nous:
- refresh_session() now POSTs grant_type=refresh_token to Portal's token
endpoint and returns a Session carrying the ROTATED refresh token (was
an unconditional RefreshExpiredError under the old "no RT in V1"
contract). The RT is sent in BOTH the request body (Portal's schema
requires it there) and the X-Refresh-Token header (log redaction) —
verified against the #293 preview deploy: header-only is rejected as
invalid_request, body is accepted.
- A 400 from Portal (expired / revoked / reuse-detected) maps to
RefreshExpiredError so the middleware forces a clean re-login; network
errors map to ProviderError; empty RT fast-fails without a network call.
- complete_login now captures the initial refresh token Portal returns
(forward-tolerant: empty string if a deploy omits it).
- Extracted the shared token-response handling into
_token_response_to_session, parameterised on the 400 exception type so
the auth-code path raises InvalidCodeError and the refresh path raises
RefreshExpiredError.
- revoke_session stays a best-effort no-op: Portal exposes no public
token-endpoint revocation grant (revocation is the authenticated
/sessions UI, keyed by sessionId+userId), so logout is cookie-clearing
and the 24h session expires on its own. Documented for a future
revoke grant.
hermes_cli/dashboard_auth/middleware:
- On an expired/invalid access token the gate now attempts refresh via
the session's RT BEFORE forcing re-login. On success it serves the
request and re-sets the rotated cookies on the response (mandatory:
Portal rotates the RT every refresh and reuse-detects, so a stale RT
cookie would revoke the whole session on the next refresh). On
RefreshExpiredError (or no RT) it falls through to clear-and-relogin.
- ProviderError during refresh (Portal unreachable) forces a clean
re-login rather than 500-ing the request.
- Uses the existing REFRESH_SUCCESS / REFRESH_FAILURE audit events.
Validation:
- 176 dashboard-auth unit/integration tests pass.
- Live E2E against the #293 preview deploy: refresh_session(bad rt) ->
RefreshExpiredError through the real token endpoint; live JWKS fetch +
RS256 verification rejects a forged token; empty-RT fast-fail. The
successful happy-path rotation is covered by unit tests (a live run
needs an interactive browser OAuth round trip + registered agent:*
client).
Depends on: NousResearch/nous-account-service#293 (server-side RT issuance).
* fix(dashboard-auth): use Portal's x-nous-refresh-token header name
The refresh-token header must match Portal's REFRESH_TOKEN_HEADER exactly
("x-nous-refresh-token"); the initial cut used "X-Refresh-Token", which
Portal silently ignores (harmless since the RT is also in the body, which
is what the schema requires — but the header redaction was a no-op).
Confirmed against the NAS token route + re-validated live against the
#293 preview deploy.
* fix(dashboard-auth): refresh session when access-token cookie has been evicted
The gated middleware bounced users to /login the instant the access-token
cookie was absent, without ever consulting the refresh token:
at, _rt = read_session_cookies(request)
if not at:
return _unauth_response(...) # bailed here
This made transparent refresh effectively dead for the common case. The
access-token cookie is set with Max-Age = access_token_expires_in (~15 min),
so a real browser EVICTS hermes_session_at the moment the token lapses while
hermes_session_rt persists (30-day Max-Age). From that point the browser
sends only the refresh-token cookie — and the old guard rejected it before
_attempt_refresh could run. The _attempt_refresh path only fired for a
present-but-invalid access token, which never happens in a browser.
Fix: only hard-bounce when NEITHER cookie is present. A request carrying
just the refresh token now skips verification (no AT to verify) and flows
into the existing refresh path, which rotates both cookies and serves the
request transparently. A dead/expired RT still raises RefreshExpiredError
and falls through to clear-and-relogin.
This failure mode escaped the original tests + manual refresh button because
both kept the access-token cookie present; only a real browser evicting the
cookie at Max-Age exposes it. Added 3 regression tests covering: AT-evicted +
RT-present (transparent refresh), no-cookies (still bounces), and RT-only with
a dead RT (clean 401, no 500).
Command Center's Models section and Settings > Model rendered the same
model state with identical persistence semantics — both write config and
apply to new sessions only (POST /api/model/set). The Command Center UI
was strictly better (provider catalog, curated model lists, friendly
auxiliary-task labels, Nous-gateway auto-routing on main-provider switch),
while Settings > Model was three barebones config fields.
Extract that UI into a shared settings/model-settings.tsx (restyled with
Settings primitives) and render it at the top of Settings > Model: main
model picker via setModelAssignment + the 9 auxiliary task slots with
per-task set-to-main / change / reset-all. model_context_length and
fallback_providers stay as config fields below it; the raw auxiliary.*
keys are dropped from Advanced (now covered by the panel).
Strip the Models section from Command Center entirely (section, state,
handlers, render, nav, search entry) leaving it focused on Sessions /
System / Usage, and move the live store-sync callback (onMainModelChanged)
from CommandCenterView to SettingsView. The composer's per-session model
picker (the only live hot-swap, via /model) is unchanged.
The left-nav Skills pane and Settings > Skills & Tools rendered the same
getSkills()/getToolsets() data with the same helpers and toggles — genuine
duplication that drifted (different default category labels, sort orders).
Make the left pane the single home: it keeps its category-tabbed browsing
and now gains the functional bits it lacked — a real toolset enable/disable
switch (was a read-only pill) and the expandable ToolsetConfigPanel for
provider selection + per-key credential config. Remove the Tools section
from Settings (nav item, view branch, query slot, type union entries) and
delete tools-settings.tsx, migrating its toggle coverage into the skills
pane test. Relabel the entry point to 'Skills & Tools' in the sidebar and
command center.
The gateway reads top-level streaming.* with StreamingConfig defaults when the
block is absent, so streaming was invisible — a user with no streaming block
sees responses arrive as single messages and has no way to discover the toggle
short of reading source. This materializes the block in config.yaml so it's
discoverable, with values byte-identical to the dataclass defaults (no behavior
change).
- DEFAULT_CONFIG gains a root-level streaming block (enabled, transport,
edit_interval, buffer_threshold, cursor, fresh_final_after_seconds), each
documented inline. Values match gateway/config.py StreamingConfig() exactly.
- _KNOWN_ROOT_KEYS gains 'streaming' so the validator accepts the root key.
- No _config_version bump: load_config deep-merges DEFAULT_CONFIG over user
YAML, so existing installs pick up the default automatically; no value
migration needed.
Does NOT touch the setup wizard — streaming stays opt-in, just discoverable.
Introduce a typed agent→gateway delivery contract so the gateway (not the
agent) decides how each streaming event is rendered per platform. Moves toward
smart-agent/smart-gateway separation while reproducing today's behavior exactly
in the base class.
- gateway/stream_events.py: typed event vocabulary (MessageChunk/Stop,
Commentary, ToolCallChunk/Finished, LongToolHint, GatewayNotice).
- gateway/stream_dispatch.py: GatewayEventDispatcher routes events through the
adapter; adapters can eat events they can't render (e.g. tool chrome on
plain-text platforms).
- gateway/platforms/base.py: render_message_event + format_tool_event default
hooks reproduce the historical emoji/preview tool formatting and consumer
delegation 1:1; adapters override for native rendering.
- gateway/platforms/telegram.py: send_draft now applies MarkdownV2 (format_message
+ parse_mode) with a plain-text fallback on BadRequest, fixing the jarring
raw-text→formatted shift when the draft finalizes as a real sendMessage.
- gateway/config.py: default streaming transport edit → auto. Safe globally:
adapters without draft support report supports_draft_streaming()==False and
transparently use edit, so only Telegram DMs gain native drafts.
Presentation-only contract — nothing rendered here is persisted to conversation
history, preserving cache/message-flow invariants.
A stray zero-width space (U+200B), BOM, or bidi control in loaded skill
markdown permanently killed any cron that loaded it. The skills-attached
assembled-prompt scan hard-blocked on any invisible-unicode char, even
though skill bodies are already install-time vetted by skills_guard.py and
the chars commonly appear in copy-pasted unicode docs / code examples.
The skills path now strips invisibles (logging the codepoints) and runs the
cleaned prompt. The raw user-prompt path (_scan_cron_prompt) keeps the hard
block — that is the actual #3968 injection surface, where a small directive
prompt with a ZWSP is a smoking gun, not prose. Stripping does not let a real
injection slip through: the directive still matches after sanitization.
_scan_cron_skill_assembled now returns (cleaned_prompt, error).
The toolset config panel highlighted the first keyless provider (e.g.
Nous Portal) on load instead of the provider actually written to config.
The /api/tools/toolsets/{name}/config endpoint never reported which
provider was active, so the GUI's default-expand logic fell back to
"first configured" — and keyless providers are always "configured".
Backend now annotates each provider with is_active (via the same
_is_provider_active helper the CLI 'hermes tools' picker uses) plus a
top-level active_provider summary. The panel prefers that signal before
falling back to first-configured/first.
Adds a frontend regression test (active provider is expanded on load)
and backend coverage (config reports is_active/active_provider; selecting
a provider round-trips into the next config read).
The /api/messaging/platforms endpoints (catalog, configure, test) shipped
with the desktop app but never got a dashboard UI; the recent admin-panel
PRs covered MCP/webhooks/hooks/system but skipped messaging channels. This
adds the missing page so all 20+ channels (Telegram, Discord, Slack, Matrix,
Mattermost, WhatsApp, Signal, BlueBubbles, Email, SMS, DingTalk, Feishu,
WeCom, WeChat, QQ Bot, Yuanbao, plugin platforms, etc.) can be configured,
enabled/disabled, tested, and connected entirely from the browser.
- web/src/pages/ChannelsPage.tsx: per-platform list with live status, enable
Switch, Test, and a Configure modal that renders each platform's exact
setup fields (secrets masked, required validated, redacted display).
- web/src/lib/api.ts: MessagingPlatform types + get/update/test client fns.
- web/src/App.tsx: /channels route + nav tab (Radio icon, after MCP).
- docs: Channels section + REST endpoints + screenshot.
Frontend-only — reuses the existing env-write + config-enable backend, which
auto-enables a platform once its required env vars are present and the
gateway restarts. No core changes, no new tool schema.
test_stale_m3_cache_dropped_and_reresolves_to_1m hardcoded
assert ctx == 1_000_000. The test re-resolves M3 through the live models.dev
registry (the seeded stale entry is dropped, so nothing short-circuits the
lookup), and models.dev now reports MiniMax-M3 at 512,000 — a change-detector
failure unrelated to any code change.
The guard's actual contract is: a stale <=204,800 catch-all value for an M3
slug must be DROPPED and re-resolved to M3's real (large) context. Both
sources satisfy that (hardcoded catalog 1,000,000; models.dev 512,000), so
assert the invariant (ctx > 204,800, stale value gone) instead of a literal
that external data can move. Renamed accordingly.
47/47 in test_minimax_provider.py pass.
Save the original working directory before init scripts cd to
/opt/data, then restore it before exec'ing the user command, so
the container starts in the Docker -w directory instead of /opt/data.
Adds regression test verifying cwd save/restore ordering in
main-wrapper.sh.
When a dispatcher-spawned worker (HERMES_KANBAN_TASK set) calls
kanban_create without an explicit workspace, the new child now inherits
the worker's own running-task workspace_kind/workspace_path instead of
defaulting to scratch. A worker editing a dir:/worktree project that
spawns a follow-up child keeps it in that project.
Orchestrators (kanban toolset, no HERMES_KANBAN_TASK) and CLI/dashboard
callers still default to scratch. An explicit workspace arg always wins.
* feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats
Backend for the comprehensive admin pass:
- MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST
/api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled
- Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes
with 403 (hot-reloaded, no restart)
- Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove;
list now reports accurate allowlist status + valid events
- System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/
uptime/process, stdlib fallback
All gated by dashboard auth; secrets never returned.
* feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats
- McpPage: catalog section (browse Nous-approved MCPs, one-click install with
env prompts) + per-server enable/disable toggle with gateway-restart note
- WebhooksPage: per-subscription enable/disable toggle (muted + badge when off)
- SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load),
shell-hook create modal + delete, 'Create backup' label
- api.ts: client methods + types for catalog, toggles, hook CRUD, system stats
* test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle
Adds tests for the comprehensive pass: MCP enable/disable + catalog list +
catalog-install-unknown, hook create/delete with consent, system stats shape,
and webhook enable/disable. 26 tests total, all green.
* docs(dashboard): document the comprehensive admin pass + fresh screenshots
Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install,
enable/disable toggles, hook creation, and host system stats; adds the new
endpoints to the API table; replaces the screenshots with live captures of
the rebuilt pages (real data, no dummies) including the hook-create modal.
* feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops
Closes the last in-scope CLI gaps from the coverage audit:
- Curator: GET /api/curator (status), PUT /api/curator/paused, POST
/api/curator/run (background)
- Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only)
- Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate
(backgrounded, tailed via action status)
Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/
postinstall/uninstall/claw) remain CLI-only by design.
* feat(dashboard): curator + portal + diagnostics UI, tests
- SystemPage: Nous Portal status section (auth + Tool Gateway routing),
Skill curator card (status + pause/resume + run now), and three new
Operations buttons (prompt size, support dump, migrate config)
- api.ts: client methods + CuratorStatus/PortalStatus types
- tests: curator pause/resume, portal shape, system-stats shape, + auth-gate
coverage for the new GET endpoints (31 tests total)
* docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots
Updates the System section for the Nous Portal status, Skill curator
controls, and the new prompt-size/dump/migrate operations; adds them to the
API table; refreshes the System screenshots (now showing Portal + Curator)
and adds a dedicated curator/gateway/memory capture.
* feat(dashboard): session stats/export/prune + skills hub search endpoints
Completes the existing tabs' backend depth (audit vs CLI):
- Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export,
POST /api/sessions/prune. /stats is registered before /{session_id} so the
literal path isn't captured by the parameterized route.
- Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded),
returns installable identifiers
- (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI)
* feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit
Audited every existing tab against its CLI command and filled the gaps:
- Sessions: store stats bar, per-row rename + export (JSON download), and a
prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats)
- Skills: new 'Browse hub' view — search the skill hub across all sources,
install by identifier with a live install log, and 'Update all' (mirrors
hermes skills search/install/update)
- Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit)
- api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions,
updateCronJob, searchSkillsHub + types
Models tab was already comprehensive (provider+model picker, dynamic per-provider
lists, main + all 11 aux-task assignments, reset) — verified, no change needed.
* test(dashboard): cover session stats/rename/export/prune + skills hub search
Adds the route-shadowing guard for /api/sessions/stats (must not be captured
by /api/sessions/{session_id}), rename/export/prune, and the empty-query
short-circuit for hub search. 36 tests total, all green.
* docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit
Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse
hub view for search/install/update (+ screenshot). Cron: edit action. API
table updated with the new endpoints.
The arm64 PR build ran fully uncached because the previous gha cache
backend's short-lived Azure SAS token expired mid-build on slow
cold-cache arm64 runs and crashed before the smoke test. Uncached arm64
PR builds were ~45% slower than amd64 (median 553s vs 382s), making the
arm64 job the one most often cancelled on supersede — surfacing as a red
X in PR checks and reading as 'the arm64 build keeps failing'.
Switch arm64 to a registry-backed cache on ghcr.io
(type=registry, ref ghcr.io/nousresearch/hermes-agent:buildcache-arm64).
Its credential is the job-lifetime GITHUB_TOKEN, not a time-boxed SAS
token, so the cold-build-outlives-token failure mode cannot recur.
- PR builds: cache-from only (read-only) — warm layers, no write races,
no cache-ref pollution from rapid PR pushes.
- main/release builds: cache-from + cache-to (mode=max) to populate the
cache for subsequent PR/main builds and let the digest push reuse the
smoke-test build's layers.
- Add packages: write permission and a ghcr.io login for the cache.
amd64 keeps its gha cache: it builds fast enough to stay inside the SAS
token's lifetime, so it never hit this failure mode.
* fix(file-safety): extend sandbox-mirror guard to cover inner-container path (#32049)
Brian's shape-based guard (#32213) catches paths that still carry the
full sandboxes/<backend>/<task>/home/.hermes/… prefix on the host side.
The inner-container case is not covered: when file tools execute inside
Docker the bind-mount strips that prefix, so the guard receives plain
/root/.hermes/… and passes through. The root:root ownership on the
divergent SOUL.md in #32049 confirms this is the primary failure mode.
Add a ContextVar (_CONTAINER_HERMES_MIRROR) set by DockerEnvironment
when persistent=True. classify_container_mirror_target / get_container_
mirror_warning detect any write whose resolved path falls under that
prefix, using the same warning format and cross_profile=True bypass
contract as the existing guards. Chain the new guard in
_check_cross_profile_path after the two existing detectors.
* fix(file-safety): derive Docker mirror guard from task
---------
Co-authored-by: Ben <ben@nousresearch.com>
Non-dispatch gateways no longer open per-board kanban DBs for notifier
polling. Mirrors the existing dispatcher gate (config
kanban.dispatch_in_gateway, default True; env override
HERMES_KANBAN_DISPATCH_IN_GATEWAY) so multi-gateway setups collapse to a
single process holding kanban.db file descriptors.
Salvaged from PR #31964 by @steveonjava; tests and docs trimmed during
salvage.
Adopt the cleaner handling from PR #37080: coerce/strip the note and
skip the extra newlines when the underlying message (or text part) is
empty, while keeping the safer fail-open behavior for unknown shapes.
Regression coverage for the multimodal-message TypeError: note folding into
text parts, image-only insertion, empty-note passthrough, and unknown-shape
fail-open.
Sending an image to a vision model turns the user message into a list of
OpenAI-style content parts. When a /model or /reload-skills note was queued
for the same turn, the CLI did `note + "\n\n" + agent_message`, crashing the
agent thread with:
TypeError: can only concatenate str (not "list") to str
Repro: `/model gpt-5.5 --provider openai-codex`, then paste+send an image.
Add _prepend_note_to_message(), which folds the note into the first text
part of a content-parts list (or inserts a leading text part for image-only
messages) and keeps the plain-string path unchanged. Used for both the
model-switch and skills-reload notes.
The /model picker emitted a standalone slug=openai row (gated on
OPENAI_API_KEY). Selecting it ran resolve_provider_full("openai"),
which resolved the legacy providers.py alias openai->openrouter BEFORE
checking the user's own providers.openai config — silently switching
users onto OpenRouter (HTTP 401 when they have no OR key).
- model_switch.list_authenticated_providers: skip vendor names that are
aliases to an aggregator (isolates openai->openrouter; copilot/kimi/etc.
are real providers and unaffected). Kills the phantom picker row.
- providers.resolve_provider_full: user-config providers.<name> now wins
over the built-in alias table, so providers.openai (api.openai.com)
beats the alias.
- model_switch PATH A: user-config providers resolve credentials via
their own endpoint instead of the name-based runtime resolver that
doesn't know user-config slugs; plus a fail-loud guard for explicit
unauthed-aggregator hops.
Verified E2E with the reporter's config (no OR key): selecting OpenAI +
gpt-4o-mini now resolves to api.openai.com instead of openrouter.ai.
decompose_triage_task hardcoded every fan-out child to workspace_kind
'scratch', ignoring the root task's workspace. A code-gen task created
with a dir:/worktree: workspace would fan out into throwaway scratch tmp
dirs (GC'd on archive), so generated code never landed in the project.
Children now inherit the root's workspace_kind + workspace_path. A child
dict may still override with its own workspace_kind/workspace_path; the
path only carries over when kinds match. Scratch roots are unchanged.
Collapse the per-type observed-media dispatch into one platform-agnostic
cache_media_bytes() helper in gateway/platforms/base.py. Any adapter can now
hand it raw attachment bytes + a filename/MIME hint; it classifies against the
shared MIME registries, routes to the right cache_*_from_bytes helper,
sandbox-translates the path, and returns a CachedMedia with a ready
context_note(). Telegram's observed-group path shrinks to: size-gate, download,
call the helper, annotate. Also dedupes the addressed-media type ladder into
_media_message_type().
Net: contributor's Telegram-only +595 LOC becomes a +210/-32 production change,
with the reusable primitive available to Discord/Slack/Signal/etc.
Co-authored-by: Glucksberg <markuscontasul@gmail.com>
Follow-up to the salvaged terminalBackground commit:
- align the CSS-var fallback and type doc to the runtime default (#000000)
- revert web/package-lock.json to main (the original commit stripped peer
flags as an npm-version artifact, unrelated to the feature)
Wires the xterm.js terminal pane background color into the theme
system. Previously hardcoded as #0d2626; now reads from
DashboardTheme.terminalBackground with #000000 as default.
Users can override via ~/.hermes/dashboard-themes/*.yaml:
terminalBackground: "#1a0a2e"
Skills discovery surfaced ~136 of 88k skills in the CLI and gave community
skills no clickable source on the docs page. Three coupled fixes:
CLI browse:
- hermes skills browse capped at 50 because the per-source limit dict had no
'hermes-index' key — when the centralized index is available the router
skips external APIs and serves only the index, so the default-50 fallthrough
silently truncated the whole hub. Add hermes-index: 5000. Browse now loads
5367 (269 pages) instead of 136.
- Add an Identifier column + install/inspect hint to the browse table so users
can act on what they see without a second 'search'.
- Route the TUI browse_skills() helper through parallel_search_sources so it
inherits the same index-aware source-skip (was double-counting); expose
identifier in its output.
Docs Skills Hub page:
- Synthesize a sourceUrl for every community skill (github tree URL, clawhub /
skills.sh / lobehub / browse.sh detail pages), preferring the adapter's
explicit extra.detail_url/source_url/repo_url. Expanded cards now show
'View source' for community skills (was nothing) and keep 'View full
documentation' for built-in/optional. 99% coverage.
- Add a Copy button on the install command.
- Add a loading state instead of flashing '0 skills / No skills found' while
the 45MB catalog fetches.
Category cleanup:
- _guess_category fell back to tags[0] verbatim, producing ~430 junk one-off
categories (version strings, brand names: '0.10.7 Dev', 'Doramagic Crystal').
Now only curated buckets are accepted; unknowns fold into 'Other'. Widen the
tag->category map so common community tags route to real buckets. 430 -> 173
categories, top 20 all meaningful.
Tests: tests/website/test_extract_skills.py covers _source_url synthesis +
precedence and _guess_category curation (13 tests). All 27 skills-hub CLI
tests still pass. Docusaurus build verified; expanded cards confirmed in
browser for both community (View source) and built-in (View full docs).
Salvages 8 distinct fixes from a batch of PRs by @kyssta-exe, reapplied
onto current main (original branches were stale) with a few refinements.
- cron(jobs.py): load_jobs() validates top-level JSON shape — a bare
list auto-repairs into the {"jobs": [...]} dict; scalars/null raise a
clear RuntimeError instead of an uncaught AttributeError that took
down the whole cron subsystem (#37065, closes#36867).
- web(web_server.py): close the per-action log file handle after Popen
so the parent stops leaking one fd per spawned action (#36843).
- web(web_server.py): DELETE /api/env returns 400 for invalid key names
instead of a misleading 500, mirroring PUT /api/env (#36840).
- gateway(gateway.py): read /proc/<pid>/cmdline inside a with-block so
the fd is released immediately instead of relying on GC (#36804).
- web-tools(web_tools.py): include "xai" in check_web_api_key() so a
configured X.AI web backend reports as available (#36802).
- compression(conversation_compression.py): mark the feasibility check
done only after it completes, and default the gate to "not checked"
if the attribute is missing (#36803).
- completion(completion.py): replace `ls` with directory globbing in the
generated bash/zsh/fish profile listers — handles names with spaces
and skips non-directory entries (#36806).
- terminal-tool(terminal_tool.py): drop a duplicate `import threading`
(#36808).
- claw(claw.py): the migrate recommendation now points at the real
`hermes gateway stop` command instead of the non-existent
`hermes stop` (#36795, #36796, closes#36771).
- tests: guard against a leaked HERMES_CRON_SESSION breaking gateway
approval tests — add it to the hermetic conftest unset list (root
cause, protects every test) and pop it in the affected test's
setup_method (#36796).
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
Reworks the content-type preflight so a misconfigured HTTP MCP url (a web-app
root serving HTML) fails in <1s instead of hanging the full 60s connect_timeout
— and does so non-retryably, which neither original PR achieved.
- Allow-list detection (application/json, text/event-stream) instead of a
text/html-only denylist — catches text/plain, application/xml, etc.
- New NonMcpEndpointError(ConnectionError); run() catches it in the same
top-level fast-fail block as InvalidMcpUrlError, so it returns before the
reconnect-backoff loop (truly non-retryable) and the probe runs once, not
on every reconnect.
- Probe runs on its own httpx client OUTSIDE the SDK anyio task group, so the
error propagates as itself rather than wrapped in an ExceptionGroup (the
trap that made the in-SDK event-hook approach a no-op).
- Forwards ssl_verify + client_cert + headers; HEAD->GET fallback on 405/501;
best-effort pass-through on missing content type, non-2xx, and network
errors; skips SSE transport. CancelledError is never swallowed.
- Replaces the malformed test file (which never imported the real method and
failed CI) with 21 tests driving the actual _preflight_content_type against
a real local HTTP server, plus full run() integration verifying <1s
non-retryable failure.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: uzunkuyruk <egitimviscara@gmail.com>
A misconfigured MCP server URL that returns text/html (e.g. pointing at
a web app root instead of an MCP endpoint) causes the MCP SDK to block
for the full connect_timeout (default 60 s) before surfacing
CancelledError.
Add a lightweight HEAD pre-flight check that detects text/html responses
in ≤5 s and raises ConnectionError with an actionable message. Non-HTML
responses, missing headers, and network errors pass through silently so
the normal MCP handshake proceeds unaffected.
Fixes#36052
* feat(tui): single /model command + unified Sessions overlay
Collapse the redundant `/provider` alias so `/model` is the only name
everywhere (it already drove the same 2-step ModelPicker in the TUI).
Merge the separate `/resume` (cold history browser) and `/sessions` (live
switcher) surfaces into one Sessions overlay reached by `/resume`,
`/sessions`, `/session`, and `/switch`. It pins a "+ new" row at the top
(always visible), lists live sessions with status, and lists resumable
history below — dispatching session.activate for live rows vs resume for
cold ones, with close/delete in place. Fixes `/session` opening an empty
live-only switcher and the hidden new-session affordance.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(tui): address Copilot review on the Sessions overlay
- Track the armed history-delete by session id instead of row index so the
1.5s live-status poll re-indexing rows can't redirect the second `d` to a
different session.
- Re-add the busy-session guard to immediate `/resume <id>` and `/sessions new`
actions (browsing the bare overlay stays allowed) so resuming/switching can't
corrupt an in-flight turn's streaming/busy state.
* fix(tui): guard cold-resume (not live-switch/new) from the Sessions overlay
Copilot flagged that overlay actions bypassed the busy guard. Only cold
resume actually closes the current session, so only it is guarded — both
from the slash path and now from the overlay (appActions.resumeById).
Switching between live sessions and starting a `+ new` live session keep
the current session running in the background, so they stay unguarded:
that concurrency is the orchestrator's whole purpose. Also dropped the
over-broad guard on `/sessions new` for the same reason.
* fix(tui): address Copilot review (history dedup + desktop /provider)
- The 1.5s poll now re-derives the resumable list from the RAW session.list
results (rawHistoryRef) against the current live set, so a session hidden
while live reappears in history once it closes — instead of being lost
until a full reload. Delete also prunes the raw ref.
- Drop the dead `/provider` entry from the desktop PICKER_OWNED_COMMANDS now
that the alias is gone, so the desktop client no longer advertises it.
* fix(tui): surface session.list errors + keep selection stable across polls
- A garbled session.list response now surfaces an error and preserves the
last good raw history, instead of silently blanking the resumable section.
- The 1.5s poll re-anchors the selection to the same row by session id
(live or history) when the live list grows/shrinks, so the highlight no
longer drifts to a different row mid-interaction.
* fix(tui): degrade session.list independently + cover overlay helpers
- Fetch active_list and session.list via Promise.allSettled so a failing
session.list no longer rejects the whole load: live sessions still render
and only the resumable history degrades (with an error).
- Add unit tests for the new helpers (sessionRowKindAt row ordering,
resumableHistory dedupe, sessionsCountLabel, relativeSessionAge).
* test(tui-gateway): assert /provider alias is gone, /model remains
The CI test_complete_slash_includes_provider_alias asserted the removed
`/provider` alias still autocompleted. Flip it to lock in the removal:
`/pro` no longer offers `provider`, and `/mod` still completes `model`.
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>