* feat(windows): enable dashboard chat tab via ConPTY (win_pty_bridge)
Add hermes_cli/win_pty_bridge.py — a pywinpty-backed drop-in for
PtyBridge with the same spawn/read/write/resize/close surface — and
wire it into the web_server PTY import block so Windows picks it up
instead of falling back to None.
pywinpty is already a declared win32 dependency (pyproject.toml).
The ConPTY read path runs inside run_in_executor so the event loop
is never blocked. Spawn/read/write/terminate call shapes are taken
directly from tools/process_registry.py which already exercises the
same pywinpty version.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: remove WSL2-only caveat for dashboard chat tab
The chat pane now works on native Windows via the ConPTY bridge added
in the previous commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(windows): cover ConPTY bridge + web_server platform-branched import
Companion to the bridge added in the previous commits. Verified live on
native Windows 11 (pywinpty 2.0.15) against `hermes dashboard`'s
`/api/pty` WebSocket: the spawned `hermes --tui` (node entry.js) renders
through ConPTY, resize escapes reach `setwinsize`, and closing the WS
reaps both the node child and the pywinpty agent with zero orphans.
tests/hermes_cli/test_win_pty_bridge.py
Mirrors the layout of the existing POSIX test_pty_bridge.py:
spawn/io/resize/close/env coverage against cmd.exe and python -c,
plus the cross-platform fallback surface (PtyUnavailableError, the
off-Windows `spawn -> raises PtyUnavailableError` guard, and the
load-bearing _clamp() helper that protects setwinsize from garbage
winsize values out of xterm.js).
tests/hermes_cli/test_web_server_pty_import.py
Asserts that web_server.PtyBridge resolves to WinPtyBridge on win32
and to the POSIX PtyBridge on POSIX, that PtyUnavailableError is the
matching class on each side (so isinstance checks in /api/pty's
spawn fallback path work), and a source-text check that pins the
platform-branched import shape so a future refactor can't quietly
collapse it back to a POSIX-only import.
scripts/release.py
AUTHOR_MAP entries so CI release-note generation can resolve both
authors' plain (non-noreply) emails to their GitHub logins.
Co-Authored-By: JoelJJohnson <josephjohnson.joel@gmail.com>
Co-Authored-By: Nea74 <andreas@schwarz-ketsch.de>
---------
Co-authored-by: JoelJJohnson <josephjohnson.joel@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Nea74 <andreas@schwarz-ketsch.de>
Salvaged from #35626 (banditburai) and re-scoped after maintainers landed the
parent-death watchdog (slash_worker.py) and PTY process-group teardown
(pty_bridge.py) directly on main. Those pieces are intentionally NOT included
here — this carries only what is still missing:
- C1 disconnect reap: ws.py's `finally` only re-pointed the dead transport at
stdio. `_close_sessions_for_transport` now reaps `close_on_disconnect`
sessions and schedules the grace-reap for the rest, offloaded via
`asyncio.to_thread` so the blocking worker.close() + DB write never stalls
the uvicorn loop.
- C2 create/close orphan race: `_attach_worker` stores the worker iff
`_sessions.get(sid) is session` under the lock (else closes it), applied at
every spawn site incl. the post-turn `_restart_slash_worker`.
- Single idempotent teardown funnel: session.close, WS disconnect, the
generous-TTL idle reaper, shutdown, and the WS grace-reap all reach
`_close_session_by_id` → `_teardown_session`; `_finalized`/`_closed` flags
make concurrent/double teardown a no-op. `_sessions_lock` upgraded to RLock.
- uvicorn `ws_ping_interval/timeout=20s` so a half-open socket (reverse-proxy
524) becomes a `WebSocketDisconnect` and the C1 path runs.
Plus two review-driven hardening fixes (mine):
- `session.active_list` now skips `_finalized` sessions so the footer
"N sessions" count reflects attachable sessions instead of only ever
growing until restart (#38950). Keys on `_finalized` only, NOT the stdio
sentinel, so a standalone `hermes --tui` session stays visible.
- `_schedule_ws_orphan_reap._reap` pops via `_close_session_by_id`
(under `_sessions_lock`) instead of `_sessions.pop` under the unrelated
`_session_resume_lock` (#39591); the resume_lock now only guards the orphan
re-check against `session.resume`.
- Float env knobs (`HERMES_SLASH_WATCHDOG_*`, `HERMES_TUI_SESSION_TTL_S`)
parse with a fallback helper so a malformed value can't crash the worker at
import.
Fixes#32377Fixes#38950
Addresses #22855
Co-authored-by: banditburai <123342691+banditburai@users.noreply.github.com>
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
The behind-count (banner._check_via_local_git) measures HEAD..origin/main, but
_recent_upstream_commits logged HEAD..@{upstream}. On a feature-branch checkout
@{upstream} is the branch's own tip (0 commits), so the changelog came back
empty while behind>0 — the overlay then showed generic filler instead of what
changed. Pin the commit range to origin/main so count and changelog agree.
Verified against a checkout 11 behind origin/main: now returns 11 commits.
Add a best-effort `commits` list (sha/summary/author/at) to the update-check
response for git/pip installs that are behind upstream, so the desktop's
remote update overlay can show what's changed before applying.
Additive and non-breaking: existing consumers (legacy dashboard, tests using
subset assertions) ignore the new field. Leaves the shared check_for_updates()
int contract untouched — commits come from a separate best-effort git call.
#41076 makes `hermes plugins list` discover nested category plugins (e.g.
observability/nemo_relay). This adds the missing enable/disable mutation path
so those plugins can actually be toggled, and fixes two incomplete-update
breakages on the #41076 base.
Before: `hermes plugins enable nemo_relay` -> "Plugin 'nemo_relay' is not
installed or bundled." (exit 1), because cmd_enable/cmd_disable went through
_plugin_exists(), which only checked top-level plugins/<name>/.
Changes:
- Add _resolve_plugin_key(): resolve a bare manifest/leaf name OR a full
path-derived key (observability/nemo_relay) to the canonical key the runtime
loader gates on, reusing #41076's _discover_all_plugins(). A bare leaf name
ambiguous across two categories resolves to None rather than silently picking
one.
- cmd_enable/cmd_disable resolve first, persist the canonical key, and drop any
stale legacy bare-name alias so the enabled/disabled lists can't drift into a
contradictory state. _plugin_exists delegates to the same resolver.
- Fix#41076 base breakages: _discover_all_plugins now returns 6-tuples, but
web_server._merged_plugins_hub() still unpacked 5 (ValueError on the
dashboard plugins-hub endpoint) and several test_plugins_cmd_list.py fixtures
were still 5-tuples. Both updated; the hub status check is now key-aware.
Verified e2e on the real CLI + runtime loader (isolated HERMES_HOME):
`hermes plugins enable nemo_relay` writes observability/nemo_relay to
config.yaml and the loader then loads it (enabled=True, error=None); a stale
bare-name alias is cleared on disable; the dashboard _merged_plugins_hub() runs
without crashing. Adds resolution + enable/disable tests; full
tests/hermes_cli/test_plugins_cmd* + web_server plugin tests green.
Follow-up to #41076 (#41066). Branched from that PR's head.
- web_server.py: after proc.poll() returns a non-None exit code, call
proc.wait() to reap the child and move the entry from _ACTION_PROCS
to _ACTION_RESULTS. Previously .poll() alone left <defunct> zombies.
- meet_bot.py: terminate and wait on the pcm_pump subprocess (paplay/
ffmpeg) during the finally-block teardown. Previously leaked on every
normal bot exit.
- tests: add test_action_status_reaps_completed_process and
test_action_status_ignores_wait_failure covering both the happy path
and the wait()-raises-OSError edge case.
Closes#38032
Desktop connected to a remote gateway can now attach images and PDFs and
display agent-written images. Previously the desktop passed a LOCAL file path
to image.attach; on a remote gateway that path doesn't exist, so the image was
silently dropped ("skipped unreadable path") and the vision model never saw it.
The reverse direction was also broken — images the agent wrote on the gateway
rendered as dead links in the remote client.
Gateway (tui_gateway/server.py):
- image.attach_bytes: base64 byte upload written into the gateway's own images
dir and queued via the existing native-image-attach pipeline. Magic-byte
extension sniffing, data-URL prefix + whitespace tolerance, 25 MB cap,
structured error codes. Accepts content_base64/filename (canonical) and
data/ext (older-desktop aliases).
- pdf.attach: renders each page to PNG via pdftoppm (poppler-utils) at 150 DPI
and queues the pages as images; 50 MB / 25-page caps. Accepts host path or
base64 upload.
- Shared helpers (_decode_attach_base64, _sniff_image_ext, _queue_attached_image)
so the two methods and the existing image.attach don't duplicate logic.
Gateway (hermes_cli/web_server.py):
- GET /api/media: returns a gateway-local image as a base64 data URL so remote
clients can display it. Auth-gated like every /api route, extension
allowlist + size cap, AND confined to the gateway's own media roots
(images/screenshots/cache, resolved symlink-safe) so an authed caller can't
read image-extension files anywhere on disk.
Desktop (apps/desktop):
- syncImageAttachmentsForSubmit uploads bytes via image.attach_bytes when the
connection mode is 'remote'; the local fast path is unchanged.
- media.ts gains isRemoteGateway() + gatewayMediaDataUrl(); directive-text and
markdown-text fetch images over /api/media in remote mode.
Consolidates the competing remote-media PRs (#38876, #40317, #21908, #39437)
into one coherent implementation, taking the strongest parts of each and adding
shared-helper cleanup plus the /api/media root-confinement hardening on top.
The per-profile gateway switching from #38876 is intentionally left out as a
separable feature. TUI file uploads (#40492) remain a separate surface.
Tested: 11 new tui_gateway tests + 5 /api/media endpoint tests + desktop
media.remote unit tests; full tui_gateway + web_server suites green (472
passed); tsc -b clean; E2E verified the full attach→disk→queue and
gateway-path→data-URL display round-trip plus the out-of-root security block.
Co-authored-by: Max Mitcham <maxmitcham@mac.home>
Co-authored-by: Justlrnal4 <Justlrnal4@users.noreply.github.com>
Co-authored-by: Chris Cook <ccook@nvms.com>
Co-authored-by: Thomas Paquette <thomas.paquette@gmail.com>
* feat(onboarding): opt-in structured profile-build path on first contact
On a user's very first gateway message, Hermes now optionally offers to
build a short profile of them — then, only with consent, gathers durable
facts and persists them to the user-profile memory store (memory tool,
target="user") so future sessions start already knowing who they are.
Inspired by Poke's zero-input onboarding, but consent-first by design:
- The agent OFFERS, never assumes. Declining stops it immediately.
- Before ANY external lookup it states what it will look up and asks.
- It never reads connected accounts (email/calendar) silently — the
exact privacy concern that made naive implementations feel invasive.
Wiring reuses existing infrastructure end-to-end:
- gateway/run.py first-message hook (was a plain self-intro) now swaps in
the profile-build directive when enabled and not yet offered.
- agent/onboarding.py gains profile_build_mode()/profile_build_directive()
+ PROFILE_BUILD_FLAG, latched once via the existing onboarding.seen
mechanism so the offer fires at most once per install.
- config default onboarding.profile_build: "ask" (set "off" to disable).
Added to an existing section, so no _config_version bump needed.
No new storage layer, no new injection path, no prompt-cache impact.
* fix(dashboard): fold onboarding into agent tab to avoid 1-field category
onboarding.profile_build is the only schema-surfaced onboarding field
(onboarding.seen is an internal latch dict), so the dashboard CONFIG_SCHEMA
single-field-category invariant rejected it. Merge onboarding -> agent like
the other small categories.
The dashboard font is now selectable from the UI, not just YAML. A new Font
section in the header theme picker overrides the UI font of whatever theme is
active; the choice is orthogonal to the theme and survives theme switches.
Each theme keeps its own font as the default — picking "Theme default" clears
the override.
- web/src/themes/fonts.ts: curated font catalog (system + Google Fonts across
sans/serif/mono), each with a family stack and optional webfont URL. The
catalog is the only injected-font surface — no free-text URL box, so the
injected <link> origins stay fixed.
- web/src/themes/context.tsx: font-override state (localStorage + server),
applied after theme typography so it wins; theme apply re-asserts it, and
clearing re-runs theme apply to restore the theme's own font. Mono is left
to the theme so code/terminal are untouched.
- web/src/components/ThemeSwitcher.tsx: Font section with grouped, self-
previewing font rows and a "Theme default" clear option.
- hermes_cli/web_server.py: GET/PUT /api/dashboard/font persisting to
config.yaml dashboard.font, with a server-side id allow-list (unknown ids
coerce to the theme sentinel).
- i18n + types, api client methods, tests, and docs.
Validation: 6 new backend endpoint tests pass; tsc + vite build clean; live
browser test confirmed pick/persist/survive-theme-switch/clear all work.
The desktop model picker calls POST /api/model/set with provider+model only
(no base_url). _apply_main_model_assignment cleared model.base_url for every
non-custom provider, so re-picking a Xiaomi MiMo model wiped a Token Plan
endpoint (https://token-plan-*.xiaomimimo.com/v1) back to the registry default
api.xiaomimimo.com — breaking valid tp- keys with 401s.
Now base_url is cleared only when switching to a different provider (the stale
URL belonged to the old one); same-provider re-assignment preserves it, and an
explicitly supplied base_url is honored for any provider.
The cron run-history endpoint (GET /api/cron/jobs/{id}/runs, added in
#40684) reused list_sessions_rich's order_by_last_active path with a
leading-wildcard id_query. That routes through the recursive
compression-chain CTE, which seeds from EVERY source='cron' row in the DB
and runs per-row preview/last_active subqueries before filtering to one
job and applying LIMIT. Work scaled with the total cron history, so a
large pile made the run-history load time out before eventually
populating.
Cron runs are flat, never-compressed sessions with ids of the form
cron_{job_id}_{ts}, so the chain machinery is pure overhead and the
job binding is a true prefix, not a substring.
- New SessionDB.list_cron_job_runs(): bounded [prefix, hi) id-range scan
on source='cron', ordered by started_at DESC, with the same
preview/last_active enrichment. No CTE, no leading-wildcard LIKE.
- Add idx_sessions_source(source, id) so the range is an index scan;
bump SCHEMA_VERSION 14 -> 15 (index reconciles onto existing DBs via
CREATE INDEX IF NOT EXISTS on startup).
- Point the endpoint at the new method.
Measured on a real SessionDB with 30k cron rows: 5ms vs 85ms for the old
path (16x), and the new path stays flat as the pile grows while the old
one scaled with it. Verified the query plan uses idx_sessions_source_id
(range scan, no full table scan), runs are correctly scoped (substring
collisions like cron_xalpha_ excluded), newest-first, and paged.
Redesign the cron surface around jobs (not run sessions), following
power-user patterns (GitHub Actions / Airflow / Dagu): master → detail → output.
Sidebar "Cron jobs" section:
- jobs with a state pip + live next-run countdown
- click toggles an inline run-history peek; a run opens its chat (active run highlighted)
- hover: trigger-now + manage (open the Cron page)
- capped at 50 with a "50+" badge
Cron page: de-nested from a collapse-in-row accordion to master/detail —
job list + the selected job's schedule, actions, and run history.
Backend: GET /api/cron/jobs/{id}/runs lists a job's run sessions.
Share STATE_DOT/jobState across both surfaces; drop dead code/keys.
The cron scheduler tick loop only ran inside `hermes gateway run`, but the
desktop app spawns a `hermes dashboard` backend with no gateway — so any cron
a user created in the app was saved and never fired (silently).
Run a minimal scheduler ticker inside the dashboard lifespan, gated on a new
HERMES_DESKTOP=1 marker the electron shell injects, so server `hermes dashboard`
is unaffected. Cross-process safe via the existing cron/.tick.lock, so it never
double-fires alongside a real gateway.
Scheduler sessions (source=cron) were listed in recents, where their
`[IMPORTANT: …]` first-message previews spammed the list — and because
cron runs are always newest, a burst of them consumed the whole recents
page budget and starved real conversations (sidebar showed 0 sessions).
Recents and cron jobs are now two independent lists:
- Backend: /api/sessions + /api/profiles/sessions accept source /
exclude_sources; session_count gains exclude_sources. Recents query
excludes cron; the cron section queries source=cron.
- Desktop: separate $cronSessions store + refreshCronSessions fetch, a
collapsed (persisted) "Cron jobs" section below Sessions that only
renders when cron sessions exist, with its own bounded scroller.
* feat(desktop): surface every provider + models from `hermes model` in the GUI
The desktop GUI's model/provider choices were starved relative to the
`hermes model` CLI. Onboarding listed ~8 providers, Settings → Model only
showed authenticated ones, because the global `/api/model/options` endpoint
called build_models_payload() without the full-universe flags the TUI's
model.options JSON-RPC already used.
- web_server.py: `/api/model/options` now passes include_unconfigured +
picker_hints + canonical_order (matching the TUI handler), so every GUI
surface fed by it sees all 37 canonical providers with auth hints.
- Settings → Model: provider dropdown lists every provider; picking an
unconfigured api_key provider shows an inline 'paste key → Activate' flow
(auto-selects the recommended default); OAuth/external route to onboarding.
- Onboarding: the API-key form is now driven by the full provider catalog
(curated five first, then the rest), not a hand-maintained list of five.
- types/hermes.ts: ModelOptionProvider gains authenticated/auth_type/key_env.
- Tests: model-settings covers the full-universe list + inline activation;
fixed a pre-existing stale assertion (nous / hermes-4 was never rendered).
* feat(desktop): /model in GUI chat opens the model picker instead of a dead-end notice
Typing /model in a desktop chat session printed "/model uses the desktop
model picker instead of a slash command" and did nothing — it never opened
the picker. (The slash worker can't render the prompt_toolkit modal /model
opens in the CLI, so the desktop just showed the unavailable-notice.)
- use-prompt-actions.ts: intercept /model client-side. No args → open the
desktop model picker overlay (setModelPickerOpen) — the same full
provider+model picker as the status-bar button. With args (/model <name>
[--provider ...]) → run the switch directly via slash.exec so power users
can still type it.
- desktop-slash-commands.ts: export isModelPickerCommand() so the hook can
detect picker-owned commands without duplicating the PICKER_OWNED_COMMANDS set.
- Test: covers isModelPickerCommand for /model (+ args) vs non-picker commands.
* fix(desktop): make onboarding provider lists scrollable + clean up card styling
The full-catalog onboarding picker could overflow the modal with no way to
scroll — the OAuth provider list and the api-key grid both grew past the
viewport, hiding the key input and the bottom action row (overflow-hidden card,
no scroll container).
- Scope a `max-h-[60dvh] overflow-y-auto` region to just the provider list /
api-key card grid; the "other providers" disclosure, key input, and action
row stay pinned and reachable.
- Inner `p-1` so card borders / focus rings aren't clipped by the scroll viewport.
- Flatter card styling: drop the persistent border, the redundant selected-state
checkmark, and the modal shadow — selection now reads from the ring alone (the
muted "already configured" check stays).
- Remove the " — set up" suffix from the Settings → Model provider dropdown; the
inline setup flow already signals unconfigured providers.
* fix(desktop): identify api-key onboarding cards by env var, not id
Selecting "Google Gemini" also highlighted "Google AI Studio": the curated
catalog and the backend-derived providers can collide on `id` (a provider slug
can equal a curated id like `gemini`), so `option.id === o.id` matched two
cards at once. Key selection (and the React key + snap-back effect) on `envKey`
instead, which the catalog dedups and is therefore unique per card.
---------
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Replicate the `hermes tools` configurator in the dashboard Skills →
Toolsets view. Each toolset now opens a config drawer that covers the
full lifecycle the CLI offers: enable/disable, pick a provider/backend,
enter and save API keys, and run a provider's post-setup install hook
with a live log tail.
The toolset view was previously read+toggle only — the provider matrix
and key-status endpoints existed but the page never called them, and
there was no way to save a key or run a backend install (npm/pip/binary)
from the browser.
Backend:
- New CLI subcommand `hermes tools post-setup <KEY>` — non-interactive,
scriptable target that runs a provider's install hook (agent_browser,
camofox, cua_driver, kittentts, piper, ddgs, spotify, langfuse,
xai_grok). Validated against valid_post_setup_keys() so an arbitrary
key can't drive _run_post_setup.
- PUT /api/tools/toolsets/{name}/env — save API keys to ~/.hermes/.env
via save_env_value (same store the CLI writes), validated against the
toolset category's env-var allowlist; blank values skipped.
- POST /api/tools/toolsets/{name}/post-setup — spawn-action that runs
`hermes tools post-setup <key>`; frontend tails the log via the
existing /api/actions/tools-post-setup/status. Registered in
_ACTION_LOG_FILES.
Frontend:
- New ToolsetConfigDrawer component (provider radios, password key
inputs with saved-state, get-a-key links, Run-setup + live install
log). Toolset cards get a Configure button + the drawer also exposes
the enable toggle.
- api.ts: toggleToolset, getToolsetConfig, selectToolsetProvider,
saveToolsetEnv, runToolsetPostSetup + ToolsetConfig/Provider/EnvVar/
EnvResult types.
Validation: 56 admin-endpoint tests pass (10 new: env save w/ CLI
parity + allowlist reject + blank-skip, post-setup spawn validation,
auth gate); 232 web_server tests pass; web npm run build + eslint clean;
HTTP E2E exercises save-key (CLI reads it back) and spawn+poll
post-setup to exit 0.
The Browse-hub tab was a blank search box with sparse result cards (name +
source + one Install button), no way to read a skill before installing, no
visual security scan, and no indication it was even connected to any hubs.
Backend (web_server.py):
- GET /api/skills/hub/sources — lists the configured hubs (label + trust
tier + GitHub rate-limit + index availability) and featured skills pulled
from the centralized index (zero extra API calls), plus installed-skill
provenance so the UI can mark already-installed results.
- GET /api/skills/hub/preview — fetches a skill's SKILL.md text + file
manifest WITHOUT installing (decodes byte-stored text, masks binaries).
- GET /api/skills/hub/scan — runs the SAME quarantine + scan_skill +
should_allow_install pipeline the CLI installer uses, then cleans up
quarantine, returning verdict / per-finding detail / severity tally /
install-policy decision.
- search now returns per-source counts + timed-out sources + installed map.
Frontend (SkillsPage HubBrowser):
- Landing state: connected-hubs strip + featured skill grid (no more blank
page).
- Rich cards: trust-level color coding, source, tags, identifier,
Details + Install (or Installed state).
- Detail dialog: read the actual SKILL.md, on-demand visual security scan
(verdict pill, severity tally, per-finding list, allow/block policy),
GitHub repo link.
- Search meta line: result count + timing + per-source breakdown (the
'feels slow / no feedback' complaint).
Tests: 4 new endpoint test classes (sources/preview/scan + updated search
shape) in test_dashboard_admin_endpoints.py.
Switching the main model never touches auxiliary slot pins (they're
independent, sticky per-task overrides). A user who switches main away
from a now-unpaid provider keeps paying 402s on every background aux call
until they manually reset those pins — silently, with no UI signal.
- /api/model/set scope:'main' now returns stale_aux: slots still pinned
to a provider different from the new main (additive field).
- Desktop Model Settings shows a switch-time notice after Apply AND a
persistent banner when any loaded aux slot mismatches the main provider,
both wired to the existing 'Reset all to main' action.
- Never auto-clears pins — a dedicated cheaper aux model is a legitimate
config; surface-and-offer instead of nuking.
- Fixes a stale pre-existing assertion in the panel test (main model now
renders via selectors, not a standalone label).
* fix: respect disabled auto-compaction on context overflow
Port from anomalyco/opencode#30749.
When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.
Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.
Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).
* fix(dashboard): populate cron delivery dropdown from configured platforms
The dashboard cron-create/edit dropdown hardcoded five delivery options
(local, telegram, discord, slack, email), so users on Matrix — or any
other backend-supported platform — had no way to pick their channel even
though the cron scheduler delivers to all of them. It also offered
Telegram/Discord/etc. to users who never set those up.
- cron/scheduler.py: add cron_delivery_targets() — the single source of
truth. Intersects gateway-configured platforms with cron-deliverable
ones and reports whether each platform's home channel is set.
- web_server.py: GET /api/cron/delivery-targets exposes that list (+ the
implicit local option) to the dashboard.
- CronPage.tsx: both modals render options from the endpoint. Configured
platforms missing a home channel still appear, annotated "set a home
channel first" (option B), so the user knows what to fix. Edit modal
preserves a job's current target even if it's no longer configured.
Local-only state shows a "configure a platform under Channels" hint.
Validation: scheduler + endpoint E2E'd with a Matrix gateway (home set
and unset); 5 new tests; tests/cron + tests/hermes_cli/test_web_server
green (366 passed).
Follow-up to the read-routing fix: make remote-profile sessions fully
first-class, not just resumable.
Mutations (rename/archive/delete) went through the same hermes:api handler but
never carried the owning profile, so they hit the local primary's state.db --
which has no row for a remote session. Deleting/archiving/renaming a remote
session silently no-op'd or 404'd, and the row reappeared on next refresh.
- hermes.ts: setSessionArchived/deleteSession/renameSession take the owning
profile and pass it as request.profile so Electron routes to that profile's
backend (matching the read path). Callers now forward session.profile.
- main.cjs: generalize the intercept (read -> request) to also reroute
DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile
param (the remote serves its own state.db; no cross-profile semantics there).
- web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with
GET/PATCH (local cross-profile delete).
Also fix the unified-list merge: it concatenated each remote's page onto the
primary's without re-windowing, so a limit=N request could return up to
N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches
limit+offset from each remote (from offset 0), re-sorts by recency, re-windows
to the page, and recomputes total/profile_totals from the remote counts.
Verified live against a remote backend: rename/archive/delete mutate the remote
db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no
overlap with page 1. tsc -b clean; connection-config tests pass.
* Revert "fix(update): require managed marker before destructive clean"
This reverts commit c8e80cd0bf.
* Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)"
This reverts commit 8a19884bf3.
* chore(install): keep npm ci desktop-build fix after stash revert
The destructive-clean reverts (#38542/#39568) pulled the desktop
workspace install back to bare `npm install`. The npm ci -> npm install
fallback is orthogonal build-correctness (avoids the Windows
workspace-hoisting flake where install reports up-to-date against a
stale marker while node_modules is empty, breaking tsc -b). Preserve it.
* feat(update): settable stash-or-discard for non-interactive local changes
Adds updates.non_interactive_local_changes (stash | discard, default
stash). Governs ONLY non-interactive updates (desktop/chat app, gateway,
--yes) — interactive terminal updates always stash-and-ask, unchanged.
- config.py: new key under existing updates section; _config_version 26->27.
- main.py: _cmd_update_impl detects non-interactive (gateway/--yes/no-TTY),
reads the setting; new _discard_stashed_changes() drops the stash
(stash-and-drop, never reset --hard/clean -fd, so ignored paths survive).
Post-pull restore site branches on it; the bail-out and up-to-date
restores always preserve work.
- web_server.py + apps/desktop settings: exposes it as a stash/discard
select (Advanced section, In-App Update Local Changes).
- docs + tests (discard drops, stash restores, interactive ignores setting,
missing section defaults to stash).
* fix(install.ps1): stash/restore instead of reset --hard on Windows update
The PR reverted the destructive update path to stash/restore everywhere
except scripts/install.ps1, whose managed-clone update path still ran
`git reset --hard HEAD` before checkout — silently destroying agent-edited
tracked source on Windows (the same #38542 data-loss class the PR fixes).
- Replace `git reset --hard HEAD` with stash-before-checkout +
restore-after-checkout, mirroring install.sh. Untracked files are
included so agent-created dirs (e.g. tinker-atropos/) survive.
- Keep `core.autocrlf false` (it prevents the phantom CRLF dirt that made
the stash necessary; it's also load-bearing for a clean restore).
- Wrap all three checkout modes (Commit/Tag/Branch); Branch case now uses
`git pull --ff-only` so local commits are never clobbered.
- Only prompt to restore when a real console is attached (UserInteractive
+ non-redirected stdin/stdout + ConsoleHost); the desktop Update button
and bootstrap have no usable console, so they default to restore and
never hang on Read-Host.
- On restore conflict or a failed update, the stash is preserved with
recovery instructions — work is never silently dropped.
Validated on Windows (PowerShell 5.1, git 2.54): AST parse clean;
E2E non-conflicting restore applies+drops cleanly with ignored paths
(node_modules) untouched; conflicting restore preserves the stash.
---------
Co-authored-by: alt-glitch <balyan.sid@gmail.com>
Resolve conflicts in desktop settings/cron/messaging/sidebar: adopt main's
ListRow + actions-menu refactors for credential rows; keep our profileColor
import on the sidebar. Drop the now-orphaned Tip-based helpers.
The per-session icon picker added more noise than value — rip it out end
to end (sessions.icon column, set_session_icon, the PATCH field, the
picker UI, and the SessionInfo.icon type).
The cross-profile session aggregator now opens each profile's state.db
read-only (mode=ro, no schema init), so listing other profiles on every
sidebar refresh never DDLs or takes a write lock on their live DBs. The
single-profile hot path stays on par with /api/sessions.
Add first-class profile support to the desktop app without app reloads.
- Swap the single live gateway onto a session's profile lazily (spawned on
demand by the Electron backend pool), so one backend serves the active
profile and others stay cold — no OOM with many profiles.
- Aggregate sessions across profiles by reading each profile's state.db
read-only; unified "All profiles" view groups sessions per profile with
per-profile pagination, while the default view stays scoped to one profile.
- Add an Arc-style profile rail at the sidebar foot: a default<->all toggle
pinned left, colored named-profile squares scrolling between, Manage pinned
right. Profile identity is a deterministic per-name color.
- Route profile-scoped REST (config/env/skills/tools/model) to the active
gateway profile and invalidate React Query caches on swap. Single-profile
users never trigger a swap, so their path is unchanged.
Backend:
- web_server: profile-aware active/list endpoints + per-profile session
totals; hermes_state: session_count(exclude_children); main.py: honor
--profile over HERMES_HOME env for pooled backends.
UI primitives:
- Add a position-aware Tip tooltip (instant, themed) as a drop-in for native
title=, and strip redundant tooltips from self-descriptive chrome.
search_sessions_by_id previously fetched up to 10k sessions via
list_sessions_rich and filtered them in Python — O(n) per keystroke.
Push the id match into SQL instead.
- list_sessions_rich gains an optional id_query param: a case-insensitive
LIKE pushed into the outer WHERE, matched against each surfaced row's id
AND every id in its forward compression chain (via the existing chain
CTE). Searching a compression root id or a tip id both resolve to the
same projected conversation. LIKE wildcards in the needle are escaped.
- search_sessions_by_id now fetches only matching rows (limit*4) and ranks
exact > prefix > substring in Python over that small set.
- web_server /api/sessions/search: route ID matches and content matches
through one lineage-keyed dedup helper so an id-hit and a content-hit on
the same conversation collapse to a single result (the contributor's
version keyed ID hits by raw sid and content hits by root, which could
double-list a compression tip).
- command-center haystack also matches _lineage_root_id for parity.
E2E verified against a real DB: exact match over 3000+ sessions
materializes 1 row in Python (was ~3000), 5ms; root-id resolves to tip;
LIKE-wildcard escaping holds.
Follow-up to @0xharryriddle's feat(desktop): search sessions by id.
The dashboard's embedded Chat surface (/chat, /api/ws, /api/pty) was gated
behind `hermes dashboard --tui` / HERMES_DASHBOARD_TUI=1. The desktop app and
the dashboard's own Chat tab both drive the agent over the /api/ws + /api/pty
WebSockets, so a dashboard started without the flag would pass the /api/status
health check but slam the chat WebSocket shut with WS code 4403 — the app
connects, reports "ready", and chat stays dead. This was the root cause behind
multiple user reports of the desktop app failing to connect to a self-hosted
gateway/dashboard, and it bit Docker and host installs alike.
Make the embedded chat unconditional:
- web_server.py: _DASHBOARD_EMBEDDED_CHAT_ENABLED defaults to True; drop the
embedded_chat parameter and the runtime reassignment from start_server().
The WS gates still read the constant (now always true) so the seam — and its
"rejects when disabled" contract test — stays meaningful.
- main.py: remove the `--tui` argument from the dashboard subparser and the
`embedded_chat = args.tui or HERMES_DASHBOARD_TUI==1` derivation.
- web/: isDashboardEmbeddedChatEnabled() returns true unconditionally; drop the
deprecated __HERMES_DASHBOARD_TUI__ alias and the dead LEGACY_TUI_RE scrape in
the vite dev-token plugin.
- apps/desktop/electron/main.cjs: drop `--tui` from the spawned dashboardArgs
(it would now error with "unrecognized arguments: --tui") and the redundant
HERMES_DASHBOARD_TUI env injection.
- Docker: no s6 run-script change needed — the script never passed --tui; the
HERMES_DASHBOARD_TUI env var is now simply a no-op, so the image works out of
the box with no extra var.
- Docs: remove every dashboard --tui / HERMES_DASHBOARD_TUI reference across the
CLI reference, env-var reference, docker/desktop/web-dashboard guides, in-app
tips, and the zh-Hans translations. The terminal `hermes --tui` / HERMES_TUI
references are intentionally left untouched.
Tests: 270 passing across web_server, dashboard lifecycle, host-header,
auth-gate, and docker-override-scripts suites.
* Port from google-gemini/gemini-cli#21541: back up corrupted config.yaml
When config.yaml fails to parse, load_config() silently falls back to
DEFAULT_CONFIG and leaves the broken file on disk. If the user then re-runs
the setup wizard or hermes config set (both rewrite config.yaml), their
broken-but-recoverable overrides are lost for good.
Adapts the policy-file recovery from gemini-cli#21541: on the first parse
warning for a given broken file, snapshot it to config.yaml.corrupt.<ts>.bak
(best-effort, symlink-guarded, size-deduped) and tell the user where it
landed. Unlike Gemini's version we deliberately do NOT reset config.yaml to a
clean state — hermes never silently mutates user config, and leaving it means
a hand-fixed file is re-read on the next load.
Tests: 3 new cases (backup created + content preserved + original untouched;
same-size backup dedup; symlink not copied). E2E verified with isolated
HERMES_HOME and a real tab-indented broken config.
* fix(dashboard): explain WHY a chat WS connection was refused
The embedded-chat PTY WebSocket (/api/pty) collapsed every rejection
into a bare close code: 4401 for any auth failure, 4403 for three
unrelated failures (host mismatch, origin mismatch, peer-IP). Neither
the server log nor the browser said which gate fired or why, so a
"chat won't connect" report was undiagnosable without a repro.
Server (web_server.py):
- _ws_auth_reason / _ws_host_origin_reason / _ws_client_reason return a
short machine-parseable reason; old bool wrappers kept for callers/tests.
- pty_ws splits the overloaded 4403 into 4401 (auth), 4403 (host/origin),
4408 (peer not allowed), 4404 (chat disabled), and sends the reason on
the close frame (clamped to the 123-byte RFC6455 limit).
- Each path logs one line: 'pty auth rejected reason=.. mode=.. cred=.. peer=..'
/ 'pty refused: <reason> ..'. Accepted path logs 'pty accepted peer=..
mode=.. cred=..' so an audit shows HOW a peer authed, not just that it did.
tui_gateway/ws.py:
- 'ws send/write failed' now logs error_type=<ExcName> so an exception
whose str() is empty (closed-transport sends) no longer logs 'error='.
web/src/pages/ChatPage.tsx:
- console.warn the real close code + server reason on every close.
- Map 4404/4408 to specific banners; 4401/4403 banners echo the server
reason; [session ended] prints the close code.
E2E verified all five reject paths + accepted path produce matching
close code, wire reason, and server log line.
Both POST /api/model/set and the profile-model writer hand-rolled the same
provider/default/base_url/context_length reconciliation. Extract it into
_apply_main_model_assignment so the custom-vs-hosted base_url logic lives in
one place — removing the future-drift risk where one site learns about
custom base_url persistence and the other forgets.
Behavior unchanged; pinned with a direct helper unit test.
* Port from google-gemini/gemini-cli#21541: back up corrupted config.yaml
When config.yaml fails to parse, load_config() silently falls back to
DEFAULT_CONFIG and leaves the broken file on disk. If the user then re-runs
the setup wizard or hermes config set (both rewrite config.yaml), their
broken-but-recoverable overrides are lost for good.
Adapts the policy-file recovery from gemini-cli#21541: on the first parse
warning for a given broken file, snapshot it to config.yaml.corrupt.<ts>.bak
(best-effort, symlink-guarded, size-deduped) and tell the user where it
landed. Unlike Gemini's version we deliberately do NOT reset config.yaml to a
clean state — hermes never silently mutates user config, and leaving it means
a hand-fixed file is re-read on the next load.
Tests: 3 new cases (backup created + content preserved + original untouched;
same-size backup dedup; symlink not copied). E2E verified with isolated
HERMES_HOME and a real tab-indented broken config.
* feat(dashboard): add Debug Share to the System page
Surface `hermes debug share` in the dashboard. The System > Operations
section gets a dedicated card that uploads a redacted report + full logs
and returns the paste URLs as real, copyable links instead of a log tail.
- debug.py: factor a pure build_debug_share() returning structured
{urls, failures, redacted, auto_delete_seconds}; run_debug_share now
calls it (CLI output unchanged).
- web_server.py: POST /api/ops/debug-share runs the share core in a
worker thread and returns the structured payload synchronously (the
URLs are the whole point — not a backgrounded action).
- api.ts: runDebugShare() + DebugShareResponse.
- SystemPage.tsx: share card with a redaction toggle (on by default),
per-link + copy-all buttons, and the 6h auto-delete countdown.
- tests: build_debug_share core + endpoint (redact toggle, failure 502,
token gate).
The runtime resolver reads model.base_url from config and ignores the
OPENAI_BASE_URL env var, so a self-hosted endpoint could not be configured
from the GUI. Two changes enable it:
- POST /api/model/set accepts an optional base_url and persists it as
model.base_url when provider=custom (still clearing stale base_url for
hosted providers).
- POST /api/providers/validate now returns the model ids a custom endpoint
advertises at /v1/models, so the GUI can auto-pick a default without
asking the user to type a model name.
Refs desktop onboarding "Local / custom endpoint" bug.
The setup-flow provider list showed two Anthropic/Claude entries with
ambiguous labels ('Anthropic (Claude API)' and 'Claude Code (subscription)')
in no deliberate order. Relabel and reorder so the distinction and the
subscription caveat are explicit:
- 'Anthropic API Key' (PKCE, API path)
- 'Anthropic OAuth: Required Extra Usage Credits to Use Subscription' (external)
- Both Anthropic entries moved to the bottom of the list.
- 'OpenAI Codex (ChatGPT)' -> 'OpenAI OAuth (ChatGPT)', now first after Nous.
Applied consistently to the backend OAuth catalog (web_server.py) and the
desktop onboarding overlay's PROVIDER_DISPLAY title/order map; test
assertions updated to the new titles.
Avoid stale WebSocket events from an old reconnect attempt flipping the gateway state after a newer socket opens. Also limit session-search dedupe to compression edges so branch-specific hits still open the branch instead of collapsing to the parent.
Four related desktop session-management bugs:
- Pins lost until refresh: pinned sessions are joined against the
paginated in-memory session list, so a pinned chat that aged off the
most-recent page got evicted on the next refresh (every message.complete
triggers one) and the Pinned section went empty. mergeWorkingSessions ->
mergeSessionPage now also preserves pinned rows (matched by live id or
lineage root). Pin id checks in the chat header, command center, and
delete/archive are normalized to the durable sessionPinId so pins survive
auto-compression.
- Stuck on "Starting Hermes" after sleep: macOS sleep drops the renderer
WebSocket; nothing reconnected on wake so the composer stayed disabled.
The gateway boot hook now auto-reconnects with backoff on close/error and
on wake signals (powerMonitor resume/unlock-screen IPC, window online,
visibilitychange). connect() gains an open timeout so a hung reconnect
can't deadlock in 'connecting'. Composer placeholder distinguishes
"Reconnecting to Hermes" from a cold start.
- Loses chats from itself: the same hard-replace that dropped pins also
dropped loaded sessions; mergeSessionPage keeps them.
- Multiple copies/branches in search: /api/sessions/search deduped only by
raw session_id, so compression segments and branches surfaced as separate
hits. It now dedupes by lineage root and returns the live compression tip,
matching the session_search tool's behavior.
* feat(desktop): enrich profiles dashboard and de-dupe channel env vars
Add active-profile switching, role descriptions (manual + auto-generate
via the auxiliary LLM), per-profile model selection, and gateway-running
/ distribution badges to the GUI Profiles page. New profile creation
gains clone-all, optional description and model assignment.
Hide messaging-platform credentials (channel_managed) from the Keys/Env
page since the Channels page is the canonical surface for them, and
relabel the trimmed "messaging" category as "Gateway".
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): address review feedback on profiles/env changes
- ProfilesPage: scope the action-menu outside-click handler to the menu's
own container via a ref so opening one card's menu no longer leaves
others open.
- EnvPage: route the "Gateway" label and hint through i18n
(t.common.gateway / gatewayHint) instead of hard-coded English, with an
English fallback for untranslated locales.
- web_server: only report description_auto=true when auto-generation
actually succeeded.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): address second-round review on profiles
- ProfilesPage: treat describe-auto success by null-checking the
description and trust the response's description_auto flag instead of
assuming true; disable the model-editor Save button unless the selected
choice resolves to a real /api/model/options entry (avoids silent
no-op saves).
- tests: cover the new profile endpoints (active get/set + 404,
description round-trip + 404, model round-trip + 400 validation, and
describe-auto success/failure contracts).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): more profiles review fixes (toggles, races, tests)
- ProfilesPage: use the canonical `active` returned by setActiveProfile;
make the SOUL/description/model action-menu items toggle their editor
closed when already open; guard description save/auto-describe against
stale responses via an activeDescRequest ref so a late reply can't
clobber a different open editor.
- tests: assert /api/env channel_managed classification matches
_channel_managed_env_keys().
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
The dashboard's update button ran 'hermes update' immediately with no
preview. Now the System page shows whether an update is available and
asks the user to confirm before applying it.
- New GET /api/hermes/update/check: reports install method, current
version, and commits-behind (via banner.check_for_updates, 6h-cached;
?force=1 busts the cache). Soft-fails to behind=null on network error;
marks docker/nix/homebrew as can_apply=false with the out-of-band cmd.
- System page: update-status badge on the Hermes version row (latest /
N behind), a Check-for-updates button, and an Update-now button that
opens a ConfirmDialog showing the commit count before POST /api/hermes/
update fires. Cached status loads with the rest of the page.
- Docs + 5 endpoint tests (git/up-to-date/docker/soft-failure + auth gate).
The embedded-TUI PTY child attaches to two server-internal WebSockets:
/api/ws (its primary JSON-RPC gateway backend) and /api/pub (the event
sidecar). Both URLs are built server-side in web_server.py and handed to
the child via its environment.
In OAuth-gated mode (auth_required=true, every hosted Fly agent), _ws_auth_ok
unconditionally rejects the legacy ?token=<_SESSION_TOKEN> path — a leaked
session token must not grant WS access once the gate is engaged. But
_build_gateway_ws_url() still only emitted ?token=, with no gated-mode
branch (its sibling _build_sidecar_url had been given a ticket branch; the
gateway-url builder was missed). So the TUI child's /api/ws upgrade was
rejected 4401 -> 'gateway websocket connection failed' -> 'gateway startup
timeout', leaving the embedded chat unusable on every gated deployment.
A single-use 30s browser ticket is the wrong shape for this link: the child
reads its attach URL once at startup and reuses it on every reconnect, and
on a slow cold boot it may not dial within the TTL. (_build_sidecar_url's
own docstring already flagged this fragility.)
Fix: add a process-lifetime, multi-use internal credential to
dashboard_auth.ws_tickets (internal_ws_credential / consume_internal_credential),
minted once per process and NEVER injected into the SPA — it only leaves the
process via a spawned child's env, so browser-side XSS can't read it, and a
leak grants no more than a ticket already does. _ws_auth_ok accepts it via
?internal= in gated mode only. Both _build_gateway_ws_url and
_build_sidecar_url now use it, so the child can reconnect both sockets.
Loopback / --insecure behavior is unchanged (still ?token=).
Needs review: touches _ws_auth_ok + dashboard_auth (core auth surface).
Generalises #37747. The WS Origin guard (_ws_host_origin_is_allowed) only
trusted the packaged Electron app's non-web origin (file:// / null / app://)
when the bind was NOT OAuth-gated. The packaged Hermes Desktop renderer loads
over file://, so when it drives a remote OAuth-gated gateway its /api/ws
upgrade was rejected with HTTP 403 even though _ws_auth_ok had already
validated the single-use ?ticket= one line earlier.
This guard runs only AFTER _ws_auth_ok has accepted the WS credential, which
is the real auth boundary in every mode:
* loopback bind -> legacy dashboard session token
* non-loopback --insecure -> legacy session token (Tailscale / LAN, #37747)
* OAuth-gated public bind -> single-use, 30s-TTL, identity-bound ?ticket=
A non-web origin can only come from a native client; a DNS-rebinding attack
always arrives from an http(s) origin and is still match-checked against the
bound host. So once the upstream credential check has passed, the Origin guard
adds nothing for a non-web origin. Collapsed the loopback/non-gated special
cases to 'return True' for non-web origins.
http(s) origins keep the strict same-host check, so browser DNS-rebinding
defence is unchanged.
Tests: gated file:///null/app:// now asserted ALLOWED; cross-site http(s)
still rejected on gated and loopback binds; #37747's loopback and
non-loopback-insecure cases retained. 37/37 test_dashboard_auth_ws_auth +
test_web_server_host_header pass.
Replace the status-bar model chip's modal with a Cursor-style dropdown:
- providers grouped by name in a stable order (no recency reshuffle on select)
- per-model hover-Edit submenu for reasoning effort + fast, gated by per-model
capabilities now surfaced in the model.options payload
- unified Fast toggle: flips the speed=fast param where supported, else swaps
to the model's `-fast` variant (base and variant collapse into one row)
- localStorage-backed "Edit Models" dialog to choose which models appear
Adds reusable dropdown primitives (DropdownMenuSearch, shared row/label
tokens, portaled + collision-aware submenus) and reads session state from
nanostores rather than prop-drilling, so editing options doesn't rebuild and
close the menu.
Module-level asyncio.Lock() binds to whatever event loop was active at
import time. When the same web_server module is reused across multiple
TestClient instances (or across uvicorn reloads), the old lock still
references a defunct loop, causing 'attached to a different loop' errors
and flaky subscriber-registration races in CI.
Replace the module-level _event_channels dict + _event_lock with:
- _lifespan() async context manager that creates both on the running
event loop during FastAPI startup (guaranteed correct loop binding)
- _get_event_state() lazy accessor that initialises on app.state when
TestClient is used without a `with` block (preserves backward compat)
All call sites (_broadcast_event, /api/pub, /api/events) now receive the
app reference and read state via _get_event_state(app) instead of the
module globals. The test polling loop is updated to check
app.state.event_channels rather than the removed module attribute.