Opus Advisor verdict: SHIP-AS-IS. Zero MUST-FIX, three SHOULD-FIX filed as follow-up issues: - Notes drawer: 10 non-en locales ship English fallback (default-off so user impact = 0) - _joplin_api_get URL-token defense-in-depth (move to Authorization header) - prefill_messages setattr cache-reuse safety on older agent builds 6,503 pytest passed (sequential mode — xdist not supported by test infra).
1.0 MiB
Hermes Web UI -- Changelog
[Unreleased]
[v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish)
Added
-
PR #2868 by @AJV20 — Installed/mobile PWA sessions now support an edge swipe from the left side of the screen to open the mobile sidebar drawer, while preserving the existing hamburger and overlay controls. PWA-standalone-gated, edge X<28px, vertical-tolerance 48px, interactive-target exclusion. Defends against accidental triggers from text selection or button taps.
-
PR #2527 by @AJV20 — Default-off, read-only Third-party notes drawer in the Memory panel. Lists configured note/knowledge MCP sources (Joplin, Obsidian, Notion, llm-wiki) when explicitly enabled via
webui_external_notes_sourcesconfig orHERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1. Automatic session recall unchanged. 4 API endpoints (/api/notes/sources,/api/notes/search,/api/notes/item, plusexternal_notes_enabledin memory_read response) all gated behind the feature flag. -
PR #2547 by @AJV20 — SSE stream runtime diagnostics in deep health checks: active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. Non-sensitive payload only.
-
PR #2547 by @AJV20 — WebUI session prefill parity for bounded JSON files. Browser-originated chat turns can load configured prefill context from
prefill_messages_file, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. WebUI intentionally does not executeprefill_messages_script; executable recall should go through the existing MCP/tool surface. Backward-compatible: degrades gracefully on older agent builds that don't support theprefill_messageskwarg.
Changed
- PR #2547 by @AJV20 — Browser-surface session context is now attached to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts. Context is ephemeral (not saved to history). WebUI progress guidance now preserves the normal Hermes messaging style instead of encouraging extra browser-only status chatter.
Fixed
-
PR #2865 by @AJV20 — New WebUI sessions no longer persist
display.personalityinto per-sessionSession.personality; only explicit personality changes remain durable, preventing stale global display defaults from overriding profile-scoped session behavior. Closes #2845. -
PR #2882 by @ycj — zh-CN (Simplified Chinese) session-time relative labels are now clearer:
${n}分钟前,${n}小时前,${n}天前, and the more natural last-week phrasing上周instead of the previous bare-unit shorthand. Also corrects a small indentation glitch in the zh-TW (Traditional Chinese) locale. (Cherry-picked onto fresh stage withCo-authored-byattribution — original PR was based on stale master.) -
PR #2873 by @Charanis — The WebUI launcher (
ctl.sh+bootstrap.py) now preserves environment variables that have already been resolved by the shell (for exampleHERMES_WEBUI_PORT,HERMES_WEBUI_STATE_DIR,HERMES_WEBUI_HOST) instead of letting a repo-level.envclobber them mid-launch. The.envkeeps working as a default-only source for unset variables, gated byHERMES_WEBUI_PRESERVE_ENV=1set by the launcher subshell.
Notes
- 6,503 pytest passed (sequential mode; the test infrastructure uses a single test server that doesn't support xdist parallelism — known limitation, tracked separately).
- Opus Advisor verdict: SHIP-AS-IS. Zero MUST-FIX. Three SHOULD-FIX items filed as follow-up issues (incomplete locale coverage for notes-drawer i18n keys,
_joplin_api_getURL-token defense-in-depth, prefillsetattrcache-reuse safety net). - #2527 i18n coverage: 10 of the 11 non-en locales currently ship the English string
'Third-party notes'for the drawer header. Since the drawer is default-off, user impact is zero today; follow-up issue tracks proper translations before any default-on transition.
[v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab)
Fixed
-
PR #2827 by @Koraji95-coder — Profile state-sync TLS-vs-thread fix (closes #2762). When switching profiles via the WebUI cookie selector, session token-usage and title were being written to the previously-active profile's
state.dbinstead of the cookie-switched one (sidecar messages + workspace files were already routed correctly; only thestate.dbsidecar sync leaked). Root cause: the cookie middleware sets_tls.profile = '<cookie>'on the HTTP request thread, but the daemon thread spawned in_run_agent_streamingdoesn't inherit that TLS. When the streaming worker callssync_session_usage,_get_state_db → get_active_hermes_home → get_active_profile_namefinds no TLS profile, falls through to_active_profile(the process default), and opens the wrong DB. Fix plumbs the session's ownprofilefield throughsync_session_usage(..., profile=...)and_get_state_db(profile=...)rather than leaning on TLS that doesn't exist on the worker thread. Keeps the existing TLS path for callers that don't passprofile=explicitly, so external integrations don't regress. Also adds defensive_validate_profile_namerejecting../etc, leading-dash, whitespace, and over-long names (prevents path traversal via cookie tampering). Adds 11 regression tests covering explicit-profile honors, multi-thread profile preservation, unknown-profile-name fallback path, invalid-name refusal, and legacy-call-shape compatibility. -
PR #2726 by @starship-s — Boot model-default precedence follow-up (refines #2709). The original v0.51.105 fix correctly preferred the profile/server default on fresh boot, but the implementation had two over-broad side effects flagged in post-merge review: (1) boot unconditionally cleared the persisted browser model state, even on restored sessions where that state should remain authoritative, and (2)
populateModelDropdown()reapplied the default on every repopulate when no session model was present, which clobbered the in-page selection during ordinary dropdown refreshes. Fix is to gate the default-reapply behind an opt-in{preferProfileDefaultOnFreshBoot: true}parameter so boot keeps profile-default precedence, restored sessions keep their session model, and non-boot dropdown refreshes preserve the loaded session's model or the current in-page selection. Browser model state is no longer deleted just because the profile default wins this boot. Expanded the regression test coverage with a Nodeselect/ DOM shim that exercises the realpopulateModelDropdown()path for boot-default, restored-session, current-selection, and removed-model scenarios (+306 LOC tests).
Added
- PR #2673 by @AJV20 — Workspace Artifacts tab (closes #2655). New tab in the workspace panel that lists likely files mentioned, edited, or created during the active session. Prioritizes structured tool-call paths (file_write, edit, patch, etc.), filters dependency/build noise (node_modules,
__pycache__,.git, lock files), and refreshes while live tool calls arrive. Artifact entries open through the existing workspace file preview flow. The MVP is frontend-scoped — backend ingestion uses the existing tool-call event stream rather than a new persistence path — so the maintainer can evaluate the UX before deciding whether artifact tracking should grow into a backend-backed feature. Refreshes alongside the file tree inloadDir()via atypeof renderSessionArtifacts==='function'guard so it composes cleanly with #2716's session stale-guard pattern. Addstests/test_issue2655_frontend.py.
Notes
- In-stage cherry-pick mechanics: All 3 PRs were on stale-base merge-bases (master had advanced through 3 releases). Used
git apply --3wayof each PR's net delta vs its merge-base onto current stage HEAD, then resolved 2 small JS conflicts manually:static/boot.js(#2726 vs post-#2716 master): kept PR's parameterizedpopulateModelDropdown({preferProfileDefaultOnFreshBoot:true})call (the whole point of #2726) on top of master's #2716 hydration flow.static/workspace.js(#2673 vs post-#2716 master): kept master'ssessionId-capture stale-session guard (closure-scoped sessionId check afterawait) AND added PR'srenderSessionArtifacts()call to refresh the new Artifacts tab when the file tree updates. Wrapped intypeof === 'function'guard for defense-in-depth.
- In-stage test fixes: Patched 3 brittle source-string assertions to accept both pre-#2716 and post-#2716 JS shapes (variable names changed during the cherry-pick, semantics preserved). Patched 1 schema mismatch in
tests/test_issue2762_state_sync_profile_kwarg.py::_read_sessionhelper — it queriedsessions.session_idbut the realstate.dbschema hassessions.idas primary key. Fix is mechanical:SELECT id AS session_id+WHERE id = ?so the helper queries the actual schema. - Full pytest: pending re-run on this finalized stage. Touched-tests gate: 41 passed (covering #2827 + #2726 + #2673 surface areas).
- Agent self-verified: profile= kwarg threading on
_get_state_db+sync_session_usage, production call site inapi/streaming.py:5078passesprofile=getattr(s, 'profile', None),populateModelDropdownopt-in parameterization present, boot.js calls withpreferProfileDefaultOnFreshBoot:true, workspacerenderSessionArtifacts()defined + called.
[v0.51.129] — 2026-05-24 — Release DA (stage-batch11 — 4-PR feature + perf batch)
Performance
-
PR #2836 by @v2psv — HTTP/1.1 keep-alive for WebUI responses. Bumps
Handler.protocol_versionfrom the HTTP/1.0 default toHTTP/1.1so browsers can reuse TCP connections across normal API and static-file requests. Adds explicitContent-Lengthheaders to hand-written responses that weren't already using sharedj()/t()helpers. AddsContent-Length: 0to empty redirect / range-error responses. Switches SSE-style streaming endpoints fromConnection: keep-alivetoConnection: close(keep-alive is only safe when the response body is framed; SSE bodies have no fixed length). Significant first-paint / session-open improvements on high-RTT / VPN / proxied paths — author reports ~47% faster first paint and ~30-40% improvements on panel-load flows on a typical remote-host setup.Opus pre-release advisor caught one missing framing site in the on-the-fly folder ZIP download path (
/api/folder/download): the body has no known length, doesn't use chunked encoding, and was relying on HTTP/1.0 connection-close-equals-EOF. Under HTTP/1.1 this would have left clients hanging waiting for the next response after the central-directory bytes finished. Patched inline before tag: addConnection: closeheader to mirror the SSE-endpoint pattern. Opus verified this was the ONLY remaining streaming response in the codebase that needed the header — all 12 hand-written response paths + 8 SSE streams + j()/t() helpers + auth flow were already correctly framed by the PR.
Added
-
PR #2680 by @mccxj — Auxiliary Models settings card in Settings → Preferences. Lets users configure per-task model routing for 9 canonical side-task slots: vision, web extract, compression, session search, approval, MCP tool reasoning, title generation, skills hub, curator. Each slot exposes a provider dropdown + model dropdown plus an "auto (use main model)" / "auto (use provider default)" pair so users can keep aux routing implicit when they don't care. New endpoints:
GET /api/model/auxiliaryreturns current assignments;POST /api/model/setwrites assignments (scope=auxiliaryfor aux slots,scope=mainfor the default chat model) and supportstask="__reset__"to reset all slots back to auto. 16 new i18n keys added across all 12 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr — Turkish translations added in-stage to cover the sibling-PR collision with v0.51.127's Turkish locale baseline). 24 source-level test assertions covering HTML structure, JS logic, i18n parity, and route registration. -
PR #2842 by @AJV20 — PWA polish for installed launches. New
static/pwa-startup.jsis loaded synchronously in<head>before the main UI bundle, so the page knows whether it's running standalone / in-browser / on iOS / offline before first paint. Markspwa-standalone,pwa-browser,pwa-ios,pwa-offline, and short-livedpwa-resumedclasses on<html>. Exposeswindow.HermesPWA.{isStandalone, syncMode, launchAction, promptInstall}helpers and capturesbeforeinstallprompt/appinstalledearly enough that any future install-prompt UI can chain off them. Manifest gains app identity / scope /display_override(window-controls-overlay→standalone→minimal-ui) and a "New conversation" PWA App Shortcut. Service worker pre-caches the startup helper, switches navigation and shell-asset fetches tocache: 'no-store'before falling back to CacheStorage. Boot path wires?source=pwa&action=new-chatto start a fresh chat instead of reopening the last saved session. The viewport meta now setsmaximum-scale=1, user-scalable=nofor native-feel — acknowledged trade-off against WCAG 2.1 1.4.4 (Resize text), intentionally kept for the PWA-installed feel of this user base. -
PR #2794 by @Michaelyklam — Runtime adapter route selection harness. Routes explicit adapter-mode chat starts through
build_runtime_adapter(...)and keepslegacy-directas the default/api/chat/startpath. Continues the #1925 RFC slice progression: this is slice 4e, the default-off chat-start route-selection seam. Returns a bounded501 Not Configuredresponse whenrunner-localis explicitly selected before a supervised runner client exists, instead of silently starting a legacy WebUI-owned run. New_chat_start_response_from_run_start(...)helper whitelists legacy-compatible chat-start response fields and keeps adapter-internalrun_id,status, andactive_controlsout of public responses. Updatesdocs/rfcs/hermes-run-adapter-contract.mdto mark #2744 shipped and define slice 4e.
Notes
- Full pytest: 6,467 passed / 6 skipped / 3 xpassed / 8 subtests passed.
- Opus pre-release advisor reviewed all 7 risk areas (HTTP framing surface completeness, PWA startup ordering, sibling-PR
api/routes.pyinteraction, service worker cache invalidation, viewport-meta trade-off, runtime adapter response shape, locale-counter brittleness). Verdict: 1 MUST-FIX patched inline (folder ZIPConnection: closeheader), 0 inline SHOULD-FIX, 1 follow-up suggested (set_auxiliary_modelcould validatetaskagainstAUX_TASK_SLOTSwhitelist — auth-gated, low severity, filing as follow-up). - Agent self-verified: protocol_version bumped, SSE Connection-close + Content-Length plumbing, Auxiliary Models API surface (config + endpoints + frontend), PWA helpers + manifest shortcuts + display_override, Runtime adapter wiring + whitelisting, i18n parity for all 12 locales on the 16 new aux keys.
- Browser-verified at 1920×1080: Auxiliary Models card renders correctly under Settings → Preferences, 9 task slots with provider/model dropdowns, "Reset all to auto" button, layout consistent with surrounding Settings cards, no clutter or clipping. PWA classes populate on
<html>and HermesPWA namespace populates with 4 helpers as expected. - In-stage commits added Turkish translations for #2680's 16
settings_aux_*/settings_label_auxiliary_models/settings_desc_auxiliary_modelskeys to close the sibling-collision gap with v0.51.127's Turkish locale (#2772). Bumpedtest_auxiliary_models_settings.py::test_all_locales_have_auxiliary_keysfromcount == 11tocount == 12(the locale set grew when Turkish landed).
[v0.51.128] — 2026-05-24 — Release CZ (stage-batch10 — 2-PR perf + correctness batch)
Fixed
-
PR #2830 by @franksong2702 — Pin state synchronization between persisted index and in-memory sessions (closes #2821). Three coupled bugs:
- Bug A (load-bearing):
/api/session/pinpre-snapshot usedgetattr(session, "pinned", False)which always returnedFalsefor dict-backed index rows fromall_sessions(). With ~55-session profiles and LRU eviction churn, pinned counts routinely under-counted because the persisted snapshot was effectively empty. New_session_field(session, field, default)helper resolves both dict-backed and Session-object snapshots correctly. - Bug B: Removed stale client-side
pinLimitReachedshort-circuit in the sidebar action menu that could block pin clicks before the server saw them, based on_allSessionsdata that was stale mid-render. Server now enforces the cap; the toast surfaces the 400 response. - Bug C recovery: Pin/unpin failure path (4xx response from
/api/session/pin) now triggersrenderSessionList()to refresh_allSessionsfrom the server, so the sidebar never gets stuck on stale optimistic state.
Adds
tests/test_issue2821_session_pin_state_sync.py(70 LOC) covering the_session_fieldhelper, the persisted-pinned snapshot, the removedpinLimitReachedreference, and the failure-catch refresh path. Companion fix to #2782 (server-side 404→200 transition for missing CLI-synced sessions) which remains out of scope. - Bug A (load-bearing):
Performance
-
PR #2716 by @dobby-d-elf — Six independent perf nudges plus one correctness fix. nesquena-APPROVED on 2026-05-22 after a deep-review iteration; cherry-picked onto post-v0.51.127 master via 3-way apply with sibling-PR composition resolution.
- Metadata-only
/api/sessioncorrectness fix. Refactors the prior inline reconciliation into_metadata_only_message_summary(sid, profile=None)helper that runs the fullmerge_session_messages_append_only()path. Pre-fix shortcut could over-count stale state.db replay rows that the merge intentionally filters out, producing false "transcript newer than loaded conversation" signals (same bug class as #2705 / #2686). The new helper threadsprofile=through toget_state_db_session_messagesto preserve #2827's TLS-vs-thread profile fix on background-thread reads. - Batched persisted-session checks in sidebar indexing. One
SESSION_DIR.glob('*.json')snapshot per call replaces per-row_index_entry_exists()filesystem lookups duringall_sessions()pruning. Fallback to the per-row helper preserved when the glob raises. - Deferred render-cache signature.
cachedRenderSignaturecloses over the lookup-time signature so the cache STORE path reuses it without recomputing._messageRenderCacheSignature()continues to include the content hash per #2692, preserving the cache-invalidation invariant. - Hoisted assistant tool-activity index. Footer-rendering loop now uses an
O(1)Set lookup instead ofS.toolCalls.some(...)per message — ~30× fewer comparisons for a 100-message conversation with 30 tool calls. - Workspace stale-session guards.
loadDirand_refreshGitBadgeinstatic/workspace.jscapturesessionIdat call time and check it after eachawait(including the catch path of_refreshGitBadge— without it, a late 404 from the previous session would hide the git badge on the current session). - Background model-catalog prime.
_startBootModelDropdownfires fire-and-forget on boot viasetTimeout(0)so the live catalog hydrates without blocking. The existingawaiton the saved-session restore path is preserved (re-applies the saved session's model after hydration so the chip never shows the stale static default). - Failed hydration retryable.
window._modelDropdownReady = null; throw e;lets the next caller refetch instead of being stuck on a permanent failure.
Adds 76 LOC of new tests across
test_session_metadata_fast_path.py,test_webui_state_db_reconciliation.py,test_session_index.py,test_issue1539_provider_removal_dropdown_invalidation.py,test_issue1785_workspace_preview_breadcrumb.py,test_parallel_session_switch.py. - Metadata-only
Notes
- PR #2716 had been pending merge since 2026-05-22 due to a rebase blocker against the rapidly-advancing master (10+ intervening releases). Cherry-picked via
git apply --3wayof the PR's net delta vs its original merge-base (f9302601); 12 of 14 files applied cleanly. Two files had genuine conflicts requiring resolution:api/routes.py(took the PR's helper extraction AND addedprofile=threading to preserve #2827's fix), andtests/test_webui_state_db_reconciliation.py(kept BOTH master's pre-existingtest_api_session_reload_drops_stale_cached_user_tail_after_saved_assistantAND the PR's newtest_metadata_fast_path_matches_reconciliation_for_restamped_replays— they pin different invariants). - Opus pre-release advisor reviewed all 6 risk areas (helper extraction correctness, sibling-PR composition,
Session.loadprofile-safety, test coverage, deferred Bug D, stale-line-number cleanup nit). Verdict: SHIP AS-IS — no MUST-FIX, no inline SHOULD-FIX. Two follow-up issues to file post-tag (Bug D startup index rebuild perf; multi-profile state.db test for theprofile=threading invariant). - Full pytest: 6,434 passed / 6 skipped / 3 xpassed / 8 subtests passed in 2m43s.
- Agent self-verified the producer→consumer channel for
_metadata_only_message_summarywith unmocked invocation against a real session-load path (per skill rule Trigger A + E for mocked-consumer test patterns). - Closes: #2821 (pin state sync), and
get_state_db_session_summarydead-code removed (#2716).
[v0.51.127] — 2026-05-24 — Release CY (stage-batch9 — 7-PR low-risk batch — brick-class Linux + brick-class update apply + composer wide-screen + Turkish locale + MCP toggle + SSE settlement + Windows CI)
Fixed
-
PR #2854 by @nesquena-hermes — Embedded terminal opens then immediately closes with
[terminal closed]on every Linux install past71d8a8fb. Root cause:_terminal_shell_preexec_fnsetPR_SET_PDEATHSIG=SIGTERMon the PTY shell so orphans would die when WebUI crashed, butPR_SET_PDEATHSIGis per-thread, not per-process. WebUI usesThreadingHTTPServer, so each HTTP request runs in its own short-lived worker thread; when the request handler returns and the worker thread exits, the kernel sees the pdeathsig-parent thread has died and SIGTERMs the PTY shell within ~10ms. macOS users were unaffected becauselibc.prctldoesn't exist there. Fix: drop thepreexec_fnentirely; rely onatexit.register(close_all_terminals)for graceful shutdown and explicitclose_terminalfor user-driven close. Addstests/test_terminal_process_cleanup.py::test_pty_shell_survives_when_spawning_thread_exits(real PTY shell spawned via worker thread, asserts shell alive after 500ms grace) plus static-check thatpreexec_fncannot be re-introduced. Closes #2853. -
PR #2855 by @nesquena-hermes — "Update Now" loops for every user past the latest tag (#2846). After #2758 the update check correctly fell through to branch comparison when
HEADhad moved past the latestv*tag, but_select_apply_compare_refstill returnedtags[0]— sogit pull --ff-only v2026.5.16no-op'd, the server bounced, and the banner reappeared unchanged.apply_force_updatehad the same bug except worse (wouldgit reset --hard v2026.5.16and rewind the checkout 254 commits). Fix: extract_head_is_past_latest_tag(path, current_tag)and have both check and apply paths consult it. Opus pre-release review caught a "case D" parameter-asymmetry drift (HEAD on older tag + commits + newer tag exists → predicate flipped between the two callsites) and patched the apply-side predicate to usecurrent_tag+ abehind == 0gate, exactly mirroring the check-side rule. Addstest_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists. Closes #2846. -
PR #2852 by @ai-ag2026 — Chat
stream_endhandler now settles from the persisted session whendonewas not received or replayed, instead of leaving the active pane with liveThinking/ assistant DOM and inflight state projected indefinitely. Reconnect / journal / replay paths can deliverstream_endwithout precedingdone; the prior code treatedstream_endas transport-only close. Duplicate / replayeddoneevents are also made idempotent before completion sound / final render side effects. Opus pre-release review added a post-await race guard inside_restoreSettledSessionto catch the case where a latedoneevent runs the finalize path while the settlement is awaiting the/api/sessionroundtrip. Adds 4 new regression tests acrosstests/test_1694_terminal_cleanup_ownership.pycovering bothstream_end-without-doneand duplicate-donepaths. -
PR #2811 by @Koraji95-coder — Native-Windows startup E2E workflow now self-tests on PR push (closes the post-#2783 gap where Windows-only regressions like the WOW64 ProgramFiles redirect could only be caught after release). Reworked per maintainer feedback to use a stub
hermes_cli/__init__.pynext to a siblinghermes-agent/folder rather thanpip install hermes-agent(which is not on PyPI). Workflow runsstart.ps1for 8s and asserts none of itsWrite-Errorguards fired (no Python, no agent dir, bad port, missinghermes_cli, missingserver.py). PowerShell syntax + path discovery is the testable surface; the server can't actually boot on a stub.taskkillexit-128 swallowed when the stub process is already gone.
Changed
- PR #2812 by @Koraji95-coder — Composer max-width is now responsive on wide displays. Pre-change
.composer-boxhad a fixedmax-width: 780pxthat pinched footer chips (workspace name, model picker, reasoning chip, context ring) against each other on 1440p+ monitors. Switched tomax-width: clamp(780px, 60vw, 1100px)— the 780px floor preserves byte-identical layout at 1280px (Aron's laptop reference width); 1440px viewports gain ~84px (864px composer); 1920px viewports gain ~320px (1100px composer cap). Mobile responsive logic untouched. Single-line CSS change instatic/style.css.
Added
-
PR #2772 by @vaur94 — Complete Turkish (
tr) locale acrossstatic/i18n.js(~1,182 keys matching existing locale coverage). Adds Turkish login page strings inapi/routes.py_LOGIN_LOCALE. Settings → Language now offers Türkçe; speech recognition usestr-TR. Stage build absorbed a sibling-PR i18n collision with #2776 below (9 missing keys:mcp_enable_server,mcp_disable_server,mcp_enabled_toast,mcp_disabled_toast,mcp_toggle_failed,open_in_vscode,open_in_vscode_failed,settings_label_ignore_agent_updates,settings_desc_ignore_agent_updates) — Turkish translations added in-stage so locale-parity test passes. Closes #2537 as superseded (byzuzayli's earlier Turkish PR with narrower scope). -
PR #2776 by @roryford — New
PATCH /api/mcp/servers/{name}endpoint accepts{"enabled": bool}, writesmcp_servers.<name>.enabledtoconfig.yaml, callsreload_config(), returns{"ok": true, "name": "<name>", "enabled": <bool>}. Each MCP server row in the panel now shows a clickable Enabled/Disabled toggle. Also fixes a pre-existing bug:_handle_mcp_server_deleteand_handle_mcp_server_updatewere defined at line ~11656 but never wired into the HTTP router — DELETE wired intohandle_delete, PUT wired via newhandle_put/do_PUTinserver.py. CORS preflightAccess-Control-Allow-Methodsupdated to includePUT(Opus pre-release review nit). Adds 5 i18n keys to all 11 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr via in-stage parity fix). 7 new tests covering enable, disable, 404, empty-name, missing-field, response payload, URL-decoded names.
Notes
- Two PRs (#2854, #2855) are brick-class fixes — every Linux install was unable to use the embedded terminal, and every install past the latest agent tag was stuck in an Update Now loop. They land in the same low-risk batch as cosmetic / locale / CI changes because both fixes are mechanical, well-tested, and the brick-class severity made deferring impossible.
- Opus pre-release advisor reviewed all 5 risk areas (PR_SET_PDEATHSIG removal, update apply path symmetry, MCP toggle wiring, composer clamp, stream_end settlement). 1 MUST-FIX + 3 SHOULD-FIX all addressed inline before tag. Net: +69/-9 across 5 files for the Opus fixes.
- Full pytest: 6,424 passed / 6 skipped / 3 xpassed / 8 subtests passed.
- UX evidence for #2812 captured at 1280/1440/1920/mobile (iPhone 14 emulation); Telegram-approved.
- File a follow-up issue for pdeathsig-on-supervisor-thread hardening (#2854 deferred Option B) and French-locale
open_in_vscodeparity gap (predates this batch, Opus advisor flagged).
[v0.51.126] — 2026-05-24 — Release CX (stage-batch8 — 2-PR low-risk batch — kanban markdown + live activity timeline)
Added
- PR #2819 by @humayunak — Kanban task descriptions and comments now render as full GFM Markdown instead of plain-text.
_kanbanRenderMarkdown()instatic/panels.jsrewrote the line-per-<p>wrapper as a block-parsing pipeline supporting headings, code blocks (fenced + indented), ordered/unordered lists, task lists with checkboxes, tables, blockquotes, horizontal rules, and strikethrough._kanbanRenderMarkdownInline()gains~~strikethrough~~and tightens the italic regex to avoid mid-identifier*matches. CSS adds table borders, code-block background, checkbox styling, blockquote accent, and heading sizing scoped to.hermes-kanban-md. Frontend-only, scoped to the kanban panel. 95 existing kanban tests pass.
Changed
- PR #2847 by @AJV20 — Live chat Activity disclosure now shows observable run telemetry instead of an empty
Thinking…placeholder when no reasoning text is available (squashed from 2 author commits). New baseline rows surface run-start metadata (model, profile),Waiting on model/Waiting on tool result/Working for …status, tool start/finish in the timeline alongside the existing compact tool cards, and aNo recent activity for …state after quiet periods. Frontend-only telemetry derived from existing stream events — no new backend event types. Addstests/test_live_activity_timeline.py(4 tests). The compact/calm default Activity disclosure is preserved; it only becomes informative when expanded.
[v0.51.125] — 2026-05-24 — Release CW (stage-batch7 — 10-PR low-risk batch — UI/UX polish + bug fixes + diagnostics)
Fixed
-
PR #2839 by @tn801534 — Kanban worker log endpoint constructed URLs with a double query string (
?board=<slug>?tail=65536) when a non-default board was active. The frontend was appending?tail=65536directly to a URL that already had?board=...from_kanbanBoardQuery(). Fix: pass{tail: 65536}as theextraargument to_kanbanBoardQuery()so it composes both params into a single valid query string. One-line, narrow scope. -
PR #2832 by @franksong2702 — Malformed HTTP request logging in
server.pyfalls back to"-"for missingcommandorpathinstead of raisingAttributeError. Defensivegetattr(self, 'command', None) or '-'matches the pattern already used for_req_t0elsewhere in the handler. Addstests/test_issue2775_log_request.pycovering the malformed-request-before-path-assigned case. -
PR #2818 by @humayunak — Approval and clarify cards no longer steal focus from the composer textarea (
#msg) when the user is mid-type.showApprovalCard()andshowClarifyCard()now guard thefocus()call ondocument.activeElement !== $('msg'), matching the pattern already used elsewhere for focus-sensitive paths. The clarify card also moves the focus call out ofsetTimeoutfor snappier UX. Silently dropped keystrokes during streaming are eliminated. -
PR #2826 by @Koraji95-coder — Composer footer chip wraps no longer overlap at narrow widths (closes #2740). The five chip wraps (
.composer-profile-wrap,.composer-ws-wrap,.composer-model-wrap,.composer-reasoning-wrap,.composer-toolsets-wrap) hadflex: 0 1 auto+min-width: 0so they would compress past their content's natural width when the composer narrowed, causing visual overlap of the profile / workspace / model / reasoning chips. Switched toflex: 0 0 autovia a single grouped selector. Each chip now keeps its natural width and the existingoverflow-x: autoon.composer-lefthandles overflow via horizontal scroll. Default-width layout unchanged; only affects the overflow regime. Mobile-specific rules (alreadyflex: 0 0 auto) untouched. -
PR #2829 by @franksong2702 — Workspace Markdown previews fall back to plain text for very large files (>64 KB or >1500 lines) instead of synchronously running the full rich Markdown renderer on the browser main thread, which could lock up the tab for several seconds on multi-megabyte
.mdfiles. Plain-text preview shows file size + line count in the status line so users know why rich rendering was bypassed; Edit mode still shows raw content as before. Closes #2823. Supersedes #2828 (same scope, less polished). -
PR #2837 by @franksong2702 — CSRF rejections now distinguish origin/proxy mismatches from expired session tokens, so provider-key removal and other protected requests show actionable diagnostics instead of the generic "Cross-origin request rejected" error. Adds
tests/test_issue2572_csrf_diagnostics.pycovering both failure modes. -
PR #2834 by @franksong2702 — Workspace Markdown
mailto:andtel:links now render as clickable links, and sandboxed HTML preview links open outside the iframe (via injected<base target="_blank">) instead of navigating the preview into a browser-blocked page. Addstests/test_issue2768_workspace_links.py. -
PR #2838 by @franksong2702 — Tasks panel surfaces a warning when the Hermes gateway is not configured or not running, so Docker users know scheduled jobs need the gateway daemon to tick while away. The single-container Docker boundary is also clarified in
docs/docker.md. Addstests/test_issue2785_gateway_cron_guidance.py.
Added
-
PR #2820 by @tangerine-fan — Clarify user choice is now echoed as a visible message in the conversation transcript. After the user responds to a clarify prompt, a synthetic user message with the chosen value is inserted into
S.messages(marked_clarify_response: trueso downstream consumers can filter if needed). Previously the choice was only visible in the transient clarify card; now the chat history preserves the decision. -
PR #2843 by @AJV20 — New Settings preference "Ignore Agent updates" keeps WebUI update notices, banners, and update actions enabled while suppressing Hermes Agent update checks. Default
False(current behavior). Useful when running an unreleased agent build or pinning to a specific agent commit.
[v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening)
Added
- PR #2805 by @Koraji95-coder —
start.ps1: expand hermes-agent candidate paths for Windows installers. The launcher now searches$env:USERPROFILE\.hermes\hermes-agent, the dev-checkout sibling, and the Windows installer roots ($env:LOCALAPPDATA\hermes\hermes-agent,${env:ProgramW6432}\hermes\hermes-agent,${env:ProgramFiles}\hermes\hermes-agent,${env:ProgramFiles(x86)}\hermes\hermes-agent) withSelect-Object -Uniqueto collapse WOW64 ProgramFiles redirection collisions on 32-bit PowerShell processes. Adds-PathType Containerto theHERMES_WEBUI_AGENT_DIRguard so a file namedhermes_clidoesn't false-positive. Null-guards${env:ProgramFiles(x86)}for constrained environments where it's missing. Zero impact on Linux/macOS — file isstart.ps1, never loaded bystart.shorbootstrap.py.
Documentation
- PR #2806 by @Koraji95-coder — Native Windows venv path corrected in
start.ps1doc-comment andREADME.md. The previous text suggested "run bootstrap.py inside WSL2 once to create the venv, then this script can use that venv" — but a WSL2-created venv isvenv/bin/python(ELF) and cannot be invoked by native Windows Python. The corrected guidance is to create a Windows venv natively (python -m venv venvfrom PowerShell), thenstart.ps1auto-discoversvenv\Scripts\python.exe. WSL2 remains useful as a parallel install for the fullbootstrap.py+ Linux runtime path.
Hardened
- PR #2807 by @Koraji95-coder —
start.ps1:HERMES_WEBUI_PORTenv-var parsing uses[int]::TryParse+ range guard (1-65535) instead of a bare[int]cast that threwInvalidCastExceptionwith no context on typos or accidental shell expansion. Server-process exit code is captured into$script:serverExitCodeand emitted viaexitAFTER thetry/finallycleanup, soPop-Locationalways runs (avoids leaving the caller stuck at$RepoRootin interactive or dot-sourced sessions). Also drops a non-functional@argssplat that PowerShell doesn't populate under[CmdletBinding()]— the launcher's existing use case is env-var-driven, no pass-through args needed.
[v0.51.123] — 2026-05-24 — Release CU (stage-batch5 — 2-PR low-risk batch — gzip+ETag static caching / Open in VS Code)
Performance
- PR #2779 by @v2psv — Static asset serving negotiates gzip, emits ETags, and uses
immutablecache headers for fingerprinted URLs._serve_static()inapi/routes.pypreviously sent every/static/*response withCache-Control: no-storeand noContent-Encoding, so a page reload over a slow link re-downloaded the full ~2.4 MB JS+CSS shell on every visit. The fix layers three changes inside the same function: (1) gzip the body when the client opts in viaAccept-Encoding, gated to compressible MIME types and files >1 KB; (2) emit a weak ETag derived from(size, mtime_ns)and short-circuit conditional GETs to304 Not Modified; (3) sendCache-Control: public, max-age=31536000, immutablewhen the URL carries a non-empty?v=…fingerprint (the__WEBUI_VERSION__token already substituted by the index template and referenced fromstatic/sw.js'sSHELL_ASSETS), falling back topublic, max-age=300otherwise. Raw bytes, compressed bytes, and ETags are cached in-process keyed by(size, mtime_ns)so a redeploy is picked up without a restart, while missing/random paths never enter the cache and image/font types skip gzip to avoid wasted CPU on already-compressed payloads. Measured against an asyncio TCP proxy that injects RTT + bandwidth caps for representative VPN scenarios: cold loads improve 2.7-3.1× (e.g. 80 ms RTT / 10 Mbps WireGuard goes from 4.0 s to 1.3 s), warm reloads improve 3.3-4.0× via 304 responses, and bytes-on-the-wire drop 74% on cold loads. Loopback (already fast) still benefits 2.4×. Scope is strictly/static/*:/api/*,/stream,/,/index.html,/session/*, and login/auth routes are served by independent handlers and continue to sendno-storeexactly as before — no change to CSRF, session payloads, SSE buffering, or login flows. 11 regression tests pin gzip negotiation, ETag/304 round-trip includingVary: Accept-Encoding, fingerprint-driven cache policy including empty?v=, image/tiny-file skip rules, redeploy invalidation, and the existing path-traversal sandbox.
Added
- PR #2787 by @munim — "Open in VS Code" action in workspace file browser (resolves #2735). Right-clicking any file, folder, or the workspace root now shows an Open in VS Code menu item alongside the existing Reveal in File Manager action. The action calls a new
POST /api/file/open-vscodeendpoint which resolves the workspace-relative path via the existingsafe_resolvetraversal guard, then launches VS Code viasubprocess.Popen(fire-and-forget, consistent with_handle_file_reveal). The endpoint resolves the executable viashutil.which()first, then falls back to a hardcoded list of common install locations (macOS:/usr/local/bin/codeand the app-bundle CLI; Linux:/usr/bin/code,/snap/bin/code; Windows:%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmdand the%PROGRAMFILES%variants) so the action works even when the server process inherits a minimal PATH. Configurable via a new optionalvscodeblock inconfig.yaml:commandoverrides the defaultcodeexecutable;host_path_prefix+container_path_prefixenable Docker/container host-path translation. If the command cannot be found anywhere, a descriptive error is returned instead of a bare OS error. i18n keysopen_in_vscodeandopen_in_vscode_failedadded with full translations in all 10 locales. 26 new tests intests/test_2735_open_in_vscode.pypin source wiring, command-resolution logic, i18n completeness, translated strings, and live endpoint error paths.
[v0.51.122] — 2026-05-24 — Release CT (stage-batch4 — 4-PR low-risk batch — stale cache tail / inflight UI / segment flush / reasoning accumulator)
Fixed
-
PR #2802 by @ai-ag2026 — Drop stale inactive cached user tails when
/api/sessionreloads a conversation whose saved sidecar already ends on an assistant answer. Supersedes #2733 (held due to async-compression interaction): the new guard adds alen(cached_messages) <= len(disk_messages)filter so it never fires when the cache has genuine new concurrent edits beyond the disk state — only when the cache has an unsaved user row past the saved assistant tail. Addsapi/models._inactive_cache_tail_needs_disk_check()+_cache_has_stale_unsaved_user_tail()helpers and 5 new tests intests/test_webui_state_db_reconciliation.py. Previously-held testtest_session_compress_async_reports_stale_session_guardnow passes (verified). Closes umbrella #2361 partially. -
PR #2796 by @ai-ag2026 — Clear stale inflight UI state before starting a new send so blocked composer busy-state from failed/incomplete prior turns doesn't divert new turns into the invisible queue. Five-commit squashed fix: (1) drop stale optimistic sidebar rows once canonical session data arrives, (2) clear stale busy state before send via
_clearStaleBusyStateBeforeSend(), (3) preserve server idle rows over stale optimistic local rows, (4) let/api/chat/startsurvive non-fatal pre-start UI errors via_runOptionalPreStartUiStep(), (5) keep those warnings console-only instead of throwing. Adds_shouldKeepLocalOnlyOptimisticSessionRow()instatic/sessions.jsand 8 new tests intests/test_inflight_send_start_race.py. Closes #2795. Authorship preserved via--author. -
PR #2777 by @b3nw — Flush pending render before segment reset at tool/interim_assistant boundaries so live tokens that arrived in the 66ms rAF throttle window don't get lost from the DOM when
_resetAssistantSegment()clearsassistantBody. New_flushPendingSegmentRender()helper writes viasmd,renderMd, orescfallback (same paths as_doRender) only when_renderPendingis true. Completed transcripts were never affected —renderMessagesrebuilds from the fullassistantTextaccumulator ondone. Addstests/test_issue2713_streaming_segment_flush.py. Closes #2713. -
PR #2778 by @b3nw — Reset reasoning accumulator per turn and prefer
reasoning_contentoverreasoningon read. Two related bugs: (1)reasoningTextwas initialized once when the SSE stream opened and never reset between turns, so thedoneevent would assign the union of every turn's reasoning to the last assistant message in multi-turn agent sessions; now reset at both turn boundaries (tool+interim_assistant). (2)static/ui.js renderMessagespreferredm.reasoning(potentially corrupted by bug 1) overm.reasoning_content(the clean per-turn backend value); the fallback now readsm.reasoning_content || m.reasoning. Updatestests/test_streaming_race_fix.pyto scope the reconnect-accumulator guard to the_wireSSEpreamble only (turn-boundary resets inside event listeners are intentional). Addstests/test_issue2565_reasoning_accumulation.py. Closes #2565.
[v0.51.121] — 2026-05-24 — Release CS (stage-batch3 — 4-PR low-risk batch — state.db merge / display counts / compression marker / Windows launcher)
Fixed
-
PR #2788 by @Carry00 — Prevent
state.dbmessages being silently dropped during sidecar merge. Two related bugs were combining to discard historical messages: (1)get_state_db_session_messages()was selectingrole, content, timestampbut NOTid, so every row was assigned a("legacy", ...)merge key instead of("message_id", ...); (2) when a WebUI-origin session was continued via another Hermes surface (Gateway, CLI), the reader was always hitting the active profile'sstate.dbrather than the session's own profile. Symptom: a 189-message session showed only 50 in the WebUI. Fix: includeidin the SELECT when the column exists, and accept an optionalprofile=arg so cross-profile reads use the right database. Both callers inapi/routes.py handle_getnow threadprofile=getattr(s, 'profile', None)through. -
PR #2797 by @ai-ag2026 — Align messaging session display counts with deduped display messages. The
message_countreturned by/api/sessionis the display coordinate space used for pagination and the header badge. Messaging-threadstate.dbmetadata can carry raw duplicate transport rows (blank assistant separators between Discord/Slack thread turns) that_merged_session_messages_for_display()intentionally dedupes for rendering. The advertised count was the raw row count, so the frontend expected phantom messages after dedupe —len(display_msgs) < message_counttriggered "load older" UI states that immediately returned nothing. Fix:raw["message_count"] = _merged_message_countfor messaging sessions, computed from the same merge that produced the displayed messages. Addstests/test_gateway_sync.py::test_messaging_session_message_count_matches_deduped_display_messagescovering the regression. -
PR #2803 by @simjak — Compression-summary cards no longer use ordinary tool output that merely mentions context compression. The streaming auto-compression path was using a local broad substring matcher that fired on any message containing the strings "context compaction" / "context compression" / "context was auto-compressed" / "active task list was preserved across context compression", including skill/tool JSON output and ordinary user discussion about compaction. The strict predicate at
api/compression_anchor._is_context_compression_marker()was already correctly scoped to synthetic marker prefixes on non-tool messages. Fix: expose the strict predicate asis_context_compression_marker()(public name) and routeapi/streaming._is_context_compression_markerthrough it as a backward-compatible alias. Tool/skill output that mentions compression no longer seedscompression_anchor_summarycards.
Added
- PR #2783 by @Koraji95-coder — Native Windows launcher and community-guide README link (squashed from 3 commits).
start.ps1is a PowerShell equivalent ofstart.shthat bypassesbootstrap.py'sensure_supported_platform()refusal and invokesserver.pydirectly on native Windows. It mirrorsstart.sh's discovery (load optional.envwith the same readonly-var filter forUID/GID/EUID/EGID/PPID, find Python viaHERMES_WEBUI_PYTHONenv →python3→python→py, validateHERMES_WEBUI_AGENT_DIRon disk before use, prefer the agent'svenv\Scripts\python.exe, setHERMES_WEBUI_HOST/HERMES_WEBUI_PORT/HERMES_WEBUI_STATE_DIR/HERMES_HOMEdefaults). The README adds a community-maintained native Windows setup section pointing to @markwang2658'shermes-windows-native-guideandhermes-windows-nativerepos with the documented memory delta (~330 MB native vs ~1080 MB WSL2+Docker). Closes both halves of #1952. Assumes Python + agent venv are already set up — first-time setup still needs WSL2 once to create the venv (bootstrap.pystill refuses on native Windows).
[v0.51.120] — 2026-05-24 — Release CR (stage-batch2 — 3-PR low-risk batch — Bedrock provider / update check past-tag / CORS preflight)
Added
- PR #2786 by @munim — Surface AWS Bedrock as a configurable provider in the WebUI model picker.
api/config.pyregisters"bedrock": "AWS Bedrock"inPROVIDER_LABELS, adds 6 default Bedrock model IDs (Claude Opus 4.7 / 4.6 / 4.5, Sonnet 4.6 / 4.5, Haiku 4.5) toDEFAULT_MODELS["bedrock"], and teaches_build_configured_model_badges()to detect Bedrock when bothAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYare present (IAM-style auth, not single-API-key). Static fallback list is overridden at runtime byhermes_cli.models.provider_model_ids("bedrock")when the live AWS model list is reachable. Addstests/test_issue2720_bedrock_model_picker.pywith 11 test cases covering registry, defaults, env-detection, and runtime override. Resolves #2720.
Fixed
-
PR #2789 by @munim — Update check no longer falsely reports "Up to date" when HEAD has moved hundreds of commits past the latest tag. The hermes-agent repository keeps committing to master between tagged releases, and the old
_check_repo_release()returnedbehind=0(sincecurrent_tag == latest_tag) and stopped — so the user saw "Up to date" while the working tree was hundreds of commits behind. The fix: whenbehind == 0, rungit describe --tags --always; if the result contains the-N-gSHAsuffix (HEAD past tag), returnNoneso_check_repo_branch()runs and reports the real commit gap. Adds 8 new test cases intests/test_updates.pycovering past-tag detection, equal-tag-and-HEAD pass-through, untagged-repo behavior, and the agent-cadence #2653 scenario. Resolves #2653. -
PR #2790 by @weidzhou — Add
do_OPTIONS()handler inserver.pyso CORS preflight requests return200 OKwith appropriateAccess-Control-Allow-*headers instead of501 Not Implemented. Browsers sending a preflight OPTIONS for cross-origin API calls previously hit the BaseHTTPRequestHandler default and the entire CORS exchange was blocked. The handler narrowly responds only to OPTIONS — no broader CORS posture change to other endpoints. Resubmit of closed #2750 (which bundled unrelated session-index changes); this PR is the minimal preflight-only split that @nesquena-hermes and @AJV20 requested.
[v0.51.119] — 2026-05-24 — Release CQ (stage-batch1 — 3-PR low-risk batch — tool cards / 404 recovery / Hepburn skin)
Fixed
-
PR #2801 by @ai-ag2026 — Preserve settled tool cards across stream completion. The streaming
donehandler now derives anchored settled tool cards from message-level tool metadata (message.tool_calls,message._partial_tool_calls, orcontent[].type === 'tool_use') when present, instead of unconditionally falling back to session-leveld.session.tool_calls. The fallback could overwrite the per-message anchors after pagination/windowing because session-level coordinates may not line up with the active message array, causing tool cards to disappear on the finaldonerender. Fixes #2613, complements #2777 (which covers pending-segment flushes at tool/interim boundaries). Addstests/test_streaming_markdown.py::test_done_handler_prefers_message_tool_metadata_for_settled_renderto lock the precedence. -
PR #2808 by @chouzz — Recover deterministically from boot-time
/session/{id}404s (Option A for #2798). WhenloadSession()hits a 404 during boot-time restore (!currentSid),static/sessions.jsnow always clearslocalStorage['hermes-webui-session'], strips the stale URL withhistory.replaceState(null, '', '/'), and rethrows so boot falls through to empty-state recovery. The previous condition required the stale id to matchlocalStorage, so a stale/session/{id}URL with emptylocalStorage(post state-reset) could leave the UI stuck on "Session not available in web UI." Fixes #2798.
Added
- PR #2799 by @gavinssr — Add Hepburn skin (magenta-rose palette derived from the Hepburn TUI theme). Full light + dark palette under
:root[data-skin="hepburn"]/:root.dark[data-skin="hepburn"], registered instatic/boot.js_SKINSand whitelisted instatic/index.html's inline skin gate. As part of this PRloadSettingsPanel()instatic/panels.jsnow preferslocalStorage.getItem('hermes-skin')oversettings.skinwhen populating the skin picker (DOM truth → settings fallback), so the picker matches what the user actually sees after the inline gate has already resolved legacy aliases.
[v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored)
Fixed
- PR #2773 by @nesquena-hermes — fix(chat): rename
_inflightStateLimits()instatic/ui.jsto_getInflightStateLimits()so it no longer collides with thewindow._inflightStateLimitsconfig object set instatic/boot.js. Closes #2771. The v0.51.117 in-flight-recovery quota fix (#2766) declared a top-level helper with the same name as a window-attached config object; because top-levelfunction foo(){…}declarations in classic (non-module) scripts attach towindow, boot.js'swindow._inflightStateLimits = {…}assignment overwrote the function reference before any session could send. Every new chat broke on firstsend()withTypeError: _inflightStateLimits is not a function, leaving v0.51.117 effectively unusable. Renamed the function only (the public-ish window key is unchanged) and updated all 4 call sites. **New regression testtests/test_window_function_collision.pyscans every static JS file for top-levelfunction NAME()declarations whose name is also the target ofwindow.NAME = {…}/= <number>, the exact shape that broke #2715 (_pinnedSessionsLimitin v0.51.106) and #2771 (_inflightStateLimitsin v0.51.117). The test fails loudly with a precise file:name diagnostic if the bug class returns. Verified end-to-end against the live browser before merge:_getInflightStateLimits()returns the limits object andsaveInflightState()persists to localStorage without throwing.
[v0.51.117] — 2026-05-22 — Release CO (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe)
Fixed
- PR #2766 by @george-andraws — Make in-flight recovery storage quota-safe so a full browser
localStoragebudget no longer blocks chat submission withQuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'hermes-webui-inflight' exceeded the quota.The fix has four parts: (1) compact recovery snapshots before writing to keep only recent messages, tool calls, uploaded-file metadata, stream id, and updated timestamp; (2) truncate individual large strings per field (60000chars default) and prune the serialized payload (1500000chars default); (3) catch quota errors specifically onmarkInflight(), drop the largerhermes-webui-inflight-statekey, then retry the tiny marker write; (4) expose 5 new configurable settings (inflight_state_max_sessions,_max_messages,_max_tool_calls,_max_string_chars,_max_json_chars) with int-range validation so operators can tune the budget. Self-healing on the very first chat submit after upgrade for users with already-quota-exhausted storage — no manual reload required. Graceful degradation if storage is still too full even after compaction (clears recovery snapshots but never blocks chat submit).
[v0.51.116] — 2026-05-22 — Release CN (stage-pr2676 — 1-PR — per-skill enable/disable toggle in Skills panel, CLI-parity with hermes skills config)
Added
- PR #2676 by @lucasrc — Each skill in the Skills panel now has a toggle pill (enabled/disabled) so users can turn individual skills on or off directly from the WebUI without editing
config.yaml. Achieves parity with the existinghermes skills configCLI subcommand (interactive TUI that togglesskills.disabledin config). The disabled state is mirrored through toskills.platform_disabled.webuiwhen that key is present. Disabled skills remain visible in the panel (muted viaopacity: .45) instead of being filtered out, so users can re-enable them later. New endpoint:POST /api/skills/togglevalidates the skill exists in the filesystem before mutating config, wraps the YAML read-modify-write under the existing_cfg_lockfor thread safety, and callsreload_config()so the change takes effect immediately. Toggle pill uses theme variables (--accent-bg-strong,--accent,--border,--muted,--accent-text) so it adapts automatically to each skin: gold for default, red for ares, blue for poseidon, purple for sisyphus, grey for mono — verified empirically across light + dark variants. i18n keys (skill_enabled,skill_disabled,skill_toggle_failed) translated across all 10 locales. Default-state safety: fresh installs (noskills.disabledkey in config) returndisabled: Falsefor every skill — no regression risk for new users.
[v0.51.115] — 2026-05-22 — Release CM (stage-pr2731 — 1-PR — clarify prompt collapse/expand with chevron-icon polish)
Added
- PR #2731 by @Michaelyklam — Clarification prompts now include a compact Collapse/Expand control so users can temporarily shrink a blocking decision card and reread the chat context behind it before responding. The toggle uses Lucide chevron icons (chevron-down expanded → click to collapse, chevron-up collapsed → click to expand) and a small circular pill matching the existing composer-button design language. The collapsed card sits cleanly above the composer at every tested viewport (desktop 1920×1080, mobile iPhone 14 390×844) without edge clipping. New clarification prompts still open expanded so users notice them.
[v0.51.114] — 2026-05-22 — Release CL (stage-407 — 1-PR — update-check recovery from remote re-tags)
Fixed
- PR #2758 by @nesquena-hermes — fix(updates): pass
--forcetogit fetch --tagsinapi/updates.pyso the WebUI's release-tracking update check can recover from a remote re-tag (e.g. a release tag that was force-pushed to a new commit after a squash-merge). Without--force, plaingit fetch origin --tagsreturns! [rejected] vX.Y.Z (would clobber existing tag)and the entire update path (check, force-apply, normal-apply) jams indefinitely — neither the periodic check nor manual "Check now" nor the Update button can recover. Three fetch call sites were patched (_check_repo,apply_force_update,apply_update) to use--tags --force; the WebUI never pushes tags, so deferring to the remote's view is the right contract. Closes #2756.
[v0.51.113] — 2026-05-22 — Release CK (stage-406 — 1-PR — composer model picker lag fix + hard-refresh recovery)
Fixed
- PR #2743 by @franksong2702 — Composer model picker now opens immediately from the existing static option list while the dynamic
/api/modelscatalog hydrates in the background, instead of blocking the click on the catalog request. A just-selected session model also survives a hard refresh that interrupts the async/api/session/updatePOST: the selection is staged intosessionStorage(keyed by session_id, 10-minute TTL) before the async update flies, andloadSession()re-applies the pending pick on next session restore and retries the persistence call. Tests pin the new ordering: visible picker render beforeawait, pending-state save beforeawait api('/api/session/update'), and pending-state replay before the firstsyncTopbar()projects server metadata.
[v0.51.112] — 2026-05-22 — Release CJ (stage-405 — 1-PR — session model authoritative across restore)
Fixed
- PR #2737 by @ai-ag2026 — Keep the session model authoritative when a restored session is reactivated. Previously, stale browser-cached picker state could override an active conversation's model in four scenarios: (1) on initial boot when
localStoragehad a different model preference than the active session, (2) on hard refresh whenS._bootReadyrevealed the composer chip before the live catalog hydrated, (3) when the session's model wasn't in the current provider catalog (the static/default fallback silently rewroteS.session.model), (4) when starting a new session whose model wasn't in the static HTML dropdown. The fix:loadSession()now requestsresolve_model=1so backend normalization happens synchronously with metadata; boot model hydration prefers the active session overlocalStorage; hard refresh re-runs the model dropdown hydration before_bootReady; a new_ensureModelOptionInDropdown()helper injects adata-custom='1'option for models not in the catalog instead of silently rewritingS.session.modelto the default. 100 LOC of new pytest regression coverage pinning each behavior.
[v0.51.111] — 2026-05-22 — Release CI (stage-404 — 1-PR — keep state.db replays out of sidecar tail)
Fixed
- PR #2746 by @ai-ag2026 — Prevent replayed state.db rows from being appended after an already-correct sidecar transcript tail.
merge_session_messages_append_only()previously tried to skip state.db rows replaying the sidecar, but two edge cases leaked through: (1) the final row of a replayed sidecar prefix was not skipped because the replay index had reached the sidecar sequence length, and (2) a replayed middle segment was not considered prefix replay, so old state.db rows could be appended after the saved assistant tail. That made/api/sessionappear to end on an old user prompt even when the saved sidecar already ended on the real assistant answer. The fix tracks per-(role, content) visible-occurrence counts in the sidecar and uses that as a replay budget when comparing state.db rows; legitimate repeated messages from state.db are still preserved._has_visible_duplicate()is kept as a thin wrapper around the new_matching_visible_duplicate()for backwards compatibility. Regression test covers both full-replay and middle-segment replay shapes.
[v0.51.110] — 2026-05-22 — Release CH (stage-403 — 2-PR batch — default personality from config + sort configured providers to top)
Added
- PR #2747 by @s010mn —
new_session()now readsdisplay.personalityfromconfig.yamlas the default for new conversations. Previously every new session started withpersonality=Noneand required an explicit/personality <name>slash command. Values'none','default','neutral', and empty string are treated as no-personality. Case-insensitive —personality: Talebnormalizes totaleb. Config-read is wrapped in try/except so malformed config falls back to the prior behavior rather than crashing session creation. The/personalityslash command still works for per-session overrides. - PR #2683 by @jasonjcwu — Sort providers so configured/custom entries appear first in both the model picker dropdown (
api/config.py::get_available_models) and the Settings providers panel (api/providers.py::get_providers). Priority order: (1) the active provider, (2)custom:*providers fromcustom_providersconfig, (3) providers with configured API keys (credential pool orconfig.yaml), (4) all others alphabetical. Eliminates scrolling past 25+ unconfigured providers to find the one in active use.
[v0.51.109] — 2026-05-22 — Release CG (stage-402 — 2-PR batch — sidebar action menu click stability + chat panel sidebar resync after navigation)
Fixed
- PR #2741 by @ai-ag2026 — Keep the sidebar conversation actions menu open while session-list refreshes, stream updates, or panel-resync repairs arrive. Previously the three-dot menu beside chat titles could be torn down before the user finished clicking it because
renderSessionListFromCache()rebuilt the row DOM (and the fixed-position menu's anchor) without checking whether the menu was open. The new early-return at the top of the refresh keeps the menu stable; destructive menu actions explicitly close the menu before they fire, so dismissal still works as expected. - PR #2736 by @ai-ag2026 — Resync the chat sidebar after returning from Settings/Logs/other panels. The session list is virtualized, and the browser can clamp the preserved scrollTop during a panel transition; without a render after the chat view is visible again, stale virtual spacer/header DOM remained until the next manual scroll. The new
_resyncChatSidebarAfterPanelSwitch()helper runs one guardedrequestAnimationFrameafter the panel becomes visible, bails if a rename input or action menu is open, and uses no polling.
[v0.51.108] — 2026-05-22 — Release CF (stage-401 — 4-PR batch — session-index dedup + update-check diagnostic redaction + handoff-summary sqlite connection leak fix + Slice 4d runner route gate docs)
Added
- PR #2744 by @Michaelyklam — #1925 RuntimeAdapter RFC follow-up: marks the
runner-localselection seam shipped in v0.51.105 and defines the next Slice 4d supervised runner route gate before live chat can move onto a runner backend. The gate keeps runner routing default-off, preserves legacy fallback and public response shapes, and carries the Runtime API gap matrix forward so active-run discovery, session→run lookup, command metadata, artifacts, and provider/tool routing do not become private WebUI runtime replicas.
Fixed
- PR #2738 by @weidzhou —
_write_session_index()full-rebuild path now deduplicates entries bysession_id. When old-formatsession_*.jsonfiles coexist with WebUI-formatxxx.jsonfiles sharing the samesession_id, the index produced duplicate Vue:keyentries and crashed the frontend with a blank page. The lazy rebuild now usesdict[session_id → compact_entry]keyed on session_id, with the highermessage_countentry winning on conflict. - PR #2730 by @ashbuildslife — Sanitize git fetch diagnostics before returning update-check errors to the browser. New
_sanitize_git_diagnostic()inapi/updates.pystrips credentialed URL userinfo (user:token@host), GitHub token shapes (ghp_*,gho_*,github_pat_*), and secret-looking query parameters (?access_token=,?token=,?password=,?auth=,?key=), then caps the message at 300 characters. Empirically verified that plainhttps://github.com/owner/repo.gitURLs and SSH-stylegit@host:owner/reporemotes pass through untouched — only credentialed shapes are redacted. Update-check failure context (e.g.Authentication failed, network errors) is preserved. - PR #2742 by @Isla-Liu — Per-turn SQLite connection leak in handoff-summary path (#2233). Two functions on the
/api/session/handoff-summaryhot path were openingsqlite3.connect(...)inside a barewithstatement, which commits the transaction at scope exit but does NOT close the connection. Per-turn invocations accumulatedstate.db/state.db-walfile descriptors and CPython heap pages on long-lived worker threads, surfacing as multi-GB VmRSS / 6× duplicated state.db fds on long-running installs. Wrapped both call sites withcontextlib.closing(...)(already imported and used at 7 other sites in the same files) so the connection is closed deterministically:api/models.py::count_conversation_roundsandapi/routes.py::_persist_handoff_summary_to_state_db. Regression test loops both functions 20× against a tmpstate.dband asserts/proc/<pid>/fdcount does not grow more than 2. Live soak: fd growth = 0, VmRSS growth = 0 KB across 20 POSTs.
[v0.51.107] — 2026-05-21 — Release CE (stage-400 — 8-PR batch — pinned-sessions-limit getter rename + uploaded-file user-turn dedupe + active-run repair guard + incremental KaTeX streaming + profile default model on fresh boot + French locale completion + update-check error surfacing + release-update apply path)
Fixed
- PR #2718 by @eslicarrillo — Follow-up to v0.51.105's #2700: rename
_pinnedSessionsLimit()to_getPinnedSessionsLimit()so the helper matches the rest ofsessions.js's_get*()naming convention for accessors. No behavior change. - PR #2723 by @ai-ag2026 — Deduplicate uploaded-file user turns when the optimistic browser bubble uses plain text but the server-persisted pending turn includes the
[Attached files: ...]suffix. Previously a turn with attachments could render twice (optimistic + persisted) in the visible transcript before reconciling. - PR #2721 by @ai-ag2026 — During session repair on restart, treat sessions with a live in-flight run as active. The prior code path could prune restart-stale state for sessions that were actually mid-stream when the server bounced, dropping the resumable run. Now the active-stream check gates the prune so live runs survive a restart cleanly.
- PR #2710 by @Michaelyklam — Render streamed math (KaTeX) incrementally during a stream so completed expressions render in place as their closing delimiter arrives, instead of all-at-once at the end of the turn. Eliminates the visual flash where prose-with-math sat as raw
\\[...\\]markup until the final render pass. - PR #2709 by @starship-s — On a fresh boot with no persisted model in localStorage, prefer the active profile's configured default model over the static HTML option. Previously a clean install / first-load could surface the placeholder model until the user manually picked a different one, even when the profile had a default configured. Behavior note: the boot path now calls
_clearPersistedModelState()on each load when no profile default is found, so a previously-persisted user pick is wiped on refresh — this is intentional (matches the PR's "profile default wins on fresh boot" intent and is ratified bytests/test_model_default_boot_precedence.py). - PR #2722 by @victorwhale — Complete French (
fr) locale coverage: +93 missing translation keys covering Settings, profile-ops, gateway tile, skills modal, session controls, and i18n test surfaces. Coverage 88.8% → 96.7%. - PR #2717 by @ai-ag2026 — Surface update-check fetch errors in the UI instead of failing silently. The background
api/updates/checkrequest previously swallowed network failures, so an offline / blocked-CDN scenario showed no indication that the version banner couldn't render. Now the failure is logged and exposed to the System panel's update-status card. - PR #2719 by @ai-ag2026 — Apply release-update target correctly when the user clicks "Check for updates" after a prior dismissal: clears the
sessionStoragecheck-once stamp and forces banner re-evaluation. The prior path silently no-op'd because the once-per-tab guard fired before the explicit user click could re-trigger the fetch.
[v0.51.106] — 2026-05-21 — Release CD (stage-399 — 3-PR batch — restamped state.db replay dedupe + context_messages dedupe so agent doesn't see duplicates + empty _partial bloat fix)
Fixed
- PR #2686 by @ai-ag2026 — Prevent
/api/sessiondisplay merges from appending restampedstate.dbreplay rows after the sidecar tail when those rows are already visible in the sidecar. Compressed sessions previously could appear to end on an old user prompt even though the assistant answer was persisted earlier in the transcript. The fix deduplicates by visible role+content even when timestamps drift (coarse sidecar seconds vs newer state.db floats), preserves the sidecar assistant tail across compaction-card variants and tool-metadata drift, and handles workspace-prefix user prompt variants. Regression test covers the full surface. - PR #2705 by @AlexeyDsov — Deduplicate replayed context messages before they reach the agent so the model no longer sees the same conversation row twice. The UI-side fix shipped in v0.51.96's #2620 corrected the display transcript but the agent still received duplicates in its context (no on-disk duplication — only at runtime in the model-facing context). Starting from the 2nd turn in any session, duplicates would cause the agent to repeat itself or list items twice. The new dedup pass runs at the WebUI/agent boundary so the runtime context is canonical regardless of upstream replay shape.
- PR #2704 by @wirtsi — Prevent unbounded
_partialmessage accumulation in session files. Two interacting bugs incancel_stream()and_message_identity()produced multi-GB session JSON growth: (1) the_partial_already_presentdedup check was gated onif _stripped:, but reasoning-only cancellations have empty stripped text so every cancel inserted a new identical empty_partialentry; (2)_message_identity()returnedNonefor empty_partialmessages so the merge layer had no way to spot the duplicate. The fix tightens both paths and adds a regression test that replays the cancel-cycle to assert bounded growth. Closes the OOM crash class reported against long-running reasoning-heavy sessions.
[v0.51.105] — 2026-05-21 — Release CC (stage-398 — 4-PR batch — hide suggestions preference + Docker agent version from copied source + runner-local adapter selection + configurable pinned session limit)
Added
- PR #2687 by @Michaelyklam (closes #2679) — Settings → Preferences gains a
Hide chat suggestionstoggle (config keyhide_empty_state_suggestions). The empty new-chat screen normally shows three suggestion buttons as first-class tap targets, which causes accidental taps on mobile. Users who don't want the suggestions can hide them via the preference; the toggle persists across sessions and reloads. Default is OFF (suggestions remain visible) so existing users see no change. - PR #2700 by @ai-ag2026 — Settings → Preferences gains a
Pinned conversations limitnumeric input. Builds on v0.51.96's #2614 3-cap by making the cap configurable (range 1–99, default 3, validated server-side via_SETTINGS_INT_RANGES). Backend validates the new cap on read, surfaces an error if a pin attempt would exceed it, and the right-click menu disables the pin item with an explanatory tooltip when the cap is reached. Default-3 keeps existing users on identical behavior. - PR #2696 by @Michaelyklam — RuntimeAdapter slice 4c — feature-flagged runner backend selection. The existing
HERMES_WEBUI_RUNTIME_ADAPTERenv var gains a newrunner-localmode that wires up aRunnerRuntimeAdapterfactory and adds a restart/reattach harness gate before the runner backend is used at the dispatcher. No user-visible change in this slice — unset /legacy-directkeeps existing behavior intact, and no production caller wires the new adapter yet. The slice exists so future work can land a sidecar runner without changing the runtime contract for existing users.
Fixed
- PR #2703 by @Michaelyklam (closes #2691) — System panel now detects the Hermes Agent version in Docker two-container deployments where the WebUI sees a copied Agent source volume instead of a live git checkout. The new detection cascade reads
VERSIONif present, falls back to the package metadata (hermes_cli), and finally to a.gitdescribe if either is available, so the System panel reports the right version even when bothVERSIONand.gitare absent in the copied source.
[v0.51.104] — 2026-05-21 — Release CB (stage-397 — 9-PR batch — i18n zh-CN/zh-TW cron status + geist-contrast skin polish + tablet hardware Enter + stale Codex slash model state + SSE reconnect jitter + cron run inline expansion + inflight send race + new-chat model provider sync + virtualized sidebar scroll-clamp resync + transcript cache invalidation on same-count content)
Fixed
- PR #2690 by @laiaman — Correct the zh-CN and zh-Hant translations for the
cron_status_activelabel so it reads "enabled / scheduled" (已启用/已啟用) instead of "running" (运行中/活躍中). The English source is "active" (enabled, scheduled), and the prior Chinese strings conflated it with the separatecron_status_running"currently executing" state, making both labels look identical when a job was both scheduled and not currently firing. - PR #2701 by @jasonjcwu — Geist-contrast skin composer polish: force
--user-bubble-textto#111in light mode so typed text is black on the light input background; hide the textarea scrollbar to match the rest of the skin; recolor the send button so it reads correctly against the contrast palette. - PR #2706 by @dobby-d-elf — Tablet (iPad-class) devices with an attached hardware keyboard now send on Enter and newline on Shift+Enter, matching desktop behavior. The prior touch-primary check forced Enter→newline on every touch device, but tablets with hardware keyboards have a physical Shift key and should follow the desktop contract. Detection uses
matchMedia('(pointer:coarse)')+ awindow.visualViewportheight-delta probe (>120px shrink = software keyboard open) so an iPad with hardware keyboard (viewport not shrunk) treats Enter as send, while a phone tapping into the composer (soft keyboard shrinks the viewport) keeps Enter as newline. Falls back to the legacy touch behavior whenvisualViewportis unavailable. - PR #2684 by @ai-ag2026 — Repair stale
openai/...slash-qualified model IDs when the active/session provider isopenai-codex. A stale browser/localStorage selection ofopenai/gpt-5against anopenai-codexprovider previously routed the chat to OpenAI directly instead of through Codex. The cross-provider model-switch resolver now detects the mismatch and re-resolves the model to the matchingcodex/...ID before the request goes out. Explicit OpenRouter slash-qualified selections continue to fast-path through unchanged. - PR #2671 by @AJV20 (closes #2629 + #2661) — Session-list SSE reconnects now use bounded jitter/backoff (each retry delay is
base*0.75 + random*(base*0.35)wherebase = min(30000, 5000 * 2^attempt), capped at 30s) instead of a fixed 5-second retry, so tabs that all dropped at the same time (server restart, network drop) don't all retry in lockstep. Expanded cron run rows now render the full output inline immediately on click; the truncated preview remains only for collapsed rows, and the full-output fallback no longer drops content when Markdown rendering is unavailable. - PR #2689 by @ai-ag2026 — Preserve the optimistic in-flight message array across the
/api/chat/startawait window so a fast back-to-back send doesn't clear the user's message before the stream ID arrives. The fix snapshots the inflight entry before the await, recreates it if a sidebar/session refresh pruned it during that window, and skips stale-inflight cleanup for the submitting session until a stream ID is bound. Regression test covers the race. - PR #2674 by @AJV20 — Resync the new-chat model picker when the server-created session has the same model ID as the current dropdown but a different provider. New conversations now resync to the configured default model provider instead of inheriting a stale persisted picker selection (e.g.
openai/gpt-5from a previous session). Without this, the dropdown text matched the new session's model, but the provider attribute still pointed at the stale choice. - PR #2688 by @ai-ag2026 — Resync the virtualized session sidebar after restoring a saved scroll position if the browser clamps or rejects that scroll position. Without this, date-group headers could render without their session rows beneath them until the user manually scrolled or a later refresh recomputed the virtual window. Regression test pins the recompute path.
- PR #2692 by @ai-ag2026 (refs #2613) — Invalidate the transcript render cache on same-count content changes, not just on count changes. The prior cache key was
(message_count, render_window_size), which silently reused a cached transcript whenever a same-count edit produced visibly different content (e.g. a tool retry that replaces a single assistant message with corrected text). The new cache signature folds a content hash into the key so any visible change forces a fresh render. Regression test asserts cache-bust on same-count content swap.
[v0.51.103] — 2026-05-21 — Release CA (stage-396 — 1-PR follow-on — Settings → Plugins distinguishes exclusive/provider activation)
Fixed
- PR #2663 by @Fail-Safe (closes #2659) — Settings → Plugins panel now distinguishes exclusive plugins (memory providers, web backends, browser providers activated via
<category>.providerconfig) from disabled-or-broken plugins. The/api/pluginspayload gainskind+activationfields; cards render a new "Active (provider)" badge variant for exclusive activation instead of mislabeling these plugins as "Disabled / No registered lifecycle hooks". Purely additive — for users without an exclusive provider configured, the panel renders "Enabled" and "Disabled" badges exactly as before. The legacyenabledboolean is preserved on the payload for back-compat with older WebUI clients; new clients readactivationfirst with a fallback. 3 new behavioral tests cover the exclusive, model-provider, and standalone code paths.
[v0.51.102] — 2026-05-21 — Release BZ (stage-395 — 1-PR follow-on — capped CLI sidebar candidate window now keyed on last-activity not start time)
Fixed
- PR #2662 by @Michaelyklam (closes #2656) — Capped CLI/agent sidebar scans now order the candidate CTE by
COALESCE(MAX(messages.timestamp), s.started_at)instead ofs.started_atalone. Long-lived CLI sessions that were resumed days later (oldstarted_at, recent message activity) stay visible in the candidate window instead of falling outside the 8×limit oversample. Closes the regression I filed against v0.51.99's #2647 sidebar candidate-window narrowing. New regression test creates an old session with a recent message timestamp and asserts it surfaces at the top.
[v0.51.101] — 2026-05-20 — Release BY (stage-394 — 2-PR deep-review batch — workspace Git backend + sidebar tab visibility toggle)
Added
- PR #2625 by @stocky789 — Add backend Git operations for the workspace panel. New
api/workspace_git.pymodule exposes read-only ops (/api/git/status,/api/git/branches,/api/git/diff,/api/git/commit-message[-selected]) unconditionally and mutating ops (stage,unstage,discard,commit,commit-selected,checkout,stash-checkout,pull,push) only whenHERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1is set in the environment — default OFF so existing deployments are unaffected. All subprocess calls use["git", *args]withshell=False, all branch/ref names go throughgit check-ref-format --branchvalidation before flowing togit switch -c, andsubprocess.envis scrubbed ofGIT_DIR/GIT_WORK_TREE/GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM/GIT_CONFIG_COUNT/GIT_CONFIG_PARAMETERSplus the fullGIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_*namespace before every invocation.GIT_INDEX_FILEis intentionally preserved to drive selected-file commits through a private temporary index. Paths are bound to the workspace root viasafe_resolve_ws()+Path.relative_to()enforcement (rejects..traversal and symlinked escapes); active-stream gate prevents mutations during a running agent turn. Documented indocs/workspace-git.mdwith the full trust model (hooks-as-RCE warning, default-allowed vs gated lists, env-scrub enumeration). Frontend UI ships in a follow-up PR. - PR #2636 by @FrancescoFarinola — Per-tab sidebar visibility toggle in Settings → Appearance. Power users can hide unused rail tabs (Tasks, Kanban, Skills, Memory, Spaces, Profiles, Todos, Insights, Logs) while keeping Chat and Settings always reachable. Settings is per-profile so each profile can have its own hidden-tabs preference; an inline
<script>in<head>appliesnav-tab-hiddenfromlocalStoragebefore first paint so toggled-off tabs don't flash visible on reload. Default off — no tabs are hidden out of the box; existing deployments are unaffected. Chips userole="switch"+aria-checkedfor clear screen-reader narration, and the container hasrole="group"+aria-labelledbypointing at its label. Backend validator stripschatandsettingsfromhidden_tabsat save time as a belt-and-suspenders against tampered POSTs. Profile switch reconciliation:_refreshProfileSwitchBackgroundre-fetches/api/settingsand re-applieshidden_tabsafter a profile change so the new profile's preference takes effect immediately.
Maintainer additions during stage
_refreshProfileSwitchBackgroundprofile-switch reconciliation for #2636 (Profile A's hidden-tabs no longer bleeds into Profile B until Settings is opened).role="switch"+aria-checkedchip a11y for #2636 (wasaria-pressed— confusing polarity for users where chip-off looks like the off state).- Server-side
hidden_tabsvalidator strip ofchat/settingsfor #2636. - CSS contrast fix for #2636 —
color: #1a1a1a+font-weight: 600on filled chips (wascolor: var(--bg-page)which resolved to white in dark theme and was barely readable on the gold accent). - 3 new regression tests for the #2636 maintainer additions (profile-switch wiring, chat/settings server-side strip, a11y switch role).
UX approval
PR #2636 went through the full multi-viewport screenshot gate (390 mobile, 1280 laptop, 1440 desktop, 1920 wide; both light and dark themes; default-on and 3-off mixed states; rail-effect proof showing hidden tabs collapse cleanly). Approved via Telegram for merge.
[v0.51.100] — 2026-05-20 — Release BX (stage-393 — 3-PR deep-review batch — lazy journal recovery retry + faster profile-switch + cross-tab session list SSE sync)
Fixed
- PR #2615 by @Isla-Liu — Self-heal stalled interrupted-turn markers by lazily retrying run-journal recovery on subsequent sidebar/full-load reads. When
_apply_core_sync_or_error_markerlands in the "interrupted, no journal yet" branch, the session now carries_pending_journal_recoverymetadata so the nextget_session()call re-runs_append_journaled_partial_output(..., dedupe_existing=True)if a fresh journal has materialized in the meantime. Bounded by three independent ceilings:_JOURNAL_RETRY_MAX_ATTEMPTS = 12(attempts on sealed-but-unrecoverable journals),_JOURNAL_RETRY_GIVEUP_SECONDS = 24 * 3600(wall-clock cap that fires even when the journal never materializes), and an explicit "no stream id → demote immediately" branch. Per-sessionthreading.Lock(non-blocking acquire) guards concurrentget_session()callers; second caller no-ops toFalse. Closes a real WSL2/network-FS class of bug where the recovery marker stuck on a transiently-missing journal and never self-healed without a server restart.
Changed
- PR #2633 by @dobby-d-elf — Make agent profile switching and initial app boot dramatically faster (~25× on the contributor's benchmark). Switch-time: applies the selected profile's defaults immediately (resolved via a per-profile
_profile_default_model_state(profile)config read), refreshes the visible/current screen, schedules non-visible workspace/model metadata in the background after the current view settles. A_profileSwitchGenerationcounter is checked at fiveawaitboundaries to abort stale switches when the user re-toggles mid-refresh. Boot-time: workspace and onboarding fetches run in parallel instead of serial; onboarding short-circuits withPromise.resolve(false)whenonboarding_completedis true. Boot synthetic model options now carrydata-providermetadata so a pre-catalog/api/chat/startdoesn't fuzzy-fallback to the wrong provider. FLIP animation capture/play is index-pinned by a new positional test;prefers-reduced-motionis honored in both JS and CSS. Server-sidenew_session()resolves the requesting profile'sconfig.yamldirectly rather than the process-global, preventing cross-tagging when the URL omitsmodel/model_provider.
Added
- PR #2637 by @dobby-d-elf — Push lightweight session-list invalidation events to connected browsers via a new in-process pub/sub bus (
api/session_events.py) and SSE endpoint (/api/sessions/events). Triggered on every visible-session mutation: new (only after worktree creation or first-message reveal), delete, archive, move, pin, rename, duplicate, import, and cron-job completion. Per-subscriberQueue(maxsize=1)with latest-wins drain — under burst, browsers see the freshest payload, not a backlog. Disconnect cleanup runs in the handler'sfinally:block, scoped to the shared_CLIENT_DISCONNECT_ERRORStuple so socket-failure modes beyondBrokenPipeErroralso trigger unsubscribe. Cron-scheduler integration uses a_cron_profile_context_depth()gate to avoid double-publish across manual vs scheduled cron run paths. Follow-on issue filed: the bus has no profile identity in its payload — a cron tick in profile A wakes the SSE handler for every browser regardless of which profile its session cookie selects, costing wasted/api/sessionsround-trips on other-profile tabs (no data leak; the GET is profile-scoped). Filing tracker for a follow-up that snapshots the active profile at publish time.
Test infrastructure
- HTTP integration tests for
/api/sessions/events: handshake (200 +text/event-stream), event delivery driven by a side-effect POST, rapid open/close burst that survives a subsequent/api/sessionsGET, and a source-level guard that the handler'sexcept _CLIENT_DISCONNECT_ERRORS:clause stays wired to the shared tuple. Added as stage-393 follow-up to Opus advisor's blocking note on PR #2637 (the existing tests covered the in-process bus but never opened a real HTTP connection).
[v0.51.99] — 2026-05-20 — Release BW (stage-392 — 5-PR batch — compact tool activity grouping + CLI sidebar scan cap + title-generation API key forwarding + post-compression replay dedup + clarify popup stability)
Fixed
- PR #2638 by @dobby-d-elf — Keep compact tool activity grouped under a single Activity disclosure within an assistant turn. The compact renderer was splitting Thinking and interim tool activity into multiple visible fragments inside the same turn. Composes cleanly with v0.51.96's #2620 (second-level dedup key) — the bundled
merge_session_messages_append_only()change now only adds toseen_message_keyswhen the key is an authoritativemessage_id-prefixed key, preserving the v0.51.96 invariant that two distinct state-only same-second rows must remain visible. - PR #2647 by @Michaelyklam (closes #2628) — Cap the CLI/agent session sidebar bridge to a recent-candidate window before joining message rows. Previously
show_cli_sessionsran aLEFT JOIN messages ... GROUP BY s.idacross the entire Hermesstate.db, aggregating 100k+ message rows before applying the visible sidebar cap. Large installs paid that cost on every sidebar read. The capped path now selects an oversampled candidate set (8× the visible limit) ordered bystarted_at DESC, then runs the message aggregate inside that window. Uncapped callers (full scans, exports) are unchanged. - PR #2650 by @starship-s — Forward the configured
auxiliary.title_generation.api_keyfor config-derived title routes. Completes #2612 / v0.51.96 — the prior PR routed provider/model/base_url through but the API key was left to fall back to the chat client's key, which doesn't match the Hermes Agent task-config shape and silently failed for setups where the title model lives behind a different account. The newcaller_supplied_routeguard prevents leakage of the title-generation key to caller-supplied (active-agent fallback) routes. - PR #2651 by @LumenYoung (refs #1217) — Dedupe replayed active-context tails before appending agent result deltas to the WebUI display transcript, and apply the same replay protection to persisted
context_messages. Without this, post-compression continuation re-fed an already-present tail into the next model turn, inflating the model-facing context and bloating the visible transcript with duplicate assistant cards. - PR #2643 by @arshkumarsingh (closes #2639) — Require a stable
clarify_idand wait for the backend ack before hiding the WebUI "Clarification needed" popup. Three bugs conspired to cause stale clarifications to silently fail: (a)_ClarifyEntryhad no unique identifier so the frontend couldn't reference a specific pending prompt; (b) the backend used FIFO resolution which silently dropped late/stale responses; (c) the frontend hid the popup before the POST returned, so users saw a successful submit while the agent fell back to its best-judgement timeout path. Server-sideclarify_idis now generated in_ClarifyEntry.__init__(UUID-based), propagated through SSE/poll payloads, sent back by the browser, and matched viaresolve_clarify_by_id(). The popup stays visible until the POST returns; a 409/stale:trueresponse keeps the draft and shows a toast. The legacynot bool(clarify_id)quirk that always returnedok:trueis gone.
[v0.51.98] — 2026-05-20 — Release BV (stage-391 — 1-PR follow-on — custom_providers allowlist priority over live /v1/models)
Fixed
- PR #2640 by @colin-chang — When a
custom_providersentry inconfig.yamldeclares a curatedmodels:allowlist (e.g. a ZenMux or other aggregator gateway), respect the curated list instead of also fetching the live/v1/modelscatalog. Without this guard the picker rendered hundreds of online models alongside the user's curated three, swamping the intended selection. The allowlist guard skips the live probe entirely; the existing fall-through to a live probe still runs when nomodels:list is configured. Composes cleanly with #2626 / v0.51.96 — when the live probe is skipped, nomodels_endpoint_erroris surfaced (the curated list is the source of truth and probe failures should not show as a user-facing diagnostic in that case).
[v0.51.97] — 2026-05-20 — Release BU (stage-390 — 2-PR batch — startup session-index rebuild + config-managed custom-provider cards)
Fixed
- PR #2642 by @dso2ng — Rebuild the WebUI session index during startup recovery when
_index.jsonis missing, even when no.baksession restore occurs. Previously the rebuild only ran after a.bakrestore path, leaving large state directories on a repeated full-scan/api/sessionsfallback after an index loss. Startup recovery now unconditionally calls_write_session_index()when no_index.jsonis present on disk, restoring the O(1) sidebar read path.
Added
- PR #2634 by @Michaelyklam (closes #2632) — Show
custom_providersentries created byhermes model(CLI) in Settings → Providers as read-only config-managed provider cards, including their configured models and key status, instead of filtering them out because they are not WebUI-editable API-key providers. The new card variant displays configured models, key-env status (set/unset), and a "config-managed" badge that links to the CLI as the canonical edit surface.
[v0.51.96] — 2026-05-20 — Release BT (stage-389 — 8-PR batch — IPv6 dashboard link normalization + configured title-generation provider routing + sidebar pinned-session 3-cap + external-refresh sidecar count preference + Hermes overview docs relocation + legacy dedup timestamp granularity + custom provider /models endpoint error surfacing + RuntimeAdapter Slice 4c harness gate RFC)
Fixed
- PR #2610 by @AJV20 — Preserve square brackets around IPv6 hosts when normalizing browser-only dashboard URLs, so links like
http://[::1]:9119remain valid after saving instead of being mangled into invalid IPv6 forms. Closes the regression introduced by the URL-sanitization path added in #2533 / v0.51.95 — bracketed IPv6 hosts now round-trip through the dashboard-link save flow unchanged. - PR #2612 by @AJV20 — Route WebUI session title generation through the configured
auxiliary.title_generationprovider, model, and base URL when present in config, instead of leaving the auxiliary client to silently fall back to the chat model. Users who configure a smaller/cheaper model for title generation (e.g. a fast 8B model on a separate provider) now have that selection honored end-to-end. - PR #2618 by @LumenYoung — Prefer the persisted sidecar
message_countover the session-index stored count during external-refresh polling. The metadata-only/api/session?messages=0path now readsSession._metadata_message_countwhen sidecar data is available, so legacy sessions whose state.db retains old rows still trip the external-refresh signal correctly on sidecar updates. Composes cleanly with #2604 (the legacy-fallback only applies when the reconciled merged count is zero). - PR #2620 by @bengdan — Use second-level timestamp granularity in the legacy message-dedup key. Drops the microsecond fallback in
_normalized_message_timestamp_for_dedup_key()so transcripts that encode timestamps at different sub-second precisions (e.g."10.0"vs10.000000) collapse to the same dedup bucket. Retroactively de-duplicates the dominant failure mode in #2616 without requiring an on-disk session rewrite. - PR #2626 by @Michaelyklam (closes #2540) — Surface named custom-provider
/modelsendpoint failures in the model picker instead of silently showing an empty provider group._read_custom_endpoint_modelsnow returns(models, error), so auth/network/HTTP failures propagate as structuredmodels_endpoint_errorhints on/api/modelsper affected provider. The composer model picker renders the hint as a quiet disabled-option diagnostic; configured fallback models remain selectable. 124 LOC of new regression coverage spans 401/network-error/5xx failure modes plus frontend hook validation.
Added
- PR #2614 by @Michaelyklam (refs #2508) — Cap sidebar-active pinned sessions at three. Right-clicking a conversation row opens the existing action menu, attempted pins beyond the cap render the menu item as disabled with an explanatory tooltip, and the backend rejects a fourth pin attempt with a structured error so the optimistic frontend can roll back the click. Settles the open question from #2508 on whether pin count is bounded — the answer is three, configurable in a future PR if user demand surfaces.
Documentation
- PR #2619 by @Michaelyklam (closes #2595) — Move the long human-facing Hermes comparison document from root
HERMES.mdtodocs/why-hermes.mdso Hermes Agent sessions opened in this repository loadAGENTS.mdas the project-specific assistant guidance instead of the marketing overview. README links now point to the new docs path and a regression test (tests/test_agent_context_docs.py) prevents rootHERMES.md/.hermes.mdcontext files from silently reappearing. - PR #2627 by @Michaelyklam (refs #1925) — Advance the RuntimeAdapter RFC after the Slice 4b
RunnerRuntimeAdapterfacade shipped in v0.51.94. The RFC now defines the next Slice 4c runner-backend harness gate: feature-flagged runner backend selection, explicit start payload validation, durable status/event observation across WebUI adapter recreation, bounded controls, and a deterministic harness for proving the facade's protocol-translation invariants without requiring the future runner/sidecar to exist.
[v0.51.95] — 2026-05-20 — Release BS (stage-388 — 5-PR batch — live tool callback event dedup + browser-only dashboard links + messaging transcript merge alignment + Geist Contrast skin + SSE runtime diagnostics)
Fixed
- PR #2598 by @AJV20 — Surface live tool activity when Hermes Agent reports tools through its dedicated
tool_start_callback/tool_complete_callbackpath, so browser chat shows the existing running tool cards instead of appearing idle until the final answer. The legacyon_toolcallback path now early-returns fortool.startedandtool.completedevents when the structured callback path is already wired, preventing the same tool event from being emitted twice to the SSE stream. - PR #2533 by @AJV20 — Allow Settings → System to save public browser-only Official Hermes Dashboard links (for reverse-proxy URLs) without treating them as server-side probe targets. URL sanitization runs against the configured link before save; the dashboard probe is skipped for browser-only links.
- PR #2607 by @AJV20 — Deduplicate messaging/CLI session transcript rows when the sidecar and state store encode the same no-id message with equivalent timestamps in different formats (e.g.
"10.0"vs10), preventing repeated visible chat rows after session reconstruction. The messaging-display merge now reusesapi.models._session_message_merge_key(...)instead of an ad-hoc dedup key, aligning with the existing append-only merge path.
Added
- PR #2521 by @intellectronica — Add the Geist Contrast skin to the appearance picker. New light + dark variant pair with a high-contrast yellow-on-black accent and Geist editorial typography. Default unchanged — opt-in via Settings → Appearance → Skin → Geist Contrast. Slash command
/theme geist-contrastnow resolves correctly because the lookup matches againstskin.valuerather thanskin.name. Documented inTHEMES.mdwith a forward-compatible skin count (no hard-coded value). - PR #2524 by @AJV20 — Add non-sensitive SSE stream runtime diagnostics to deep health checks (
/health?deep=1), including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. Read-only telemetry; existing surfaces unchanged.
[v0.51.94] — 2026-05-19 — Release BR (stage-387 — 10-PR full sweep batch — Slice 4b runner adapter facade + folder zip download + partial recovery marker dedupe + browser api() client-side timeout + auto-compression card rotation finish + composer draft rollback fix + metadata count reconciliation + active-session refresh on external sidecar updates + indexed context metadata + gateway-queues approval peek)
Fixed
- PR #2566 by @bjb2 — Add
GET /api/folder/download?session_id=...&path=...streaming-zip endpoint with pre-flight 413 on size/file-count cap exceeded,os.walk(followlinks=False)plus per-symlink workspace-root resolution check,allowZip64=Truefor large files, and a "Download Folder" item in the workspace file context menu (dir items only). Configurable caps viaHERMES_WEBUI_FOLDER_ZIP_MAX_MB(1024 default) andHERMES_WEBUI_FOLDER_ZIP_MAX_FILES(50000 default).download_folderi18n key added across all 11 locales with// TODO: translatefallback markers for non-en entries. - PR #2593 by @Michaelyklam (closes #2592) — Deduplicate cancelled/recovered partial assistant markers using the full
(content, reasoning, partial tool calls)payload instead of only non-empty text content. Tool-only failed turns no longer append identical empty-content_partialmessages repeatedly. Full session loads collapse adjacent duplicate partial markers from already-bloated session files while preserving a.partial-bak-<timestamp>backup. New helpers_partial_message_signature()(api/streaming.py:2593-2622) +_partial_marker_already_present()(api/streaming.py:2625-2641) scope the dedup search to the current user turn only. - PR #2597 by @dso2ng (closes #2539) — Add a 30s default client-side timeout to the shared browser
api()helper, with per-calltimeoutMsoverrides,AbortController-based cancellation, a timeout toast, and explicit 60s/120s ceilings for legitimately longer update flows. Body-read phase also raced against the timeout so a server that replies headers-OK and then stalls mid-JSON rejects cleanly. Newtests/test_api_timeout.pycovers default, override, abort, and body-read-stall paths. - PR #2601 by @starship-s — Prevent the composer-draft rollback regression introduced by #2581's active-session external-refresh polling. Adds
opts.preserveActiveInputto_restoreComposerDraftand gates the overwrite oncurrent && current !== text, keeping the guard co-located with the function that owns the contract. Backends.save(touch_updated_at=False)for/api/session/draftso draft autosaves no longer falsely advanceupdated_atand trigger the refresh poll. Supersedes parallel-discovery PR #2602. - PR #2603 by @starship-s — Finish the running auto-compression card after the backend rotates the session id. The
compressedSSE listener atstatic/messages.js:1829-1862used to early-return wheneverS.session.session_id !== activeSid, but thestateevent listener at:1656-1662already rotateswindow._compressionUi.sessionIdto the continuation id beforecompressedarrives. The strict active-session check is replaced with a cross-session safety check that still rejects mismatched events but no longer rejects the legitimate post-rotationdonepayload, so the elapsed-timer "compressing…" state no longer freezes after rotation completes. - PR #2604 by @Michaelyklam (closes #2594) — Reconcile session metadata counts in the
/api/session?messages=0fast path. Replaces the priormax(sidecar_count, state_count)heuristic withlen(merge_session_messages_append_only(sidecar_messages, state_db_messages))so the metadata-only count matches the full-load count. Closes the followup issue filed against PR #2581 / v0.51.93 — sidebar refresh polling no longer loops forever whenstate.dbretains old rows that the append-only merge correctly filters out. - PR #2605 by @LumenYoung (refs #2581) — Make the metadata-only
/api/session?messages=0&resolve_model=0path return the persisted sidecarmessage_countfromSession._metadata_message_countwhen no session-index entry exists, so the active-session external-refresh signal still trips on legacy sessions whose sidecar contains externally-appended content. Composed cleanly with #2604 (the legacy-fallback applies only when the reconciled merged count is zero). - PR #2573 by @espokaos-ops (closes #2510) — Persist session-level approvals when a "Allow for this session" click lands while a stream is active and
_pendingis empty. The approval flow now peeks_gateway_queues[sid]to recover the queued_ApprovalEntry'spattern_keyssoapprove_session()records the approval; the next dangerous command in the same session no longer asks again. Reduced scope to peek-only per prior review note; theagent_session_keyround-trip plumbing was dropped (it was dead on the WebUI streaming path).
Added
- PR #2599 by @Michaelyklam (refs #1925) — Add the Slice 4b
RunnerRuntimeAdapterfacade — a protocol-translator client over a future runner/sidecar backend. The facade delegatesstart_run,observe_run,get_run, and control calls to an injected runner client, normalizes results into the existingRunStartResult/RunEventStream/RunStatus/ControlResultdataclasses, carries explicitprofile/workspace/modelpayload fields, and returns boundedunsupportedcontrol results without owningAIAgent, stream lifecycle, cancel/approval/clarify queues, goal state, or cached-agent table. No route wiring, no default-on runner mode, no public response-shape change. - PR #2600 by @LumenYoung (refs #2266) — Slimmer WebUI follow-up from the closed LCM/context-engine PR #2266. Adds rendering and persistence for context-engine compression-anchor metadata (when present on a session or live compression event) including an "Indexed context" detail line on auto-compression cards. No agent-layer clone orchestration; WebUI-only metadata surface.
[v0.51.93] — 2026-05-19 — Release BQ (stage-386 — 10-PR full sweep batch — RFC Slice 4 runner/sidecar gate + workspace tree toggle width CSS variable + settled file:// markdown link rendering + prompt-cache coverage percentage fix + terminal shell shutdown reap + configured model picker provider preservation + profile-aware assistant display names + state.db reconciliation slice 1 + queued-message cross-session drain fix + stale-stream writeback supersede)
Fixed
- PR #2580 by @Michaelyklam (refs #2571) — Centralize the workspace-tree toggle slot width into a
--file-tree-toggle-widthCSS variable at:root, referenced from both.file-tree-toggleand.file-tree-toggle-placeholderso a future width adjustment can't silently desync the two rules. Closes the followup issue filed against PR #2563 / v0.51.92. - PR #2576 by @dobby-d-elf (closes #470) — Preserve labeled
file://links in settled markdown by rewriting them to/api/media?path=...&inline=1before the sanitizer drops them. The streamed and settled markdown paths are now symmetric on local-file anchors, while rawfile://image sources continue to be blocked. - PR #2579 by @starship-s (refs #2419, #2421) — Fix the prompt-cache hit percentage to display the fraction of the prompt served from cache (
cache_read / prompt_total) instead of the meaninglesscache_read / (cache_read + cache_write). Newapi/usage.pyprompt_cache_hit_percent()helper matches Hermes Agent's log convention; UI labels updated across all locales. - PR #2582 by @Michaelyklam (refs #2577) — Harden embedded workspace-terminal shell cleanup so graceful WebUI shutdowns close/reap every active PTY shell and the spawned shell receives a Linux parent-death signal (
PR_SET_PDEATHSIG) if the WebUI process dies. The terminal close path now waits again afterSIGKILLso timed-out shells don't remain unreaped. - PR #2583 by @dobby-d-elf — Make assistant display names properly profile-aware. The saved assistant-name preference applies only to the literal
defaultprofile; named profiles use their own profile name. CentralizesassistantDisplayName()resolution across composer placeholder,document.titleviasyncTopbar(), message role labels via_assistantRoleHtml(), browser notifications, cancel-copy fallback, and empty-state on session delete. - PR #2584 by @wirtsi (closes #2585) — Prevent queued follow-up messages from draining into the wrong chat when the user switches sessions during the 120ms
setBusy(false)drain window. The drain-time guard re-queues againstsid(not the currently-viewed session) and_sendInProgressSidcaptures the activeSid at the commit point so the re-entrantsend()path no longer reads a staleS.session.session_id. - PR #2587 by @AJV20 — Allow a still-running stream that was mistakenly marked interrupted by stale-pending recovery to replace its own recovery marker when it later finishes, while continuing to block stale writeback after any newer turn appends transcript content. Three new tests in
tests/test_session_sidecar_repair.pycover the supersede-allowed and the two refuse cases. - PR #2588 by @Michaelyklam (refs #2569) — Preserve the configured provider when choosing a configured model from the composer picker.
_getOptionProviderId()now readsdata-providerfrom temporary<option data-custom="1">rows (created byselectModelFromDropdownfor configured models outside the native catalog), so the next send routes through the correct provider instead of falling back to whatever provider was already active.
Changed
- PR #2581 by @LumenYoung (refs #2194) — First recovery slice from the closed reconciliation PR #2194. Routes streaming session reconstruction and sidebar metadata through the reconciled state.db/session-summary path with a metadata-only fast path for sidebar polls and a single-snapshot reuse on the streaming hot path. Includes the reviewer-requested
_new_turn_context_from_messagesextraction so both legacy and streaming paths share the_drop_checkpointed_current_user_from_context+ casual-fresh-chat suppression behavior (refs #1217 / #2308). 923 LOC acrossapi/models.py,api/routes.py,api/streaming.py,static/sessions.js+ four new test files; second-pass agent diff review LGTM after the streaming-path regression was caught and fixed.
Documentation
- PR #2575 by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC to the Slice 4 runner/sidecar planning gate after #2560 shipped the queue-staging clarification. The RFC now marks queue routing as staged by default, defines Slice 4a as a docs/test contract before any runner code lands, and pins default-off feature-flagging, restart/reattach success criteria, control parity, profile/workspace payload isolation, and explicit non-goals for legacy-backend removal or server-side queue scheduler work.
[v0.51.92] — 2026-05-19 — Release BP (stage-385 — 7-PR full sweep batch — RFC Slice 3c clarification + workspace tree icon alignment + project move cache refresh + auto-compression handoff metadata + Grok OAuth provider catalog + anonymous custom endpoint picker fallback + PWA standalone reload + pull-to-refresh)
Fixed
- PR #2563 by @Michaelyklam (closes #2554) — Align workspace-tree file rows with sibling directory rows by reserving the same expand/collapse toggle slot for files via a new
.file-tree-toggle-placeholderelement. Expanded directories now show child files stepped in at the same icon column as child folders. Directory toggles and file interactions are unchanged; source-level regression coverage and before/after PNGs included. - PR #2561 by @nanookclaw (closes #2551) — Refresh the authoritative
_allSessionscache when the project picker moves a session to/from a project. Previous code mutated only the shallow sidebar row copy, sorenderSessionListFromCache()re-read the unchanged cache and repainted a stale project dot until the next/api/sessionspoll healed the UI. Both the "Removed from project" and "Moved to " branches now write the newproject_idinto_allSessions[idx]before re-rendering. - PR #2567 by @dso2ng (refs #2477) — Surface automatic-compression handoff metadata through the
compressedSSE event so the active browser stream keeps its completion card even after the backend rotates the session id from the origin to a compressed continuation. The event now carries bothold_session_idandnew_session_id/continuation_session_id; the frontendcompressedlistener accepts either, and the automatic-compression detail line names the compressed continuation session so the done state isn't silently dropped. - PR #2568 by @Michaelyklam (closes #2545) — Add the Hermes Agent
xai-oauthprovider to the WebUI's OAuth provider catalog so Grok OAuth accounts authenticated via the Hermes CLI appear in Settings → Providers and the/api/modelspicker. The provider is treated as CLI-managed OAuth (no WebUI API-key form) and uses the live Hermes CLI model catalog when available with a Grok 4.20 static fallback. - PR #2550 by @espokaos-ops (refs #2542) — Keep anonymous custom OpenAI-compatible endpoints in the model picker even when the configured
/v1/modelsprobe fails. Lightweight relays and llama-server-style deployments that authenticate/v1/chat/completionsbut not/v1/modelsno longer have their provider group silently dropped from the picker. Users can type a model id manually in the free-form input when no live catalog is available.
Added
- PR #2548 by @espokaos-ops — Add a PWA-standalone reload affordance. A small refresh button appears in the app titlebar (visible only under
@media (display-mode: standalone), (display-mode: fullscreen)) so users running the WebUI as an installed home-screen PWA can reload without re-launching the app. Adds a complementary pull-to-refresh gesture on the messages container with an 80px threshold and a smooth-scroll-to-top guard so accidental triggers while reading history feel intentional. 4-viewport screenshots (390/1280/1440/1920, light/dark, hover/idle) included underdocs/pr-media/2548/.
Documentation
- PR #2560 by @Michaelyklam (refs #1925) — Clarify the RuntimeAdapter Slice 3c state after #2544 shipped. The RFC now distinguishes shipped
/api/goalrouting throughRuntimeAdapter.update_goal(...)from the still-stagedqueue_message(...)protocol method, and explicitly warns not to add a new server-side queue endpoint or queue scheduler merely for adapter symmetry while/queueremains browser-side queue/drain behavior.
[v0.51.91] — 2026-05-18 — Release BO (stage-384 — 5-PR full sweep batch — reasoning-replay history fix + archive-extract per-session inbox + fallback streaming warnings + sanitized custom-provider env hints + Slice 3c queue/goal adapter routing)
Fixed
- PR #2536 by @Michaelyklam (closes #2514, refs #2535) — Stop reasoning-only Thinking entries from being replayed into provider-facing history as blank assistant turns. Long WebUI sessions were accumulating duplicated stale Thinking blocks and inflated Activity/tool metadata on later turns when reasoning-only display entries (from interrupted/canceled turns) got reinserted into the restored conversation history. The fix keeps visible Thinking cards in the transcript while filtering them out of provider-facing replay. Settled compact Activity rerenders now also clear previously inserted Thinking rows before rebuilding the visible transcript.
- PR #2520 by @OneFat3 (refs #2247) — Route archive extraction (
/api/upload/extract) through the per-session attachment inbox (_session_attachment_dir) instead of hardcodedPath(s.workspace), matching the single-file upload path. Extracted archives now land at<attachment_root>/<session_id>/<archive_stem>/so session deletion cleanup covers them and per-session isolation is preserved whenHERMES_WEBUI_ATTACHMENT_DIRis configured. - PR #2505 by @cyberdyne187 — Surface provider fallback and rate-limit lifecycle notices as auto-clearing fallback warnings in the streaming composer status. The new bridge in
_agent_status_callbackmatches agent lifecycle messages containingrate limited/switching to fallback/falling back/fallback activated/trying fallbackand emits them aswarningevents withtype=fallback, so the existingstatic/messages.jswarning channel surfaces them with the correct auto-clear contract instead of letting them drop silently. - PR #2556 by @Michaelyklam (closes #2541) — Sanitize auto-generated custom-provider API-key environment variable names so endpoint-derived provider ids such as
custom:gpu.local-8000use POSIX-safe names likeCUSTOM_GPU_LOCAL_8000_API_KEY. Runtime custom-provider key resolution now checks the sanitized env var first and falls back to the legacy punctuation-preserving name with a one-shot deprecation warning. Configured literalapi_keyvalues and explicitkey_envconfig are unchanged.
Documentation
- PR #2544 by @Michaelyklam (refs #1925) — Implement the first Slice 3c RuntimeAdapter control routing.
RuntimeAdapter/LegacyJournalRuntimeAdapternow exposequeue_message(...)andupdate_goal(...)as protocol-translator delegates, and the/api/goalroute usesupdate_goal(...)only whenHERMES_WEBUI_RUNTIME_ADAPTER=legacy-journalis enabled while preserving the legacy-direct response shape. The change keeps/queue's existing browser-side drain semantics and goal post-turn evaluation in the current agent loop; no runner/sidecar, WebUI-owned queue, goal scheduler, cached-agent table, or execution-survives-restart claim is introduced.
[v0.51.90] — 2026-05-18 — Release BN (stage-383 — 10-PR full sweep batch — empty-gateway messaging history fix + previous-messaging-sessions setting + Kanban board switcher layout + UI/UX demo theme controls + Slice 3c queue/goal RFC gate + keyless custom endpoints + custom-provider remote model catalog parity + auto-compression elapsed timer + new-conversation cold-start guard + Kanban drag-drop detail open fix)
Fixed
- PR #2286 by @junjunjunbong (refs #2275) — Narrow messaging stale-session filtering to active gateway sessions that are visible in the current sidebar candidate set. Older Discord/messaging history is now preserved when the gateway advertises a fresh zero-message session that hasn't yet entered the visible projection, instead of being hidden as stale. Adds a regression test for an empty active Discord gateway row preserving prior history.
- PR #2459 by @franksong2702 (closes #2458) — Fix the Kanban board switcher menu when a board's icon slot carries a long text label (e.g.
layout-kanban). The icon column changed from a fixed18pxslot to a bounded flex cell withmin-width:18px;max-width:7.5rem, with overflow ellipsis on the icon itself so long labels render fully when space allows and truncate cleanly when not. Title and count columns keep stable spacing. Adds before/after screenshots and a CSS contract regression intests/test_kanban_ui_static.py. - PR #2522 by @Michaelyklam (refs #2271) — Treat named custom OpenAI-compatible endpoints with a configured
base_urlas key-optional at WebUI agent startup. Local keyless servers (llama-server / vLLM-style LAN deployments) no longer fail early with a syntheticCUSTOM:<slug>_API_KEYenv-var prompt before the request reaches the endpoint; instead the OpenAI-compatible client initialises with a harmless placeholder key and real configured keys are still preferred when present. Refactors the three near-identical custom-provider rebuild blocks (initial agent setup + two retry/healing paths) through the existingresolve_custom_provider_connectionhelper. - PR #2515 by @Michaelyklam (closes #2513) — Keep named custom-provider model pickers populated from each configured endpoint's live
/modelscatalog even whencustom_providers[].modelis present. The singularmodelfield now acts as a sticky/fallback entry appended after the remote catalog rather than collapsing the picker to just the configured model and hiding sibling named custom providers. Extracts reusable OpenAI-compatible/modelsparsing/fetching helpers and threads them through both the active-base-url and per-named-provider paths. - PR #2512 by @dso2ng (refs #2477, Slice A) — Show an elapsed timer on the running automatic-compression card so long WebUI context-compression pauses no longer look frozen while the browser waits for the
compressedevent. StampsstartedAton thecompressingSSE event, ticks once per second, and switches to a5+ mincap label past the Slice A bound so the UI never frame-freezes at05:00. Browser-transient state only — no SSE contract change and no server-side resume reconstruction. - PR #2528 by @Michaelyklam (closes #2518) — Guard New Conversation creation while a previous
/api/session/newrequest is still in flight, so cold model/provider catalog resolution gives immediate pending feedback and rapid repeated clicks reuse the same create request instead of enqueueing duplicate blank sessions. Coalesces concurrentnewSession()calls behind a single in-flight promise, disables the sidebar button witharia-busy="true", and shows a localizedCreating new conversation…composer status. - PR #2530 by @franksong2702 (refs #2529) — Keep Kanban drag/drop status updates from also opening the task detail pane. Two failure paths were both producing detail-pane opens after drag/drop: the browser's trailing synthetic click after
drop, and the generic task-update helper opening detail on every PATCH. The fix adds a time-windowed_kanbanSuppressCardClickUntilset onondragstart/ondragend/ondropand routes drag/drop status changes through a board-only update path. Explicit card click and keyboard activation remain unchanged.
Added
- PR #2294 by @junjunjunbong — Add a
show_previous_messaging_sessionssetting so users can opt back into seeing previous messaging sessions that were replaced bysession_resetor auto-compression. The preference is wired through boot, settings persistence, and the sidebar projection. Also adds a separate "Hide from list" action for imported messaging/CLI sessions that hides individual rows from the sidebar without deleting source history.
Documentation
- PR #2511 by @franksong2702 (refs #2502 / #2503) — Update the
docs/ui-ux/demo appearance controls to initialize asclass="dark" data-skin="slate"instead of the deprecateddata-theme-only buttons and legacy theme names. Brings the demo pages in line with the live Theme + Skin contract referenced from the newdocs/CONTRACTS.mdso contributors following the contract-index path don't land on stale demos. - PR #2509 by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 3b approval/clarify seam shipped in v0.51.89. The RFC now marks Slice 3b as shipped and defines the next Slice 3c queue/continue + goal control gate: route those controls through
RuntimeAdapter.queue_message(...)/update_goal(...)only after pinning stable response contracts, bounded unavailable-control behavior, replayable lifecycle/status evidence, ordering/idempotency expectations, and explicit non-goals for runner/sidecar ownership or a WebUI-owned queue/goal scheduler. Docs + adapter-seam regression test only — no runtime/control routing changes in this PR.
Added
- Geist Contrast skin — Add a new Geist-inspired
geist-contrastskin with neutral monochrome surfaces, restrained selected/sidebar states, and dark-mode#FFF175primary accents with black foreground on solid accent controls.
[v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index)
Changed
- PR #2496 by @Michaelyklam (refs #1925) — Route approval and clarify responses through the default-off
RuntimeAdapter.respond_approval(...)/respond_clarify(...)seam whenHERMES_WEBUI_RUNTIME_ADAPTER=legacy-journalis enabled. The defaultlegacy-directpath still uses the existing callback helpers directly, legacy no-id responses keep their historicalok: trueshape, and stale explicit approval ids are now bounded as not-active instead of falling back to the oldest queued command. No approval queue, clarify queue, callback registry, runner, sidecar, queue/goal migration, or cached-agent state is introduced.
Added
- PR #2500 by @mccxj — Surface
SOUL.md(the agent's third-person voice/persona profile, stored atHERMES_HOME/SOUL.mdalongsideconfig.yaml/.env) as a third section in the Memory panel next to MEMORY.md (notes) and USER.md (profile).GET /api/memorynow returnssoul,soul_path, andsoul_mtime;POST /api/memory/writeacceptssection="soul"writing toHERMES_HOME/SOUL.md(not insidememories/). Redaction still applies, i18n labels (agent_soul/no_soul_yet) added across all 11 locales, newsparklesLucide icon for the section header.
Fixed
- PR #2499 by @franksong2702 — Keep server-idle session rows from inheriting stale local streaming fields during sidebar optimistic merging, so PWA/browser caches cannot keep a completed session's spinner alive after
/api/sessionsreports no active stream or pending user message. - PR #2501 (closes #1855) — Short-circuit the
resolve_model_providerstage inPOST /api/chat/start(and sibling chat-handler call sites) when the request already carries an explicit(model, model_provider)pair and the model isn't@provider:model-qualified. The new fast path in_resolve_compatible_session_model_state()returns the inputs verbatim without callingget_available_models()— that catalog rebuild can do network I/O (custom OpenAI-compat/models, OpenRouter/models, LM Studio probes, credential-pool refresh) under an RLock thundering-herd guard and was observed wedging a single request for 115 seconds in a production-grade local deployment. The recurrence captured via the PR #1911 stage diagnostics confirmed the wedge sat entirely inresolve_model_providerwhile every other stage completed in <5 ms. Users behind default-60s reverse proxies (nginx / Apache / Caddy / Cloudflare) were seeing a502 Proxy Errorwhile the WebUI eventually completed the run anyway, creating a duplicate-send risk if the user retried in the browser. The slow path is preserved for the inputs that genuinely need it: bare/un-qualified models without a storedmodel_provider(cross-provider repair),@provider:model-qualified strings (active-provider validation per #1253), and empty models (default-model lookup). 14 new regression tests intests/test_issue1855_resolve_model_provider_fast_path.pycover both directions — fast-path skips, slow-path still fires — including a static check that the short-circuit precedes the catalog call in source order.
Documentation
- PR #2503 by @franksong2702 (refs #2502) — Add
docs/CONTRACTS.mdas a public contributor-facing routing index that points UI/UX, runtime/state, and onboarding/setup changes to the relevant public docs (DESIGN.md, AGENTS.md, RFCs, troubleshooting) before contributors edit code or open PRs. Also addsdocs/UIUX-GUIDE.mdsynthesizing the calm-developer-console UI/UX principles from DESIGN.md / README.md / THEMES.md /docs/ui-ux/into one contributor guide, refreshes the README and THEMES.md skin lists to cover all ten built-in skins (catppuccin+nous), and tightens the AGENTS.md / CONTRIBUTING.md contribution-style notes for state-layer and evidence requirements. Docs-only — no runtime or maintainer-policy changes.
[v0.51.88] — 2026-05-18 — Release BL (stage-381 — 3-PR security + UX + lineage batch — session-bound CSRF tokens for unsafe browser requests + quoted-reply selected-text composer append + compression-continuation sidebar collapse)
Security
- PR #2484 by @franksong2702 (refs #1909) — Add session-bound CSRF token protection for authenticated unsafe browser requests, layered on top of the existing Origin/Referer same-origin checks. New helpers in
api/auth.pyderive a per-session HMAC token from the HttpOnly session cookie's server-side token (rotates on login, invalidates on logout/expiry).api/routes.py_check_csrf()now requires a validX-Hermes-CSRF-Token(legacyX-CSRF-Tokenaccepted) on authenticated POST/PATCH/DELETE/PUT requests with a browser Origin/Referer./api/auth/loginand/api/csp-reportremain exempt so the bootstrap and CSP-report paths stay unauthenticated; non-browser callers (curl, MCP, agent) without Origin/Referer continue to bypass the token check. The shell template now injects the token via__CSRF_TOKEN_JSON__and the frontend attaches it to same-origin unsafe fetches.
Added
- PR #2485 by @franksong2702 (refs #2481) — Add a ChatGPT-style "Reply with selection" floating button that appears when the user selects visible chat transcript text. Clicking the button appends the selection into the composer as a Markdown blockquote, with repeat selections appending additional quoted blocks for multi-selection workflows. Frontend-only slice: no new backend routing, message schema, or persistence contract — the existing composer/send path is reused. New i18n labels/title/aria text across locales including
zh-Hant.
Fixed
- PR #2493 by @dso2ng (closes #2489) — Collapse WebUI auto-compression continuations into the parent session's sidebar row so long-running conversations stay visually continuous.
static/sessions.js_sessionLineageKey()now accepts asessionsByIdmap and recognises thepre_compression_snapshotparent-chain shape as compression lineage even when the continuation lacks explicit_lineage_root_id/lineage_root_idmetadata. The existing child/fork guard is preserved; only the snapshot-parent case is folded back into a single sidebar row. Regression test covers the #2489 case where parent snapshot + continuation were both visible but the continuation carried onlyparent_session_id.
Documentation
- PR #2483 by @franksong2702 (refs #2364) — Add a narrow README note for the community ARM64 Android AVF field report: Hermes Agent + WebUI running inside a Debian 12 VM on a mid-range Android phone with cloud-hosted inference. The note frames the report as a compatibility signal rather than an official support baseline or provider/model benchmark, and records practical mobile caveats around first-install compile time, Android tab reloads, and battery optimization.
- PR #2487 by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 3a cancel-control implementation shipped in v0.51.86. The RFC now marks Slice 3a as shipped and defines the next Slice 3b gate for approval/clarify controls: route them through
RuntimeAdapter.respond_approval(...)/respond_clarify(...)only after pinning response-shape stability, bounded missing-prompt behavior, replayable request/resolution events, duplicate-response safety, and explicit non-goals for queue/goal and runner/sidecar work.
[v0.51.87] — 2026-05-18 — Release BK (stage-380 — 2-PR Docker hygiene + CI gate — read-only mount tmpfs staging + Docker runtime smoke workflow + agent-source boundary inventory + writable-mount startup warning)
Added
- PR #2482 by @Michaelyklam (refs #2453) — Add a durable source/API boundary inventory for the WebUI's remaining Hermes Agent source dependencies: chat execution, runtime events, profiles, goals, slash/plugin commands, provider/auth/model catalogs, redaction parity, and imported Agent/Gateway sessions. The new RFC tracks replacement API contracts before the source mount can be removed.
Changed
- PR #2482 by @Michaelyklam (refs #2453) — Make the multi-container source boundary more explicit: Docker docs and README now link the boundary inventory, and
docker_init.bashemits a startup warning when the WebUI sees a writable agent-source mount instead of the default read-onlyhermes-agent-srcmount.
Fixed
- PR #2490 by @nesquena-hermes — Multi-container Docker startup is no longer broken by the v0.51.84
:romount onhermes-agent-src.docker_init.bashwas callinguv pip install "$_agent_src[all]"against the mounted source tree directly. setuptools'egg_infobuild step toucheshermes_agent.egg-info/inside the source tree even under PEP 517 build isolation, whichEROFS-failed on the now-read-only mount and (underset -e) killed startup of every multi-container deploy. The init script now stages the agent source into/tmp/hermes-agent-buildviarsync(with acp -afallback for images without rsync, both excluding any pre-baked*.egg-info,build,dist, and__pycache__artifacts) and runs the install against that writable copy, leaving the underlying:romount untouched. Stage dir is removed after the install completes. This regression was caught by the new Docker runtime smoke gate (below) on its very first CI run against its own PR — 5800+ source-level pytests + the independent reviewer's eyeball had all missed it on PR #2470.
Infrastructure
- PR #2490 by @nesquena-hermes — Add a Docker runtime smoke gate (
.github/workflows/docker-smoke.yml) triggered on PRs and pushes tomasterthat modifyDockerfile,docker_init.bash,docker-compose*.yml,.dockerignore, or.env.docker.example. Validates every compose file parses (docker compose config), then matrix-runs the single, two-container, and three-container variants end-to-end: rebuilds the localDockerfileand re-tags it asghcr.io/nesquena/hermes-webui:latestso the multi-container variants exercise PR-level changes rather than the previously-released registry image,docker compose up -d --waits with a 120s health window, probes/health, and greps startup logs for known-bad signatures (EROFS,Traceback,PermissionError,error_exit,!! ERROR,!! Exiting script,groupmod: cannot,usermod: cannot,Failed to set). Closes the source-only-test gap that let v0.51.84's:ro-mount ×chown -h ... {} +startup regression reach review with 5800+ green pytests. Workflow runs withpermissions: contents: read, uses per-run project names and a pre-flight orphan reaper for safe concurrency, and unconditionally tears down all volumes/networks in anEXITtrap. Two new source-level invariants intests/test_docker_docs_and_readonly.pypin the staging path so the underlying:ro-incompatible call doesn't regress.
[v0.51.86] — 2026-05-17 — Release BJ (stage-379 — 4-PR review-bypass batch — memory-provider session lifecycle + cross-provider /model alias + RuntimeAdapter cancel seam + Fork-from-here messaging coord)
Fixed
- PR #2461 by @starship-s — Add a WebUI-side memory-provider session lifecycle for batch-extraction providers (OpenViking, etc.). The new
api/session_lifecycle.pymodule tracks per-session generation, segment ownership, and anin_flightflag with athreading.Condition, so a late-finishing commit can only advancecommitted_generationagainst its captured generation without erasing newer turns marked during the commit.mark_turn_completedruns post-turn after save/cancel/completed-journal guards;commit_session_memoryruns at session boundaries (new session, eviction, shutdown) outside cache locks and per-session mutation locks.register_agent/unregister_agentpreserves dirty segment owners so failed work remains retryable even if the cache drops the current agent reference.drain_all_on_shutdownflushes every registered session with uncommitted work at process exit. - PR #2473 by @ts2111 —
/model <alias>now correctly routes cross-provider custom-model aliases to theircustom_providers[].namerather than incorrectly falling through to the active provider'sconfig_base_urlbranch. Adds a custom-providers prefix check inresolve_model_provider()between the explicit early-return carve-outs and theconfig_base_urlcatch-all, and exposes a top-levelaliaseskey in/api/modelsso the frontend can resolve user-defined/model <alias>shortcuts.cmdModel()now fetches/api/models, resolves the alias, fuzzy-matches the dropdown, and falls back to a directPOST /api/session/updatewhen no dropdown match exists. - PR #2480 by @Michaelyklam (closes #2472) — Make "Fork from here" use the same merged messaging-session transcript coordinate space that
/api/sessionexposes, so forking an older message no longer silently copies the full sidecar when CLI/Gateway history inflated the visible message offset. Extracts the merge logic into_merged_session_messages_for_display(session, cli_messages)and routes bothGET /api/sessionandPOST /api/session/branchthrough it. The frontend snapshots the source session id across the async full-history load (so a fast sidebar switch can't fork the wrong session), reloads the forked transcript fully after creation, and the branch handler best-effort saves the source session before slicing to keep undo/retry state coherent.
Changed
- PR #2479 by @Michaelyklam (refs #1925) — Route Stop Generation through the default-off
RuntimeAdapter.cancel_run(...)seam whenHERMES_WEBUI_RUNTIME_ADAPTER=legacy-journalis enabled. Implements the first code slice of the Slice 3a cancel-control gate accepted in #2469 / v0.51.85. The defaultlegacy-directpath still callscancel_stream(...)directly; the adapter branch preserves the existing{ok, cancelled, stream_id}JSON response contract. No new cancellation registry, runner, sidecar, approval/clarify, queue/goal, or cached-agent state is introduced — adapter remains a pure protocol translator.
[v0.51.85] — 2026-05-17 — Release BI (stage-378 — 3-PR batch — workspace-prefix display leakage fix + release-tag update banner + Slice 3a cancel-control gate RFC)
Fixed
- PR #2145 by @swftwolfzyq — Prevent internal
[Workspace::v1: …]metadata from leaking into the visible user transcript when a failed provider/retry path echoes an optimistic draft followed by the workspace prefix and the real prompt. Adds_looks_like_current_user_turn(msg, msg_text)to match the current human turn even when the internal tag appears mid-text — only when the text after the sentinel exactly matches the submitted prompt — and routes the merge/dedupe/display-normalization paths in_merge_display_messages_after_agent_resultand_find_current_user_turnthrough it. Replaces_has_new_assistant_replylength-delta gating in_periodic_checkpointwith the new_assistant_reply_added_after_current_turnhelper, which slices result messages from the current-turn position before counting assistant deltas — silent/no-response failures are now detected from the current turn alone instead of being masked by prior assistant content. - PR #2146 by @swftwolfzyq — Track WebUI update checks against the latest published release tag instead of every commit on the upstream branch, so operators who only want released versions stop seeing noisy update banners for post-release development commits. Falls back to branch-based detection when no release tags are available. Splits
git describe --dirtyinto a fast base describe plus a bounded dirty probe so WSL-mounted workspaces never block version detection on a slow--dirtywalk, keeping the base version visible even if the dirty probe times out.
Documentation
- PR #2469 by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 2 seam shipped by marking Slice 2 complete and defining the first Slice 3a cancel-control gate. The new gate scopes Stop Generation through
RuntimeAdapter.cancel_run(...)only, pins behavior-preserving cancellation, journal/status coherence, idempotent duplicate cancel, and explicit non-goals for approval/clarify, queue/goal, runner/sidecar, and public chat-start response changes.
[v0.51.84] — 2026-05-17 — Release BH (stage-377 — 1-PR Docker hygiene — agent-image upgrade docs + read-only WebUI source mount + chown prune widening)
Changed
- PR #2470 — Multi-container Docker hygiene pass. The
hermes-agent-srcnamed volume indocker-compose.two-container.ymlanddocker-compose.three-container.ymlis now mounted read-only on the WebUI service (the WebUI only reads it to install the agent's Python dependencies at startup), bringing the actual mount mode in line with the existingdocs/docker.mdarchitecture diagram. To keepdocker_init.bashstartup compatible with the new read-only mount,chown_home_hermeswebuinow prunes the entire/home/hermeswebui/.hermes/hermes-agentsubtree from the ownership walk instead of only.git/objects— the WebUI never writes to the agent source, so the previous narrower carve-out was always a nicety, and on a:romount it would have returnedEROFSand killed startup underset -e. The widened prune also subsumes the original #2237 macOS bind-mount case (the.git/objectspacks are inside the now-pruned subtree). The default${HERMES_WORKSPACE:-~/workspace}workspace bind is changed to${HERMES_WORKSPACE:-${HOME}/workspace}so the path resolves consistently across Linux, macOS, WSL2, and Docker Desktop on Windows (matching the single-containerdocker-compose.ymlconvention). No behaviour change for users who setHERMES_WORKSPACEexplicitly.
Documentation
- PR #2470 —
docs/docker.mdgains an "Upgrading the agent container" section documenting the root cause of #1416: thehermes-agent-srcnamed volume caches the agent's/opt/hermessource tree on first run, and Docker reuses the cached volume on every subsequentcompose up— even afterdocker pullof a newer agent image. The new section gives the canonicaldown → docker volume rm → pull → up -drecipe and the same upgrade pointer is mirrored as a comment block in both multi-container compose files. A new "What the multi-container setup isolates (and what it doesn't)" section explicitly frames the two/three-container setups as process, network, and resource isolation, not filesystem isolation — calibrating expectations for users who reach for multi-container expecting a trust boundary between the chat UI and the agent.
[v0.51.83] — 2026-05-17 — Release BG (stage-376 — 12-PR contributor batch — chat-start adapter parity + populated-core journal recovery + thinking card dedup + context metadata refresh + model cache fingerprint + stream fade cap + manual cron delivery + active-session spinner + email gateway label + thinking copy button + /theme i18n + compact activity semantics)
Added
- PR #2460 by @Michaelyklam (closes #2449) — Add a copy button to Thinking card headers so users can copy the card's reasoning text without selecting the
<pre>manually. The button stops header-toggle propagation and shows the same short checkmark feedback pattern used by existing copy actions.
Fixed
- PR #2438 by @franksong2702 (fixes #2435) — Keep the default-off
HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journalchat-start path response-compatible with the legacy-direct path by not adding adapter-internalrun_id,status, oractive_controlsfields to/api/chat/startresponses. The adapter facade for the future #1925 runtime split stays an internal protocol-translator seam instead of expanding the public chat-start contract. - PR #2439 by @franksong2702 (fixes #2434) — Recover already-journaled visible assistant text and tool cards even when restart repair first syncs a populated Hermes core transcript into an otherwise empty WebUI sidecar. The core-sync branch now merges non-duplicate run-journal output before clearing stale stream state, closing the carve-out from PR #2427 where recoverable partial output could be silently skipped. Adds
_append_journaled_partial_output(..., dedupe_existing=True)plus helpers_run_journal_has_visible_output,_find_existing_assistant_for_journal_content, and_journal_tool_already_present. - PR #2441 by @Michaelyklam (fixes #2440) — Compact live Thinking cards now reuse the same timeline card across sequential tool calls within a single assistant turn.
finalizeThinkingCardclears thedata-thinking-activemarker by searching the entire assistant turn instead of only the tool group, andappendThinkingreuses the most recent Thinking card when no active marker is set, preventing repeated Thinking cards from stacking as reasoning resumes between tool calls. - PR #2444 by @franksong2702 (fixes #2442) — Refresh session context-window metadata when a session's resolved model changes during deferred hydration or when the user switches models, so high-context models do not stay stuck on a stale prior window and trigger premature compression. Adds a shared
_resolve_context_length_for_session_modelhelper, updatesGET /api/session?resolve_model=1to refresh non-zero persisted windows from current model metadata, resets context metadata on/api/session/updatemodel/provider changes, and applies returnedcontext_length/threshold_tokens/last_prompt_tokensin the deferred client-side resolution path with an immediate context-indicator resync. - PR #2445 by @Michaelyklam (fixes #2443) —
/api/modelsnow fingerprints the in-module provider catalog plus the local Codexmodels_cache.jsonas part of its persisted cache metadata, so server-side catalog additions and Codex local catalog refreshes invalidatemodels_cache.jsonimmediately on the next restart instead of waiting for the 24-hour TTL or manual cache deletion. - PR #2450 by @Michaelyklam (fixes #2447) — Cap the optional streaming word-fade drain after the final
doneSSE event so very large or bursty completed responses render from the canonical session promptly instead of keeping the chat in a live/working state until Stop is pressed. The existing caught-up path and per-token animation wait are preserved for normal responses. - PR #2452 by @Michaelyklam (fixes #2451) — Manual WebUI cron triggers now deliver the same final response or failure notice as scheduled cron runs. The manual-run wrapper reuses the scheduler delivery contract (
[SILENT]skipping, separatelast_delivery_errormetadata, error-notice fallback) with aTypeErrorshim for legacymark_job_runsignatures used by older WebUI test doubles. - PR #2455 by @franksong2702 (fixes #2454) — Keep the sidebar spinner in sync with server session metadata when the currently open session has finished but the browser still has stale local busy state. A new
_reconcileActiveSessionIdleStateFromListhelper clearsS.busy,S.activeStreamId, theINFLIGHTcache, and active-session stream metadata before optimistic merging can re-mark the row as streaming. - PR #2457 by @Michaelyklam (closes #2456) — Email gateway sessions imported from Hermes Agent
state.dbnow normalize as messaging sessions and show anEmailsource label in the WebUI sidebar instead of falling through as unlabelled generic agent sessions. Keeps the Python source-normalization contract (MESSAGING_SOURCES,SOURCE_LABELS), gateway status platform labels, and frontendstatic/sessions.jswhitelist in sync. - PR #2463 by @Michaelyklam (closes #2462) — Align
/themecommand help strings in Russian, German, Simplified Chinese, Traditional Chinese, and French with the current Theme × Skin contract. The localized command descriptions now mentionsystem/dark/lightplus the full skin list throughnous, and French invalid-usage text now uses the actual/themeslash command prefix instead of/thème. Supersedes the parallel-discovery duplicate at #2464 (closed in favor of this PR).
Changed
- PR #2466 by @franksong2702 (closes #2465) — Clarify
Compact tool activitysemantics in Preferences: the setting now describes compact inline activity that preserves the agent timeline, matching the current long-running turn behavior where thinking cards, visible progress notes, and tool Activity bursts stay in chronological order instead of being described as one top-of-turn collapsed block. Renderer behavior is unchanged; this is a description-only correction plus thesimplified_tool_callingdefault comment and regression-test wording.
[v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin)
Added
- PR #2432 by @Michaelyklam (closes #2426) — Add a Catppuccin skin to Appearance settings. The single opt-in skin maps light mode to Catppuccin Latte and dark mode to Catppuccin Mocha, using Mauve as the accent while preserving the existing theme/skin persistence and no-build-step architecture.
Fixed
- PR #2428 by @bengdan — Protect pipes inside parens / brackets / braces from naive
split('|')in the Markdown table renderer. Cells like`(a|b)`,`Union[int|float]`,`(a|b|c)`, and`Union[int|float|str]`now stay in a single column instead of mis-splitting. The fix uses an iterative_protectPipesloop so all pipes inside one bracket pair are caught, not just the first. Also adds a$...$guard so a KaTeX inline-math span straddling|column separators is left alone instead of being stashed as math. Stage-fix on the contributor branch (a) swapped the literal}glyphs in the regex character classes for\x7dhex escapes (semantically identical, but the JS source no longer carries bare close-brace glyphs that confused the brace-countingextractFuncintests/test_renderer_js_behaviour.py); (b) dropped a stray apostrophe stop that would have mis-split('a'|'b')-style string-literal unions; (c) dropped angle brackets</>from the protected-bracket set, after Opus advisor flagged that| x < 5 | y > 10 |would otherwise collapse into a single cell (comparison-operator usage dominates content-grouping usage in real LLM table output); and (d) addedtests/test_issue2428_table_pipe_protection.pywith 12 regression cases covering single-pipe, multi-pipe-in-brackets, apostrophes-with-pipes, the KaTeX-in-table guard, and the angle-bracket comparison-operator case.
[v0.51.81] — 2026-05-17 — Release BE (stage-374 — 6-PR batch — cost-history POSIX lock + prompt-cache tokens + Plugins panel i18n + pending-placeholder chat + journal-replay partial recovery + default-off RuntimeAdapter Slice 2 seam)
Added
- PR #2424 by @Michaelyklam (refs #1925) — Add the default-off
RuntimeAdapterSlice 2 seam.HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journalnow routes chat start through aLegacyJournalRuntimeAdapterfacade over the existing legacy streaming path, while the default remainslegacy-direct. The new adapter interface/payload classes expose start/observe/status/cancel/approval/clarify methods and delegate controls to existing handlers without introducing a runner, sidecar, new process-local queues, cached agents, cancellation registries, or callback registries. - PR #2421 by @Michaelyklam (fixes #2419) — Surface provider prompt-cache read/write tokens in WebUI usage displays. Cache-miss cost issues are now visible in the context tooltip and per-turn usage footer; counters carry through session persistence, SSE usage payloads, and live snapshots so deltas remain accurate across the active turn.
- PR #2425 by @mccxj — Wire Settings → Plugins panel into the existing i18n system. Panel title, description, empty state, and per-plugin labels (hooks, enabled/disabled, load failures) now respect the user's language preference; 10 new keys ship in English with
TODO: translateplaceholders in 9 additional locales.
Fixed
- PR #2418 by @Michaelyklam (fixes #2402) — OpenRouter cost-history snapshot updates now take a provider-specific POSIX file lock around the read-modify-write cycle, preserving the existing process-local lock while preventing lost snapshot updates if WebUI is deployed with multiple worker processes sharing one Hermes home/state directory.
- PR #2431 by @Michaelyklam (fixes #2429) — Chat sends now render the assistant-side pending
Thinking…placeholder immediately after the user turn is echoed, before/api/chat/startreturns a stream id or the first SSE event arrives. The existing stale-stream guard remains in place for ordinary reasoning updates — only the explicit pre-stream placeholder path is allowed through. - PR #2427 by @franksong2702 (fixes #2423) — Recover already-journaled visible assistant text and tool cards when a WebUI process restart interrupts an in-flight browser-originated turn. The stale-stream repair path now materializes run-journal output before the explicit interrupted marker instead of collapsing the turn to "no agent output was recovered."
Documentation
- PR #2416 by @Michaelyklam (refs #1925) — Expand the runtime-adapter RFC with the concrete Slice 2 adapter-seam contract: minimal
RuntimeAdaptermethods, payload fields,legacy-direct/legacy-journalfeature-flag rollback path, legacy-backend mapping, explicit non-goals, and adapter-seam acceptance tests. Keeps the next step scoped to a reversible protocol-translator boundary over the journaled legacy path, not a runner/sidecar or execution-ownership move.
[v0.51.80] — 2026-05-17 — Release BD (stage-373 — 2-PR batch — provider config flag filter + stale compaction greeting heuristic)
Fixed
- PR #2415 by @Michaelyklam (fixes #2399) —
providers.only_configuredand other scalar flags under the top-levelproviders:config mapping no longer appear as fake provider groups in the model picker. Provider detection now only seeds picker groups from known provider ids/aliases or dict-shaped provider configs, so filtering flags cannot render asOnly-Configured. The gating contract is documented inline inapi/config.py(within the existing_PROVIDER_MODELS/_PROVIDER_DISPLAYmembership block) so the test_issue604 source-scan stays satisfied. - PR #2417 by @nesquena-hermes (co-authored by @franksong2702, supersedes #2309, closes #2308) — Compressed sessions with hidden "resume active task" context no longer treat a short fresh greeting (
hi,hello, plus 6 CJK greetings) as implicit permission to continue an old agent task. Explicit continuation prompts (continue,resume, plus 4 CJK continuation phrases) still keep the compacted task context. The new helpers (_normalize_fresh_chat_text,_is_casual_fresh_chat_message,_has_task_resume_compaction_marker,_context_messages_for_new_turn) require BOTH the compaction phrase AND a task-resume keyword in the SAME message before treating it as a stale-task marker (precision-preserving guard). Length cap of 24 chars + workspace-prefix normalization + exact greeting-set match prevent false positives. CJK greetings/continuation terms are stored as Python\u-escape sequences soapi/streaming.pypasses thetest_title_sanitization::test_title_generation_source_has_no_cjk_literalsEnglish-only-source invariant; runtime values are unchanged. Stage-372 Opus advisor pass caught two CJK codepoint typos (嘖→嗨,哈喂→哈喽) in the maintainer rebase and corrected them; new regression testtest_all_cjk_greetings_drop_stale_compaction_contextpins all 6 CJK greetings against future codepoint drift withU+XXXXfailure messages.
[v0.51.79] — 2026-05-16 — Release BC (stage-372 — 5-PR batch — text-mode image history fix + Activity-group compression boundary + named custom provider routing + quota chip Settings toggle + RFC docs)
Added
- PR #2413 (self-built follow-up to v0.51.78's #2082, closes the quota-chip default-on regression) — New "Show provider quota chip in composer" checkbox in Settings → Preferences, default off. When disabled (the new default), the chip is hidden at all viewports and the
/api/provider/quotafetch is skipped entirely. When enabled, the existing@media (max-width:1399.98px)gate from stage-371 still restricts the chip to wide desktops only. Per Nathan's directive 2026-05-16 immediately after stage-371 shipped — users get explicit agency over an ambient composer-chrome element. Wired throughapi/config.py_SETTINGS_DEFAULTS,static/boot.js,static/panels.jsround-trip,static/ui.jsshort-circuit-when-disabled,static/index.htmlSettings field, and 11 locales instatic/i18n.js.
Fixed
- PR #2406 by @Michaelyklam (fixes #2398) — The fallback synchronous
POST /api/chatroute now passes the active WebUI config into the conversation-history sanitizer, so text-mode providers do not receive historical nativeimage_urlcontent parts when direct API callers use the legacy chat endpoint. This brings the sync route in line with the streaming chat path fixed for #2297. - PR #2408 by @Michaelyklam (fixes #2404) — Auto-compression cards now close the current live Activity burst before rendering, so post-compression tools start a fresh
Activityrow instead of joining the pre-compression tool group across a real timeline/context boundary. Adds acloseCurrentLiveActivityGroup()helper that clears thedata-live-activity-currentmarker beforeappendLiveCompressionCard()inserts the compression card. Resolves the DEFER from stage-370 Opus advisor review of PR #2390. - PR #2411 by @Michaelyklam (fixes #2405) — Named
custom:*providers no longer lose vendor-prefixed model selections when the static model picker has not hydrated that model yet. The frontend now treats named custom providers as routable aggregators for both mismatch-warning suppression and missing-dropdown fallback, and live-fetched models keep explicit@custom:name:provider context so selections persist instead of snapping back to the configured default.
Documentation
- PR #2407 by @Michaelyklam — Document the #1925 runtime-adapter gate update: Slice 1 run-journal replay has now passed a 100-trial synthetic replay/restart validation pass on current
origin/master, #2313's selected-session chat SSE cap is shipped, and Slice 2 is ready for a reversible adapter-seam planning PR without moving execution ownership yet.
Test infrastructure
- New regression test
tests/test_quota_chip_settings_toggle.py(6 cases) pins the quota-chip toggle invariants: Settings field present with i18n labels,show_quota_chipdefault-Falsein_SETTINGS_DEFAULTS+_SETTINGS_BOOL_KEYS, render/refresh both short-circuit when disabled (no wasted API calls), boot initializeswindow._showQuotaChipfrom settings + default-false on settings-fetch failure, full panels.js round-trip, 11 locale strings present.
[v0.51.78] — 2026-05-16 — Release BB (stage-371 — stuck-PR sweep salvage — RTL chat + ambient quota chip with composer-clutter gate)
Added
- PR #2409 (maintainer follow-up from 2026-05-16 stuck-PR sweep, co-authored by @malulian and @ai-ag2026, closes #1721 and #2082) — Two stalled contributor PRs absorbed into one self-built release after Telegram UX approval across mobile/laptop/desktop/wide viewports.
- Right-to-left chat layout (salvaged from #1721 by @malulian) — New Settings → Preferences toggle, default off, flips the chat-area direction for Arabic and Hebrew users. Honors @aronprins' design review on PR #1721 (May 13 2026): drops the contributor's composer footer toggle button to keep composer real estate clean. Implementation includes a flash-prevention bootstrap
<script>in<head>(applieschat-content-rtlclass synchronously before any chat content paints), scoped CSS that only flips.msg-row,.msg-bodytables,.tool-call-group-summary, and the composertextarea#msg— the sidebar, workspace panel, settings panel, and any other UI element stay left-to-right. Code blocks (pre,code,kbd,samp,tt,.hljs,.code-block) and tool-call group bodies forcedirection:ltr; text-align:left; unicode-bidi:isolateeven under RTL, because Arabic and Hebrew developers still write English code, command lines, and JSON the same way English developers do (visually verified with embedded Python in an Arabic SSE conversation). Localized in 11 locales (en, it, ja, ru, es, de, zh-CN, zh-TW, pt, ko, fr). - Ambient provider quota chip (overridden from #2082 by @ai-ag2026) — New green pill chip in the composer footer that surfaces the active provider's remaining quota (OpenRouter credit balance shaped as
$X.YZ, or account-limit-shaped providers asN%), with click-through to Settings → Providers. Fetches/api/provider/quotaon boot and on tab visibility return. Hidden below 1400px viewport via@media (max-width:1400px) { display:none !important }because the composer footer at 1280px laptop and 1440px standard desktop was already tight and the chip squeezed adjacent chips (model picker truncated fromClaude Sonnet 4 7toClaude Sonnet 4, workspace dropdown lost text). Mobile users find quota through the dedicated mobile-config drawer; laptop users follow the chip's click-target into Settings → Providers anyway. The chip's value proposition (ambient quota visibility) is preserved on wide displays where there's genuine composer room without trading off existing chip readability.
- Right-to-left chat layout (salvaged from #1721 by @malulian) — New Settings → Preferences toggle, default off, flips the chat-area direction for Arabic and Hebrew users. Honors @aronprins' design review on PR #1721 (May 13 2026): drops the contributor's composer footer toggle button to keep composer real estate clean. Implementation includes a flash-prevention bootstrap
Test infrastructure
- New regression test
tests/test_pr1721_rtl_salvage.py(8 cases) pins the RTL salvage invariants: Settings field + i18n keys present, no composer footer button (negative assertion encoding @aronprins' design objection), bootstrap script runs synchronously in<head>before paint, CSS scoped to chat only (negative tests against.sidebar,.settings-panel,.workspace-panel,html,bodyrules), code blocks force LTR under RTL, tool-call bodies force LTR under RTL, panels.js load/save round-trip,rtlinapi/config.pyDEFAULTS and writable-key allow-list, 11 locale strings present.
[v0.51.77] — 2026-05-16 — Release BA (stage-370 — 1-PR follow-up — live Activity grouping boundary fix)
Fixed
-
PR #2390 by @franksong2702 (refs #2376, #2344, #2347, #2377) — Live progress Activity grouping no longer degrades consecutive tool calls into repeated
Activity: 1 toolrows. The frontend was using one reset helper for two different jobs — resetting where the next assistant text segment should render, and closing the current live Activity group — but those are not the same operation. Tool starts now only reset the next-text-segment anchor; the live Activity group closes only when the model emits a visibleinterim_assistantprogress update (the actual timeline boundary). The flow stays:Thinking card visible progress note Activity: N related tools visible progress note Activity: N related tools final answerAdds a WebUI-only ephemeral progress contract in
api/streaming.pythat asks multi-step tool-heavy turns to emit concise visible progress notes in the user's language, while explicitly forbidding exposure of hidden reasoning, chain-of-thought, scratchpads, secrets, raw logs, or long tool output. Any selected personality prompt is preserved. New regressions cover the progress-contract reach-through, the interim-assistant split boundary, and the consecutive-tools-in-one-Activity-row invariant.
[v0.51.76] — 2026-05-16 — Release AZ (stage-369 — 4-PR safe-lane batch — live timeline preservation + OpenRouter cost history + chat stream cap + credential pool cache)
Added
- PR #2195 by @Michaelyklam (refs #692) — OpenRouter cost history backend. New
GET /api/providers/openrouter/cost_historyendpoint backed by daily snapshots from OpenRouter's/auth/keycumulative spend. Process-local lock around the snapshot read-modify-write critical section so concurrent dashboard refreshes or multiple tabs cannot overwrite newer reads with stale ones. Delta computation handles cumulative-counter resets (key rotation, OpenRouter-side reset) by starting a fresh series and using the current value as that day's delta rather than emitting negative spend. Backend-only slice; the 7-day daily cost chart UI is a separate follow-up.
Fixed
-
PR #2347 by @franksong2702 (fixes #2344) — Preserve live agent timeline across session switches. Previously, switching away from an active stream and returning rebuilt the turn from the persisted
INFLIGHTtail, which is enough to reconnect the stream but is not a full-fidelity DOM timeline — Thinking/tool grouping flattened, interim assistant text moved away from its surrounding context, auto-compression cards could project twice. The restore path now snapshots the live assistant turn DOM during the active stream and, on return, loads the persisted transcript first then merges the live snapshot back in so the on-screen scene is preserved as the user left it. Stampingrow.dataset.sessionIdat turn creation prevents the new live-turn sites from re-triggering the lossy rebuild path. -
PR #2393 by @Michaelyklam (refs #2313) — Cap live chat stream transports to the selected conversation. Previously, keeping many sessions open accumulated one long-lived
/api/chat/streamEventSource per session. NewcloseOtherLiveStreams(activeSid)helper instatic/messages.js;attachLiveStream()now reuses an existing same-session transport first, closes other sessions' chat SSE transports, then opens or replaces the selected session's stream. Background sessions still reattach normally when the user selects them — only the SSE transport is pruned, not the server-side stream ownership. New regression test pins the ordering (reuse first, prune background streams next, replace active transport last). -
PR #2396 by @starship-s — Preserve session agents for credential pools. The per-session
AIAgentcache signature previously mixed stable agent identity with the volatile resolved API key, so credential-pool providers (where each request can resolve a different runtime token even when provider/model config is unchanged) missed the cache every turn and rebuilt the agent — losing warmed cross-turn state such as memory-provider prefetch results for providers like Hindsight. New credential-aware cache-signature helper uses a stable sentinel for credential-pool routes while preserving hashed API-key identity for non-pool routes; reused cached agents refresh runtime credentials in place;AIAgent._primary_runtimestays aligned after refresh so fallback/transport recovery cannot resurrect an old token; agents still in fallback-active state rebuild rather than mutate to avoid mixed primary/fallback runtime state. Static non-pool API keys still participate in the cache signature so explicit credential changes continue to invalidate.
[v0.51.75] — 2026-05-16 — Release AY (stage-368 — 11-PR safe-lane batch — storage + i18n + run-journal parity + attachments + compression sidebar + restart-recovery + text-mode images + tables + settings i18n + German labels)
Test infrastructure
- Stage-368 maintainer fix — pytest no longer self-loops on the
_schedule_restartdaemon thread. Several existing tests intests/test_update_banner_fixes.pycallapi.updates._schedule_restart(), which spawns a daemon thread that eventually callsos.execv(). Those tests monkeypatchos.execvfor the test scope, but monkeypatch teardown can win the race against the daemon thread, restoring the realos.execvbefore the thread fires it — at which point the daemon re-execs the entire pytest process with the original argv, looking from the outside like pytest hangs at 99 % then restarts the suite from 0 % in an infinite loop.tests/conftest.pynow installs a permanent no-op wrapper onos.execvat module-import time so late-firing daemon threads cannot re-exec pytest. Newtests/test_pytest_execv_guard.pypins the guard against future regressions.
Added
- PR #2377 by @franksong2702 (refs #2283, refs #2363, refs #1925) — Run-journal replay timeline parity checks. After #2283 shipped the first run-journal replay slice and #2363 documented the cross-layer state consistency contract, this PR adds explicit parity assertions over the replayed timeline so divergences between the journal and the visible transcript (Thinking → tool calls → assistant text) surface as test failures instead of silent drift.
Fixed
-
PR #2391 by @Michaelyklam (fixes #2389) — Reduce browser storage pressure during service-worker updates and over long-running sessions.
static/sw.jsnow callsdeleteOldShellCaches()BEFOREcaches.open(CACHE_NAME)in the install handler so the new ~2.2 MB shell cache no longer overlaps the old one during a version bump (especially painful on shared-origin quota accounting). A new_clearSessionViewedCount()helper plus extended_clearHandoffStorageForSession()prunehermes-session-viewed-counts,hermes-session-completion-unread, andhermes-session-observed-streamingon every single-session delete and batch-delete so per-session tracking maps no longer grow unbounded. -
PR #2387 by @Michaelyklam (fixes #2386) — Guard
localStorage.setItem('hermes-webui-session', ...)and workspace-panel runtime-state writes withtry { … } catch (_) {}acrossstatic/boot.js,static/sessions.js,static/commands.js, andstatic/messages.js. These convenience writes were previously fatal UI operations on quota-exhausted browsers (especially Firefox public-domain setups where shared quota fills up after a service-worker shell rotation). -
PR #2368 by @Michaelyklam — Hybridize background profile env routing so background title generation, manual compression, and update-summary workers honor a session's non-default profile. The pure thread-local refactor for #2321 was reverted because
hermes_cli.config.load_config()still readsHERMES_HOMEfrom process env. This PR keeps the thread-local layer for WebUI helpers and adds anos.environ.update(runtime_env)mirror under a narrow_ENV_LOCKfor the worker body, with proper restore of prior values. New test assertsOPENROUTER_API_KEYis visible from the worker against a non-default profile. -
PR #2382 by @Michaelyklam (fixes #2380) — Serve raw chat attachments from the per-session inbox in addition to the session workspace. Chat uploads were intentionally moved out of workspaces into a per-session attachment inbox in an earlier release; the transcript renderer still emits stable
api/file/raw?session_id=...&path=<filename>URLs, but_handle_file_rawonly checkedsession.workspaceso inbox-backed uploads rendered as broken images. The URL surface is preserved and a session-attachment fallback is added with path-traversal guards intact. -
PR #2385 by @franksong2702 — Keep fuller compression snapshots reachable in the sidebar. The default behavior hides
pre_compression_snapshot: truerows so archived compression segments do not duplicate the active continuation. A real long Kanban session exposed a narrower failure: the fuller transcript was still present on disk but remained marked aspre_compression_snapshot, so the sidebar surfaced a shorter row and the fuller transcript became unreachable. The fix preserves discoverability without re-introducing duplication in normal cases. -
PR #2371 by @franksong2702 — Clarify interrupted turn recovery after a WebUI restart. WebUI executes browser-originated agent turns inside the WebUI process; if that process restarts mid-turn, the worker dies with it. Run journal replay can only replay events that were already emitted, so the stale-pending repair path is now annotated and refined to make the post-restart state explicit (interrupted, recoverable, or terminal) instead of leaving the user with a half-rendered turn and no signal.
-
PR #2378 by @Michaelyklam — Strip historical images in text-only mode. Current-turn uploads already respect
agent.image_input_mode: text, but saved conversation history still passed nativeimage_urlcontent parts back into later provider calls, breaking text-only providers on replayed turns._sanitize_messages_for_api()gains acfg=keyword argument so the API-history sanitizer can strip historical native image parts when the mode is text. Defaultcfg=Nonepreserves prior behavior for callers that don't pass the new argument. -
PR #2375 by @Michaelyklam — Keep Markdown tables block-level. Pipe tables were already converted to
<table>markup, but the final paragraph pass did not treat generated tables as block-level output, occasionally wrapping them in<p>and breaking the surrounding layout. The fix isolates generated tables and addstableto the paragraph-wrap skip list so valid CommonMark tables render predictably. -
PR #2372 by @mccxj — Settings → Conversation page action buttons now respect locale selection. Pre-fix, the JSON export, MD export, and Copy buttons had hardcoded English labels/titles. Adds
data-i18n/data-i18n-titleattributes plus the missing translation keys so non-English locales no longer see English labels stuck in the middle of a translated screen. -
PR #2381 by @Michaelyklam (fixes #2379) — German relative session-time labels now interpolate the elapsed value instead of rendering the literal
{n}placeholder in the sidebar/header. The German locale now uses function-valued translations for minutes, hours, and days, matching the other locale bundles.
[v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n)
Added
- PR #2363 by @franksong2702 (refs #2361, refs #1925) — Adds
docs/rfcs/webui-run-state-consistency-contract.mdas a documentation companion to the #1925 runtime-boundary RFC. Documents the shared coherence contract across visible transcript, model context, pending turn metadata, live stream, run journal, compression handoff, browser timeline cache, and sidebar metadata. Complementary to #1925: that RFC says where execution ownership should move, this one says what must stay coherent across the current and future state layers.
Fixed
-
PR #2362 by @franksong2702 (fixes #2360) — Markdown table rows no longer become too tall when cell text is wrapped in paragraph tags by the renderer. Adds a table-specific CSS reset for
.msg-body td pand.msg-body th pso the globalmargin-bottom: 10pxrule on.msg-body pdoesn't add unwanted vertical space inside table cells. Especially visible on narrow viewports such as iPad Safari/Chrome. -
PR #2365 by @mccxj (fixes #1106) —
get_available_models()now handles YAML-list formatcustom_providers.modelsentries in addition to dict format. Pre-fix, declaring models as a list ([m1, m2]) or list-of-dicts ([{id: m1, label: ...}]) inconfig.yamlsilently discarded every model from that provider in the picker dropdown because the code only recognized dict shape ({model_id: {}}). Now supports all three YAML shapes consistently with existing provider-config and live-models-fallback handlers. -
PR #2367 by @mccxj — Settings sidebar menu items (Conversation, Appearance, Preferences, Plugins, System) now respect locale selection. Pre-fix these were hardcoded English; only Providers had
data-i18n. Addsdata-i18nattributes plus the missingsettings_tab_pluginskey. Stage-367 maintainer fix applied inline: the PR only added the new key to English, breaking 5 locale-parity tests. Addedsettings_tab_pluginstranslations to all 10 non-English locales (it/ja/ru/es/de/zh/zh-TW/pt/ko/fr).
[v0.51.73] — 2026-05-16 — Release AW (stage-366 — 1-PR safe-lane batch — #2357 compression reference card anchoring fix)
Fixed
- PR #2357 by @franksong2702 (fixes #2355) — Auto-compression reference cards no longer get mixed into the final answer turn after a session rotation. Pre-fix,
_insertCompressionLikeNodeByRawIdx()appended the compression-reference node to the future assistant anchor turn's blocks, which projected the[CONTEXT COMPACTION — REFERENCE ONLY]card into the live tail. The fix inserts the node before the anchor segment so the reference card stays a sibling, not a child of the answer turn.
[v0.51.72] — 2026-05-16 — Release AV (stage-365 — 2-PR safe-lane batch — #2354 recovered pending turn context fix + #2348 Thinking card interim-text echo suppression)
Fixed
-
PR #2354 by @franksong2702 (fixes #2353) — Stale stream recovery now keeps a recovered pending user turn in the model context (
context_messages) as well as the visible transcript. Pre-fix, a server restart during an in-flight turn could restore the user's message in WebUI while omitting it fromcontext_messages, so the next agent turn could forget a prompt that was visibly present just above it. The repair path now appends the recovered user turn to both surfaces with 8-message lookback dedup so already-checkpointed entries are not duplicated. -
PR #2348 by @franksong2702 (fixes #2346) — Thinking cards now suppress exact snippets that are already shown as user-visible interim assistant text, avoiding duplicated progress lines when an agent emits the same sentence through both reasoning and interim-assistant callbacks. Tracks
_liveThinkingTextduring the live stream to strip the visible echo from the live Thinking card display; applies the same suppression in the settled-transcript path so reload/session-switch sees the cleaned-up view too.
[v0.51.71] — 2026-05-16 — Release AU (stage-364 — 3-PR batch — #2349 stale-stream cleanup non-touching + #2343 profiles vs workspaces help card + #2283 run-event journal replay [refs #1925 RFC slice 1] — with Opus-caught replay double-render fix)
Added
-
PR #2343 by @Michaelyklam (refs #2147) — The Profiles panel now includes an inline "Profiles vs workspaces" explainer. The copy clarifies that profiles control how the agent works — identity, memory, skills, model/provider config, and tools — while workspaces control what project/files a session operates on, making the OpenClaw-style role/profile mental model easier to map onto Hermes WebUI.
-
PR #2283 by @franksong2702 (refs #1925) — Adds an append-only WebUI run event journal for browser-originated chat streams (refs #1925). Every SSE event emitted by the legacy in-process runner is mirrored to a per-session JSONL file,
/api/chat/stream/statusreports when replay is available for a dead stream,/api/chat/streamcan replay journaled events with SSE event IDs and a clear stale-restart diagnostic, and the frontend reattach path uses that replay before clearing local running state. Reconnect replay uses the last rendered SSE event id as itsafter_seqcursor so it does not replay already-rendered events, and journal fsync defaults to terminal events only (HERMES_WEBUI_RUN_JOURNAL_FSYNC=eagerrestores per-event fsync). This is the first compatibility slice only: it preserves the existing WebUI runner and does not make active execution survive a WebUI restart. Stage-364 maintainer fix applied inline: Opus advisor caught that live SSE frames emitted by_sse()inapi/streaming.py:2296carry noid:field, so the frontend's_lastRunJournalSeqcursor stayed at 0 during live streaming and a mid-stream error→replay would arrive withafter_seq=0, replaying every journaled event from seq 1 and double-rendering tokens. The fix addsSTREAM_LAST_EVENT_ID: dict = {}as a per-stream side-channel inapi/config.py;put()writes the journal'sevent_idto that dict on every event;_handle_sse_streamreads it at SSE emit time and uses_sse_with_id(handler, event, data, event_id)when present. The queue tuple shape is preserved as(event, data)so existing queue consumers (cancel sentinel, sprint42/51 tests, etc.) are not broken. Cleaned up in the worker's finally block alongside the other STREAM_* dicts. 6 regression tests added covering side-channel dict declaration, writer/reader paths, tuple shape preservation, and cleanup.
Fixed
- PR #2349 by @franksong2702 (fixes #2345) — Clearing stale stream runtime flags no longer refreshes a session's
updated_at, so old compressed continuations should not jump back to the top of the sidebar just because WebUI repaired a deadactive_stream_idduring a read/list request.
[v0.51.70] — 2026-05-16 — Release AS (stage-363 — 4-PR snapshot+journal+UI batch — #2337 compression snapshot runtime-clear + #2334 turn-journal fcntl lock + #2342 INFLIGHT reattach pending row + #2339 workspace panel edge toggle)
Added
- PR #2339 by @Michaelyklam (refs #2211) — The workspace panel now has a small desktop edge toggle that remains clickable after the right panel is hidden, making it possible to reopen the workspace browser without returning to Settings. The existing panel close button and composer workspace button remain unchanged; the new affordance only appears when the workspace panel is closed on desktop widths.
Fixed
-
PR #2337 by @Michaelyklam (closes #2336) — Pre-compression snapshot preservation now also clears stale runtime stream fields when the existing on-disk snapshot is already as complete as the in-memory session. This keeps the load-and-mark branch aligned with the full-save branch and adds regression coverage so archived parent snapshots cannot retain stale
active_stream_id/pending_*state. -
PR #2342 by @franksong2702 (fixes #2341) — Reattaching to an active streaming session now keeps the user prompt that started the running turn visible. Pre-fix, reload/session-switch restore could hydrate from the browser's INFLIGHT stream cache while the backend still held the initiating prompt only as
pending_user_message, so the transcript showed assistant Thinking/Tool activity without the user's just-submitted message. The restore path now merges that pending user row into the live transcript before rendering and updates the INFLIGHT cache, while duplicate suppression checks the current message array so final session payloads do not show the prompt twice. -
PR #2334 by @Michaelyklam (refs #2097) — Turn journal appends now take an advisory
flockaround each JSONL event write and fsync when Unix file locks are available. This keeps oversized submitted-message events from interleaving at the byte level if a future deployment runs multiple WebUI worker processes against the same state directory, while preserving the previous best-effort append path on platforms withoutfcntl.
[v0.51.69] — 2026-05-15 — Release AT (stage-362 — 8-PR follow-up batch — Ollama routing + legacy toolset + cancel copy + cleanup + custom provider mismatch + cron metadata + dead-code removal; #2323 reverted after Opus-caught silent regression, refiled as #2321 reopen)
Added
-
PR #2347 by @franksong2702 — Long tool-heavy streaming turns now preserve the live Thinking / assistant progress / Tool / Command timeline when the user switches away and back. The active stream keeps accumulating token and interim-assistant state while inactive, reloads the persisted transcript before merging the live tail, restores the live turn DOM snapshot instead of replaying tools into a flat list, and anchors automatic compression cards inside the active turn to avoid duplicate cards while an answer is still streaming.
-
PR #2332 by @Michaelyklam (refs #2290) — Cron run history/output cards now surface token/cost metadata when the underlying cron output markdown includes it. The backend parses optional model/token/cost/duration frontmatter from cron output files and returns it from
/api/crons/historyand/api/crons/run; the Tasks panel renders a compact usage strip beside run rows and below expanded output without affecting older outputs that lack usage metadata.
Fixed
-
PR #2322 by @Michaelyklam (refs #2271) — LAN Ollama models selected from endpoint-discovered
custom:<host>-<port>/custom:<host>:<port>picker entries now route through the configuredollamaprovider and base URL instead of surfacing a missingCUSTOM_*_API_KEYerror. The picker still surfaces endpoint-discovered entries; the fix is to recognize them as UI routing hints matching the configured local-server base URL and resolve them via the actualollamaprovider. -
PR #2326 by @Michaelyklam (closes #2232) — Legacy
hermesCLI toolset alias is now normalized tohermes-cli+hermes-api-serverwhen WebUI resolves CLI toolsets from shared Hermes config. Modern Hermes Agent exposes the composite under those two names; older configs that still contain the legacyhermestoolset name no longer surface as "unknown toolset" warnings. -
PR #2327 by @dotBeeps — Cancel-mid-stream messaging now uses the user's configured assistant name (e.g. "Hermes") instead of hardcoded "Skyly". Preferences allow defining an Assistant Name that persists throughout the UI; the cancel copy was the last place still showing the persona placeholder. Backend persisted-cancelled-turn text and frontend live-cancel toast both now read from the same
botNamesetting. -
PR #2328 by @Michaelyklam (closes #2325) — Two cleanup follow-ups from v0.51.68 stage-361 review: (a) when a session is deleted via
/api/session/delete, its~/.hermes/webui/attachments/<sid>/inbox is also removed (orphan accumulation prevention); (b) the deferred stream-recovery listener bound by_deferStreamErrorIfPageHidden()now bails out when the user switches sessions in the same tab — the recovery would otherwise firesetComposerStatus('Reconnected')for a stream the user has moved past. Both fixes are narrow cleanup with regression tests. -
PR #2330 by @Michaelyklam (closes #2329) — Provider mismatch warnings now skip named custom providers such as
custom:zenmux. Custom aggregators can legitimately route vendor-prefixed models likegoogle/gemini-3.1-flash-lite, so_checkProviderMismatch()now treatscustom:<name>the same as barecustomand avoids false-positive "may not work with your configured provider" warnings. -
PR #2331 by @Michaelyklam — Live activity row now shows a transient human-readable progress phrase derived from the current tool category (e.g. "Reading file…", "Searching files…", "Running command…") instead of only the elapsed-time counter
Working 1m 23s. Compact transcript view unchanged. -
PR #2333 by @Michaelyklam (closes #2312 follow-up #1) — Removed dead production helper
_save_pre_compression_snapshot()atapi/streaming.py:1945. The production path now uses_preserve_pre_compression_snapshot()exclusively (which must index snapshots withskip_index=Falsefor sidebar filtering). The dead helper was only called fromtests/test_compression_snapshot_runtime_clear.py; the test is retargeted to exercise the actual production helper instead. Closes follow-up item #1 from the v0.51.66 review (#2312).
[v0.51.68] — 2026-05-15 — Release AR (stage-361 — 4-PR follow-up batch — #2315 profile skill seeding + #2317 theme fallback + #2318 mobile stream defer + #2319 chat upload relocation — with Opus-caught vision-model regression fix)
Added
- PR #2319 by @Michaelyklam — Chat file uploads now land in a session-scoped attachment inbox instead of cluttering the active workspace root. By default uploads are stored under
~/.hermes/webui/attachments/<session_id>/; operators can override the root withHERMES_WEBUI_ATTACHMENT_DIR, and the agent still receives the absolute uploaded file path for context. Archive extraction stays workspace-scoped (it's an explicit workspace operation). README updated to document the new default location. Stage-361 maintainer fix applied inline: Opus advisor caught that_build_native_multimodal_messageatapi/streaming.py:787required uploads to be underworkspace_root, which would have silently dropped every image upload for vision-capable models once the inbox moved outside the workspace. The fix adds_attachment_root()(fromapi/upload.py) as a second allowed location, with 3 regression tests covering the new code path AND verifying the original workspace + cross-root rejection paths still work.
Fixed
-
PR #2315 by @Michaelyklam (closes #2305, refs #749) — WebUI profile creation now seeds bundled profile skills for newly-created non-cloned profiles, matching the CLI's
hermes profile createbehaviour. Pre-fix, creating a profile via Settings → New Profile (without checking "Clone from active profile") left the profile'sskills/directory empty, which was inconsistent with CLI-created profiles that get the full bundled-skills overlay. The fix callsseed_profile_skills(profile_path, quiet=True)afterprofile_path.mkdir()whenclone_from is None. Cloned profiles still inherit skills from their source — they don't get a second bundled-skills overlay. Seed failures (e.g.hermes_cliunavailable in Docker fallback) are logged as warnings, not fatal — profile creation still succeeds. -
PR #2317 by @Michaelyklam (refs #2312 follow-up #2) — Appearance boot reconciliation now treats explicit
light,dark, andsystemlocalStorage theme values as user selections when a prior Settings autosave failed. Pre-fix, the predicatelsHasExplicitTheme = lsTheme === 'system'only treated 'system' as explicit, so a user who pickedlighton a server defaulted todark(or vice versa) with a failed autosave still reverted to the server default on refresh. Now broadened to['system','light','dark'].includes(lsTheme). Skin handling was already correct (lsSkin !== 'default'). Closes follow-up item #2 from the v0.51.66 review (#2312). -
PR #2318 by @Michaelyklam (closes #2307) — Mobile/Android backgrounded tabs no longer show a permanent
**Error:** Connection lostbanner when the backend stream is still alive and able to replay buffered events. Pre-fix, the SSE error finalization fired regardless of page visibility state, so any tab discarded by the mobile OS (battery saver, tab compression, brief switch to another app) showed a permanent error even though the stream could be re-attached on visibility return. The fix defers inline stream error rendering whiledocument.visibilityState === 'hidden'ordocument.wasDiscarded === true, then on visibility return polls/api/chat/stream/status?stream_id=.... If the stream is still active, reattaches with a freshEventSource. If not, falls back to the settled-session restore path. If both paths fail, falls back to the original error rendering. Behaviour on desktop and on tabs that ARE visible is unchanged.
[v0.51.67] — 2026-05-15 — Release AQ (stage-360 — 3-PR streaming-lane batch — #2279 stream completion recovery + #2299 profile-scoped aux routing + #2306 workspace panel polish — with _ENV_LOCK narrow-lock architectural fix)
Fixed
-
LAN Ollama models selected from endpoint-discovered
custom:<host>-<port>/custom:<host>:<port>picker entries now route through the configuredollamaprovider and base URL instead of surfacing a missingCUSTOM_*_API_KEYerror. Refs #2271. -
PR #2279 by @franksong2702 (closes #2262 + refs #2168) — WebUI stream completion recovery gaps closed for both
notify_on_completebackground tasks and the preserved-task-list compression marker UI. Pre-fix, completions held in the agent process registry were never drained by the WebUI gateway session because the gateway session platform was unset. The fix routes the completion queue by process session key before injecting any notification into a WebUI turn. Separately, the preserved-task-list compression marker — an internal sentinel — was sometimes the only assistant text rendered after a context compression turn timed out, leaving a confusing "preserved tasks" message with no actual response. The frontend now suppresses the marker when it's the only assistant content and the run state is terminal. -
PR #2299 by @starship-s — Background workers (title generation, manual session compression, update-summary generation) now correctly inherit profile-scoped configuration when a profile-scoped chat triggers them. Pre-fix, those workers read default-profile configuration instead of the session/request profile, so auxiliary model routing silently used the wrong configured model or failed provider resolution entirely. The fix threads the active profile context through
_run_background_title_update,_run_background_title_refresh, and the manual compression and update-summary helpers, with regression tests covering all three paths. -
PR #2306 by @dobby-d-elf (follow-up to v0.51.66) — Workspace panel header polish + test cleanup. Single close button on the workspace panel (was double in some states), tooltip now reads "Close" (was inconsistent label),
.close-previewopacity removed so the X button matches other panel icon styling. Companion test cleanup removes ~293 lines of stale assertions intest_issue781.py,test_sprint41.py,test_sprint44.py, andtest_workspace_panel_session_list.pythat tested behavior either no longer present after #2238 or covered redundantly by other test files.
[v0.51.66] — 2026-05-15 — Release AP (stage-359 — 17-PR safe-lane batch — Docker fixes + UI polish + compression snapshot improvements + i18n parity + profile validation)
Added
-
PR #2287 by @mslovy (refs #2284) — Upload size limit is now runtime-configurable via the
HERMES_WEBUI_MAX_UPLOAD_MBenvironment variable. Previously the effective 20 MB cap was hard-coded across multiple layers. Server-side upload limit moves to runtime config; browser-side preflight check stays aligned with the effective backend limit; archive extraction guard continues to scale with the same configured cap. New_env_mb_bytes()helper inapi/config.pyparsesHERMES_WEBUI_MAX_UPLOAD_MB. -
PR #2291 by @linuxid10t — New "Nous Research" skin option in the Settings → Appearance picker, inspired by nousresearch.com. Monospace typography, steel blue (#4682B4) accent, cool gray text, sharp 1-2px corners, thin dashed borders, technical aesthetic.
-
PR #2301 by @franksong2702 (fixes #2289) — Cron detail Prompt and Output panels now have explicit expand/collapse controls in addition to the default capped scroll view. User preference (per-panel) persists across sessions. Narrow accordion-style expansion, not drag-resize, per maintainer direction.
-
PR #2303 by @franksong2702 (fixes #2246) — New per-turn assistant question jump button in the assistant message footer. Allows quick navigation back to the user question that started a long answer. Desktop-only, hidden during live streaming.
Fixed
-
PR #2275 by @ai-ag2026 — CLI/messaging continuation sessions (sessions stitched from a
parent_session_idchain) now return their full transcript instead of an empty list. Pre-fix,get_cli_session_messages()called_is_continuation_session()while walking the parent chain, butapi/models.pydidn't import that helper. The exception was swallowed byexcept Exception: return [], so valid external sessions could fall through silently. Adds regression coverage that a stitched continuation chain returns a non-empty transcript. -
PR #2277 by @eleboucher — Rootless container runtimes (k8s
runAsNonRoot: true, OpenShift restricted SCC,docker --user, rootless Podman) no longer hit a cascade of permission errors at startup. Pre-fix, the rootless branch skipped the root init phase entirely, but root init also did rsync,/uv_cachepermissions,~/hermeswebuihome directory creation, and/workspacewritability.docker_init.bashnow distinguishes "no root init available" from "root init available but skipped", running the work that doesn't need root in the rootless branch too. -
PR #2280 by @franksong2702 (closes #2276 — partial) — Adds missing Italian and French translations for
settings_label_fade_text_effect+settings_desc_fade_text_effect(added in v0.51.65 PR #2099). Also extends the i18n parity regression test from covering onlyprovider_quota_*keys to also cover settings labels/descriptions, so future PRs that add a new setting label automatically fail CI if they skip a locale. -
PR #2281 by @franksong2702 (refs #2260) — Onboarding DNS probe now classifies failures consistently as
dnsforsocket.gaierror,URLError/OSErrorwrappers around DNS failures, and reserved non-resolvable TLDs (.local,.invalid,.test,.example). Pre-fix, real platform/proxy stacks that wrapped DNS failures as generic exceptions were misreported asunreachable. Maintainer direction on #2260 was to tighten product classification rather than relax the e2e test. -
PR #2282 by @franksong2702 (refs #2264) — Update summary parsing now keeps "unknown-prefix" bullets like
Caveat:orImportant:as regular notice bullets instead of silently dropping them. Pre-fix, once the LLM returned at least one recognizedNotice:bullet, other unrecognized-prefix bullets fell through and disappeared (only the empty-notice fallback at api/updates.py:466 caught them, which never fired when notice_items was non-empty). Preserves existing sentence-splitting fallback for plain prose responses. -
PR #2285 by @dso2ng (closes #2230 — partial) — Pre-compression snapshots (preserved by PR #2227's compression rotation fix) are no longer shown as duplicate active sidebar rows. Adds a
Session.pre_compression_snapshotmarker; backend stamps it when saving the archived old_sid; sidebar projection inapi/models.pyfilters out marker-tagged snapshots from active rows. JSON stays on disk for lineage traversal so the snapshot is still recoverable. Resolves the long-running #2230 follow-up about preserved snapshots accumulating in the sidebar. -
PR #2288 by @linuxid10t — Theme/skin no longer reset to server defaults on page refresh when the appearance autosave POST silently fails (network glitch, transient error). Pre-fix, the async boot IIFE in
boot.jsunconditionally overwrotelocalStoragewith whateversettings.jsonhad on the server. NowlocalStoragewins when it has a non-default theme/skin and the autosave is known to have failed. -
PR #2293 by @franksong2702 (fixes #2237) — Docker startup no longer fails when a bind-mounted
~/.hermes/hermes-agent/.git/objectstree contains read-only git object packs. The root init ownership pass now skips that git object subtree while still chowning the rest of/home/hermeswebui. macOS Docker Desktop bind mounts can now start WebUI without requiring writable ownership over agent git internals. -
PR #2295 by @ai-ag2026 — Context-compression snapshot preservation now clears archived parent runtime fields (
active_stream_id,pending_user_message,pending_attachments,pending_started_at) before saving the old session id. Pre-fix, a completed continuation session could leave its archived parent looking permanently active/running after compression (sidebar showed the parent as if it had a live in-flight turn). Continuation session's live state is restored from saved locals after the snapshot write so the active turn is not affected. Stage-359 maintainer fix integrated this runtime-clearing into the_preserve_pre_compression_snapshot()helper from #2285 — both PRs touch the same compression rotation block. Stage-359 Opus SHOULD-FIX applied inline: the second branch of_preserve_pre_compression_snapshot()(when an existing on-disk file is at-least-as-complete-as memory) now also clears runtime fields on the loaded snapshot before saving — keeps the contract symmetric so snapshot files never contain live runtime state even via the load-and-mark-only path. -
PR #2296 by @Jordan-SkyLF — Offline/recovery banner now follows the active theme palette via
--warning-*tokens instead of mixing warning colors with a hard-coded--bg-1fallback. Light/custom skins (Sienna, Poseidon, etc.) no longer show a banner that looks detached from the selected palette. Behavior-neutral: offline detection and recovery flow unchanged. -
PR #2300 by @franksong2702 (refs #2240) —
_has_new_assistant_reply()shrink-case detection inapi/streaming.pynow returnsFalseinstead of scanning all messages whenlen(result_messages) < prev_count. Pre-fix, an older assistant reply could hide the current turn's silent-failure banner if the agent dropped history mid-turn. Fail-closed in the exotic shrink case; normal appended-message path unchanged. -
PR #2302 by @franksong2702 (closes #2240 — partial, refs #749 follow-up) — Profile create API now validates
default_model/model_provideragainst/api/modelsserver-side, returning 400 on invalid values instead of writing them to the new profile's config.yaml. Pre-fix, ordinary browser users were protected by the picker, but hand-crafted API requests could create a profile that looked valid until the agent later tried to resolve a nonexistent model. -
PR #2306 by @dobby-d-elf — iPhone PWA mobile shell no longer renders broken/unusable. Restores the pre-#2238 iPhone PWA viewport contract by removing the global
viewport-fit=covershell change, returning standalone top safe-area scoping, and restoring the proven mobile composer padding. Keeps the useful phone-sidebar improvements from #2238 while scoping its 44pxpanel-icon-btnsizing to sidebar controls so the workspace header no longer collapses on narrow panels. Mobile workspace panel header refined into two rows. Tested on both Desktop and iOS.
[v0.51.65] — 2026-05-14 — Release AO (stage-358 — 2-PR held-PR clearance — #2099 opt-in streaming text fade + #2165 pooled Codex quota status)
Added
-
PR #2099 by @dobby-d-elf — Adds an opt-in
Settings → Preferences → Fade text effecttoggle (off by default). When enabled, newly streamed output tokens are revealed through an adaptive playout buffer and animated with an opacity-only fade similar to ChatGPT and other frontier LLM apps. Fade locked per stream to avoid mid-stream toggle rewind; reduced-motion users get non-animated text; live cursor hidden while fade is active; custom renderer onstreaming-markdownparser wraps only newly-appended words; animated spans replace themselves with plain text onanimationend(event-delegated, no listener leakage); unsafe streamedhref/srcvalues blocked via allowlist regex (rejectsjavascript:,data:,vbscript:). 200-350ms fade duration scaling with playback speed, 16ms word stagger, 320ms done-drain cap, 160 wps visual cap. Default-off means existing users see no change. 293-line regression test pinning the contract. -
PR #2165 by @starship-s — Pooled OpenAI Codex quota status surfaced in the Providers panel. Collapsed view shows "Best of N" pool summary (available / exhausted / failed / checked counts); expandable per-credential rows. Concurrent probing capped at
min(_CODEX_POOL_MAX_WORKERS=6, len(probe_items)). Exhausted credentials NOT re-probed during cooldown. Manual refresh = "probe now", but transientNoneprobe results are NOT cached (preserves last-known-good warm snapshot); only known-exhausted snapshot objects are cached. JWT decode (_decode_jwt_claims_unverified) is documented as classification-only (Codex OAuth JWT vs raw OpenAI API key), explicitly NOT for authorization. Per-row plan labels only shown when verified account-limit data is available. 32-test regression suite + 11-locale i18n parity assertion.
Fixed
-
WebUI agent turns now inherit
HERMES_SESSION_PLATFORM=webuiand drain matchingnotify_on_completebackground-process completions into the next model input. Completion events are filtered by the process session key before delivery, so another tab/session's background process output remains queued for its owner instead of being injected into the wrong conversation. -
Marker-only preserved-task-list compression sentinels no longer render as standalone assistant responses after stream recovery or timeout paths. If the frontend receives only that internal marker as assistant content, it replaces it with an explicit "No response received after context compression" error and shows an error toast.
[v0.51.64] — 2026-05-14 — Release AN (stage-357 — 3-PR small batch — docker_init k8s whoami fallback + PWA manifest session routes (closes #2226) + aux title test coverage)
Fixed
-
Silent-failure detection no longer treats old assistant messages in a shrunk result history as proof that the current turn produced a new assistant reply.
-
PR #2270 by @Michaelyklam (closes #2226) — Firefox Android PWA installs from
/session/<id>pages now resolve the Hermes manifest and icons instead of falling back to a generated letter icon. The dynamic<base href>script now runs before manifest/favicon links,/session/manifest.jsonand/session/manifest.webmanifestreturn the real manifest JSON, and session-prefixed manifest routes are now marked as public auth-skip routes. Adds 211 lines of regression coverage for the manifest responses and the session-prefixed 512px icon path. -
PR #2268 by @eleboucher —
docker_init.bashno longer fails under KubernetesrunAsUserconfigurations where the running UID has no/etc/passwdentry. Pre-fix, the barewhoamiinvocation aborted the script underset -ebecausewhoamiexited with a non-zero status on missing-passwd UIDs. Now falls back to a syntheticuid-<numeric-uid>name whenwhoamifails (whoami 2>/dev/null || echo "uid-$(id -u)"). Two-line change.
Tests
- PR #2272 by @Michaelyklam (refs #2235) — 493-line regression test file
tests/test_2235_initial_aux_title.pycovering the first-turn WebUI title-update path when auxiliary title generation is configured. Asserts a valid aux-generated title replaces the provisional first-user-message slice and persists/emits title events; covers fallback preservation, refresh-path parity, and title_status diagnostics for aux success/failure/skipped cases. Test-only change pinning the existing behavior before any refactor of #2235.
[v0.51.63] — 2026-05-14 — Release AM (stage-356 — 2-PR small batch — #2234 aux-model routing + #2265 mixed-case provider canonicalization (closes #2245))
Fixed
-
PR #2265 by @Michaelyklam (closes #2245) — Model picker provider lookup now canonicalizes configured provider keys before loading their configured models. Pre-fix, custom provider keys with mixed casing or underscores such as
CLIPpoxyorsnake_case_providerwere lower-cased during canonicalization, but the resulting canonical key didn't match the rawconfig.yamlkey, so the model allowlist lookup silently returned empty and the model picker dropdown showed no models for that provider. The fix maps canonical provider IDs back to their raw config.yaml provider keys before loadingprovider_cfg. Original config keys are preserved for provider settings rendering. 242-line regression test covering CLIPpoxy + snake_case_provider plus built-in/fallback behavior. -
PR #2234 by @Jordan-SkyLF (post-v0.51.62 rebase, follow-up to v0.51.62's category-refinement portion) — Update summary generation now routes through the documented
auxiliary.compressiontext-model slot instead of a WebUI-onlyauxiliary.update_summarymagic key. The reviewer concern was thatupdate_summarywould have been a non-discoverable WebUI-specific config key; using the existing documented compression/summarization slot keeps the PR self-contained tohermes-webuiand gives users a way to override summary generation through an existing config surface. The existing main-model fallback is preserved if auxiliary resolution or generation fails. Adds route comment explaining why summary generation maps to compression instead of inventing a new task name.
[v0.51.62] — 2026-05-14 — Release AL (stage-355 — 11-PR full sweep — metadata-only cache hit fixes + skill detail + phone UX + display-title projection + escaping + RFC update)
Fixed
-
PR #2244 by @franksong2702 (fixes #2243) —
Archive Sessionno longer fails when the in-memory session cache contains a metadata-only stub for the target. Pre-fix, the route loaded viaget_session(sid)which returned the cached_loaded_metadata_only=Trueinstance, thenSession.save()correctly refused to write because the metadata stub'smessages=[]would have overwritten the full transcript (#1558 guard). Now the archive route reloads the full session from disk before mutatingarchivedand refreshes the cache. Existing CLI/imported-session fallback unchanged. 47-line regression test pinning the route-level behaviour. -
PR #2249 by @franksong2702 (fixes #2248, follow-up to #2244) — Same metadata-only cache hit was happening at
/api/session/pin,/api/session/rename, and/api/personality/set. Adds_ensure_full_session_before_mutation()helper inapi/routes.pythat reloads throughSession.load(sid), replaces the cached entry, preserves LRU ordering, and enforcesSESSIONS_MAXeviction. Applied to all three routes. Parametrized regression coverage forces a metadata-only session into the cache for each route and verifies the saved session keeps its original messages while the cache is upgraded to a full session. (Archive Session in #2244 still uses an inline fix at the same site — a follow-up could refactor archive to use the helper too.) -
PR #2241 by @dso2ng — Long WebUI sessions that received fresher same-session titles via
state.db(typically after compression) but kept older generic JSON/index titles now render the fresher title in the sidebar through a read-onlydisplay_title/_state_db_titleprojection on/api/sessions. Persisted JSONtitlestays unchanged so custom renames and storage semantics are not mutated by a sidebar-only display fix. Manual/custom titles remain authoritative. Adds 169-line regression coverage for the projection path, generic-title detection, and lineage-collapse compatibility. -
PR #2250 by @franksong2702 (refs #1880 — now fully closed) — Skill detail panes for local/profile/external WebUI skills no longer render blank. The list endpoint already resolved active-profile and external skill directories, but the detail endpoint passed the resolved absolute
SKILL.mdpath back intohermes_agent.skill_view(), which now rejects non-relative paths and returned an error payload with nocontent. The browser then rendered a blank pane. Fix keeps the WebUI detail endpoint at the same layer as the list endpoint: once WebUI resolves the skill file, it readsSKILL.mddirectly and builds the detail payload itself. Multi-directory resolution covers local + profile + external skill dirs. -
PR #2253 by @franksong2702 (companion to #2250) — Skills detail pane now renders explicit error messaging when
/api/skills/contentreturns HTTP 200 with an application-level error payload (success: falseorerrorfield). Pre-fix, the UI fell through to "(no content)" which was indistinguishable from a legitimately empty skill body. Also treats linked-file content responses witherroras failures. Static regression test verifies the error guard runs before the empty-content fallback. Backend root-cause fix is #2250; this is a defensive UI layer to keep this class of regression visible in future. -
PR #2255 by @franksong2702 (closes #2254) — Model picker now escapes provider-supplied model names and IDs before inserting them via
innerHTML. Pre-fix, configured model rows, regular model rows, and Providers panel load-error messages all passed raw provider strings throughinnerHTML. With a maliciously named (or just unfortunately punctuated) model ID, the picker could render arbitrary markup. Applies the existingesc()helper to the three missed spots. Defensive cleanup — no known active exploit, but the model picker now consistently uses the project's escaping pattern. -
PR #2257 by @franksong2702 —
start.sh.envloader no longer silently drops valid keys on macOS/bash. Pre-fix, thesource <(grep ...)form produced an empty sourced stream in some environments, so even valid keys likeHERMES_WEBUI_PORTwere not loaded. Now filters.envinto a temporary file before sourcing it. Still filters shell-readonly Docker keys (UID,GID,EUID,EGID,PPID) to avoid readonly-variable crashes. Static-test wording updated to assert the required contract instead of requiring process substitution specifically. -
PR #2259 by @franksong2702 (closes #2258) — Update-link regression tests (
test_issue1579_whats_new_link_404.py) now explicitly pin the throwaway bare repositoryHEADtorefs/heads/master. Pre-fix, on machines whose global Git default branch was notmaster, the bare repo'sHEADcould point elsewhere and the subsequent clone/rev-parse chain silently failed. Test-only change. Makes the fixture deterministic. -
PR #2234 by @Jordan-SkyLF (post-v0.51.61 rebase) — Update summary category handling now preserves all explicit
Notice:andWorth knowing:bullets the summarizer returns instead of forcing a three-item split. Distinct categories are deduplicated against each other so the same content can't appear twice across sections. Keeps the existing fallback grouping when the model doesn't return explicit prefixes. The summary panel becomes scrollable when longer summaries need more vertical room. Caps large update-summary commit input to the latest 24 commit subjects and discloses that scope in the generated summary while keeping the full comparison link available.
Added
- PR #2238 by @franksong2702 (fixes #2231) — Phone-width layouts (≤640px) keep the hamburger drawer entry pattern, but the drawer now lays out
.sidebar-navas a vertical 52px strip with stable 44px touch targets and a left-edge selection indicator instead of a cramped horizontal icon row. PWA chrome alignment:theme-colormeta tag now follows the app chrome--sidebarcolor instead of the chat background--bg, so iOS Safari / PWA status bars visibly match the titlebar/sidebar. Phone composer also reserves the bottom safe area so it is not clipped by rounded corners or the home indicator. Before/after screenshots shipped underdocs/pr-media/2231/.
Docs
- PR #2251 by @franksong2702 (refs #1925) — Updates the
docs/rfcs/hermes-run-adapter-contract.mdRFC to codify the #1925 review direction: WebUI stays broad in product scope but becomes thin in execution ownership. Revised RFC credits Michael Lam's "protocol translator, not runtime surrogate" guardrail, defines the browser event/control contract, classifies current runtime state into runner / journal / adapter / presentation ownership, adds an acceptance-test catalog, and gates the first implementation slice to append-only journal/replay without changing_run_agent_streamingcontrol flow. Preserves @Michaelyklam as the RFC author and adds a revision line for this update so the review thread keeps one source of truth.
[v0.51.61] — 2026-05-14 — Release AK (stage-354 — 3-PR contributor batch — profile model picker + update-banner cleanup + silent-failure detection scope fix)
Added
- PR #2228 by @franksong2702 (refs #749) — Profile creation now exposes the same configured model/provider choices users already see in the composer/settings model picker. New profiles can be created with an explicit
model.defaultandmodel.providerwritten into that profile's ownconfig.yaml, while clone/base-url/API-key creation behavior remains unchanged. Backend validates the chosen model/provider before profile creation so invalid values do not leave a half-created profile on disk. Adds locale entries for English, Chinese, Japanese, Korean, Russian, and Spanish (parity-tested). 74-line regression test pinning the form, backend, and locale-key contract.
Fixed
-
PR #2234 by @Jordan-SkyLF (refines #2207, original v0.51.61 portion) — Three update-banner improvements: (1) Update summaries no longer repeat the same bullet under both "What you'll notice" and "Worth knowing" — visible notice items keep priority, and the secondary section is omitted when there is no distinct detail to show. (2) Update summaries now cap large commit lists (24 + probe item) before sending them to the summarizer and disclose when the summary uses only the latest commit subjects, while keeping the full comparison link available — bounds summarizer cost on large update ranges while remaining honest about coverage. (3) Update banners now wrap generated-summary links and long update text on narrow/mobile screens inside the banner instead of pushing controls off-screen. 108-line regression coverage for short-target dedup, repeated Agent-summary bullets, large-range capped input, and responsive wrapping. (A follow-up commit pushed AFTER stage-354 merged is now shipped in stage-355.)
-
PR #2236 by @jasonjcwu — Silent failure detection in
api/streaming.pynow scans only NEW messages, not the full conversation history. Pre-fix, the_assistant_addedcheck at_run_agent_streamingscanned all messages inresult["messages"](including pre-turn history); if any prior turn contained an assistant response,_assistant_addedwasTrueand the apperror SSE event was silently skipped, leaving the user staring at a blank response after a provider 401/429/rate-limit error. Fix extracts a_has_new_assistant_reply(all_messages, prev_count)helper that only inspects messages beyond the pre-turn history offset (_previous_context_messages); applied to both the main detection path and the self-heal/retry_heal_okcheck. 15-test regression suite covering empty/short/long-history scenarios, the heal path, and thelen < prev_countedge-case fallback. Also includes a small alignment fix totest_issue1857_usage_overwrite.pyso the FakeAgent message shape matches what the real agent produces.
[v0.51.60] — 2026-05-14 — Release AJ (stage-353 — 3-PR overlapping Appearance + critical #2223 compression-rotation data-loss fix + Opus SHOULD-FIX on parent_session_id)
Fixed
-
PR #2227 by @theh4v0c (closes #2223 — critical) — Context compression no longer destroys session history. The previous implementation renamed
old_sid.json→new_sid.jsonbefore the new compressed session had been saved, destroying the only persistent copy of the full conversation. When the summarisation LLM call also failed, the user was left with zero recoverable messages and the bug reportSummary generation was unavailable. N message(s) were removed to free context space but could not be summarized.text with no way to scroll back. The fix removes the destructiveold_path.rename(new_path)call:old_sid.jsonis preserved intact as an immutable pre-compression archive,new_sid.jsonis created fresh vias.save(), andparent_session_idis set on the continuation session so the frontend can traverse the lineage chain back to the original. Even when summarisation ors.save()fails, the original conversation file survives on disk. New 106-line regression test file covers the no-rename invariant, parent_session_id stamping, and marker-only-result preservation. Stage-353 Opus SHOULD-FIX applied inline: the preservation block previously cleareds.parent_session_idbefore saving the snapshot (writingparent=Nonetoold_sid.jsonon disk) and used aif not s.parent_session_idguard when stamping the continuation. Both bugs broke fork-of-fork compression lineage — a/branchfork that subsequently compressed would lose its "Forked from X" badge on the snapshot AND the continuation would skip past the snapshot back to the original fork parent. Maintainer fix removes the parent clearing during preservation (preserves the fork lineage on disk) and drops theif notguard (always stamps continuation toold_sid). Two new regression tests pin both invariants. Traversal now consistently walks new → old → old.parent → ... root. -
PR #2222 by @franksong2702 — Settings → Appearance now wraps the "Load older messages while scrolling up" checkbox in its own
<label>AND moves it into its ownsettings-fielddiv instead of leaving it orphaned after the session-jump description with a stray closing</label>. Stage-353 maintainer resolution adopted PR #2227's stronger structural variant (each preference in its ownsettings-field) over PR #2222's smaller in-place wrap. Regression testtest_session_endless_scroll.pypins the new per-label per-settings-field contract.
Added
- PR #2225 by @franksong2702 (refs #2224) — Adds an Extra Large option to Settings → Appearance → Font size for tablet and large-desktop readability. The new
xlargevalue is accepted by the persisted settings contract, appears alongside the existing Small / Default / Large picker options, and scales the same key UI text surfaces already covered by the font-size preference: sidebar session rows, chat message bodies/headings/code/tables, the composer textarea, workspace file rows, and app-level em/rem text. The picker grid now usesrepeat(auto-fit, minmax(96px,1fr))instead of a fixed 3-column grid so the fourth option doesn't crowd narrow viewports.
[v0.51.59] — 2026-05-14 — Release AI (stage-352 — 4-PR clean batch — _summary_cache LRU cap + re.MULTILINE strip fix + Compact sidebar lineage hide + CONTRIBUTORS/README refresh)
Fixed
-
PR #2217 by @franksong2702 (refs #2215 Fix B) — Drops the leftover
re.MULTILINEflag from the "the user is asking" pre-amble strip pattern inapi/streaming.py:695. PR #2213 removedre.MULTILINEfrom the three sibling wrapper-strip patterns (<think>, MiniMax, Gemma) but missed this one instance. Withre.MULTILINE,^matched the start of any line in the response, so a mid-response line that legitimately started with "The user is asking us to wait" could be stripped silently. Now the pattern only matches when the entire response leads with that wrapper, consistent with the other strips. One-flag, two-character change + regression test pinning the new behavior. -
PR #2216 by @franksong2702 (closes #2215 Fix A) — Caps the
_summary_cachefor per-target update summaries with anOrderedDict-backed LRU bounded at 16 entries. Pre-fix the cache was an unbounded plain dict introduced in PR #2207; cardinality is small in practice (0-2 active update ranges per server lifetime) so this is defensive future-proofing rather than a leak being hit today. Cache hits callmove_to_end()to refresh recency; cache writes callpopitem(last=False)to evict the oldest entry when at capacity. Overwrites of existing keys bypass eviction. Both operations run under the existing_cache_lockfor thread safety. With Fix A and Fix B both shipped, issue #2215 is closed. -
PR #2219 by @franksong2702 (refs #2218) — Compact sidebar density no longer shows compressed-session prior-turn lineage badges or expandable lineage segment rows. Pre-fix, Compact density (the default) exposed
N prior turnsbadges that users read as an affordance for opening earlier conversation history — but lineage segments aren't guaranteed to have complete WebUI-loadable transcripts, so clicking them could lead toSession not available in web UIerrors. Now the sidebar still collapses compressed continuations to the latest tip, but the lineage metadata only renders in Detailed density. Avoids the lineage report fetch/merge work entirely in Compact density since the affordance is hidden. Updates the sidebar regression test to pin the Detailed-only contract. Visual before/after evidence shipped underdocs/pr-media/2218/.
Docs
- PR #2220 by @nesquena-hermes — Refreshes
CONTRIBUTORS.mdand the README contributor section to reflect the 14 releases shipped between v0.51.44 (last refresh) and v0.51.58. Total contributors: 130 → 137. Total PR credits: 568 → 646. Seven first-time contributors added across v0.51.45–v0.51.58: @lucasrc (auth trilogy), @LumenYoung (streaming hot path), @MrFant (reasoning_content whitelist), @xz-dev (thinking-card state + session-scoped metering), @legeantbleu (French locale), @ayushere (ctl.sh macOS compat), @plerohellec. Bucket promotions: @dobby-d-elf (2 → 6 PRs), @samuelgudi / @vcavichini / @hualong1009 / @michael-dg promoted from single-PR to two-PR. @Jordan-SkyLF added to top-contributors with a recent burst of UX polish PRs. @lucasrc and @LumenYoung promoted into the special-thanks roll.
[v0.51.58] — 2026-05-13 — Release AH (stage-351 — 6-PR net-positive ready batch — perf CLI scan cache + thinking-tag leading-only + MCP tools pagination + per-target update summaries + sweep animation tune + cron badge)
Fixed
-
PR #2210 by @Jordan-SkyLF — MCP Tools list in Settings → System no longer renders an unbounded inventory that makes the settings panel scroll-trapping. Added a toolbar (result-count summary, page-size 5/10/20/50/all, search input), bounded scroll area with consistent height, paginated rendering, and focused regression coverage for the large-inventory case. Existing WebUI-only/runtime-only contract preserved (no MCP server probing, no agent-side changes). Visual before/after evidence shipped under
docs/pr-media/2210/. -
PR #2213 by @franksong2702 (fixes #2152) — Literal
<think>/</think>discussions in normal assistant prose are no longer stripped from saved messages and re-renders. The old server cleanup and stored-message render regexes stripped the first closed thinking-looking block anywhere in the content. PR aligns saved/static paths with the existing streaming rule: provider reasoning wrappers (<think>...</think>, MiniMax<|channel>thought...<channel|>, Gemma 4<|turn|>thinking...<turn|>) are stripped only when they lead the response (i.e. the wrapper is the first non-whitespace content). -
PR #2149 by @starship-s —
/api/sessionloads no longer pay the cost of full external CLI session discovery when opening an ordinary WebUI-native chat. Caches CLI/external session scans briefly, skips CLI metadata lookup for ordinary WebUI-native session loads, and reuses a single in-memory ID snapshot during session-index pruning. Messaging, read-only, external-agent, and CLI-marked sidecars still take the CLI metadata path; CLI-only sessions still use the existing fallback. Stage-351 maintainer fix renamed the existing_needs_cli_session_metadata()gate to the broader_session_requires_cli_metadata_lookup()from this PR — strictly more inclusive (now also coversread_only=Truesidecars,session_sourcemarkers, and source_tag/raw_source/platform metadata so legacy-imported sidecars still get the slow path when they need it).
Added
-
PR #2207 by @Jordan-SkyLF (fixes #1579) — Update banner now shows target-aware "What's new?" links: WebUI updates link to the WebUI comparison, Agent updates link to the Agent comparison. Agent-only and WebUI-only update states no longer show a misleading cross-target comparison action. Opt-in settings toggle enables human-readable LLM-generated update summaries for each target's diff; users can still open the original diff from the summary. Cached/generated-summary button states persist across refreshes. Extended update-banner regression coverage for the diff-link and summary flows. Visual evidence:
docs/images/update-banner-whats-new-{before,after}.png+ summary on/off variants. -
PR #2206 by @vcavichini — Cron list now shows a 🤖 emoji badge for jobs running in agent mode (
no_agent=false). Cron detail view shows the configured provider/model next to the Mode badge, falling back to "default" when neither is explicitly set for agent-mode crons. UI-only change. -
PR #2212 by @dobby-d-elf — Tunes the Activity sweep animation introduced in PR #2203 (stage-350) — softer color stops, less aggressive contrast, smoother fade. CSS-only follow-up.
[v0.51.57] — 2026-05-13 — Release AG (stage-350 — 7-PR medium-risk batch — auth trilogy + cancel-status with conflict resolution + Ollama label guard + provider precedence + Activity animation + Opus dedup tightening)
Fixed
-
Issue #2152 — Literal discussions of reasoning tags such as
<think>and</think>no longer disappear from saved or re-rendered assistant messages. WebUI now treats<think>...</think>, MiniMax<|channel>thought...<channel|>, and Gemma 4<|turn|>thinking...<turn|>blocks as hidden reasoning metadata only when the wrapper is the first non-whitespace content in the response; provider wrappers with leading whitespace still strip as before. -
PR #2191 by @lucasrc (auth refactor 1/3) — Thread-safe login rate limiter (new
_LOGIN_ATTEMPTS_LOCK) + PBKDF2 key separation (new_pbkdf2_key()reading.pbkdf2_keyseparately from_signing_key()reading.signing_key— previously both shared.signing_key, a key-reuse anti-pattern across HMAC and PBKDF2 primitives) + transparent migration inverify_password()that re-salts legacy hashes with the new key on next successful login. 241-line regression suite covering the lock + migration paths. Split from earlier #2167 per maintainer review request. -
PR #2192 by @lucasrc (auth refactor 2/3, depends on #2191) — Invalidate password-hash cache when password changes via the Settings panel. The PR #2191 cache lives for the process lifetime, but
save_settings({'_set_password': ...})could mutatesettings.json.password_hashwithout telling the auth module — leaving the cache stale and verifying against the old password until restart. Nowsave_settings()calls_invalidate_password_hash_cache()on both_set_passwordand_clear_passwordpaths. 52-line regression suite +verify_password()simplified to rely on the new hook instead of doing the invalidation itself. -
PR #2193 by @lucasrc (auth refactor 3/3, independent of #2191/2) — Full 64-char HMAC-SHA256 session signatures with upgrade migration bridge.
create_session()now emits the full digest instead of the previous[:32]truncated form;verify_session()accepts both lengths during a transition window so existing sessions survive the upgrade without a forced global logout. Restored the_is_secure_context(handler)heuristic (getpeercert + X-Forwarded-Proto) that the original #2167 had dropped — adds anHERMES_WEBUI_SECUREenv-var override on top of the auto-detect. 42-line regression suite covering both signature lengths + Secure-cookie env-var override. -
PR #2151 by @Jordan-SkyLF — Cancelled chat turns are no longer reported as provider/no-content failures. Classifies user/client cancellation, interruption/abort, provider-empty/no-content, and provider/rate/quota errors separately in streaming error handling. Persists cancelled turns as
_errorassistant markers with verbose copy and aCancellation detailsdisclosure, so reloads match the live UI. Adds race/idempotency guards so worker finalization and/api/chat/canceldo not duplicate cancel markers, late Stop clicks after a completed worker save do not emit contradictory cancel events (_emit_cancel_event = Falseshort-circuits the terminal event when the writeback is stale), and partial streamed text/reasoning/tool-call metadata is still preserved on real cancellation. Stage-350 maintainer resolution merged this PR's cancel-handler guard with #2136's_stream_writeback_is_current()ownership check — both correct guards now coexist on the cancel path. -
PR #2178 by @hualong1009 — Custom-provider models now display correctly in the model configuration list, and bare custom-provider model IDs containing dashes (e.g.
Qwen3.6-35B-A3B) no longer have their hyphens stripped to spaces + last letter lowercased by the Ollama label formatter. Adds anallowOllamaFormatguard derived fromatProvider(the@<provider>prefix on the model id, if any): the Ollama formatter only runs whenatProvideris empty or starts withollama. For@custom:ai_gateway:Qwen3.6-35B-A3Band similar non-ollama @-provider model IDs, the formatter is suppressed and the model badge label preserves the original casing/punctuation. Stage-350 maintainer fix updatedtests/test_ollama_model_chip_label_regression.pyto assert on the newallowOllamaFormat &&guard prefix (the original test asserted on the pre-PR code shape and was failing CI). -
PR #2204 by @Michaelyklam (closes #1894) —
resolve_model_provider()now prefers the configured non-custom provider when it owns a requested bare model id, even when a named custom provider also advertises the same model. Pre-fix,model="deepseek-v4-pro"underprovider="opencode-go"could route to a siblingcustom_providers["opencode-go"]entry that happened to advertise the same model rather than the canonical opencode-go provider. Custom-provider routing for custom-only models is preserved. 157-line regression suite covering the opencode-go/deepseek-v4-pro overlap and explicit provider/suffix parsing.
Added
- PR #2203 by @dobby-d-elf — Animates the "Activity: X tools" composer footer text while the LLM is using tools — subtle shimmer gradient that stops when tool-calling completes. Highlight color follows the active theme. Reduced-motion and mask-support fallbacks render plain muted Activity text unchanged in unsupported or
prefers-reduced-motionenvironments. Also fixes a small flickering/unclickable first "Thinking" block when the user clicks it while the model is still streaming reasoning into it (unrelated to the animation but right next to it on screen).
Stage-350 maintainer fixes
-
api/streaming.py:_partial_already_presentdedup scope tightening — Opus SHOULD-FIX-pre-merge on PR #2151. The dedup loop that prevents double-writing a_partialmarker oncancel_streamre-entry used a substring check (_stripped in _existing or _existing in _stripped) against any prior assistant message — too broad. Any short prior assistant reply like "OK" or "Here is the answer:" would be a substring of many later partial bodies and could silently drop the new partial, resurrecting the #893 data-loss bug on long sessions. Tightened to: only dedup against actual prior_partial=Truemarkers, with exact (whitespace-stripped) content match. Three new regression tests added: (a) short prior non-partial reply does NOT dedup a longer new partial that contains it, (b) exact-content match against a prior_partialmarker DOES still dedup (re-entry safety), (c) prior assistant message with same content but NOT marked_partialdoes NOT dedup (it's from a completed earlier turn). 10/10 partial-cancel tests pass after the fix. -
api/streaming.pycancel-handler conflict resolution between #2151 and the already-shipped #2136 — Resolved a semantic merge conflict on the cancel handler. Both PRs added stale-stream ownership guards at the same site. Kept #2136's_stream_writeback_is_current()check as the strictly-stronger condition (it also catches the case where the stream rotated to a new stream with a new pending_user_message — #2151's standalone check would have let that case fall through). Adopted #2151's_emit_cancel_event = Falsesemantic on the same path so the terminal cancel SSE event isn't emitted in addition to skipping the writeback (otherwise a successful done payload already delivered to the client would be contradicted by a late cancel event). 55/55 tests across both PR suites pass after the resolution. -
tests/test_ollama_model_chip_label_regression.pyupdated to match PR #2178's newallowOllamaFormatguard — The existing static-source test asserted on the pre-PR string and was failing CI. Updated the assertion to require the newallowOllamaFormat &&guard prefix, with an extended docstring explaining the bug class (Qwen3.6-35B-A3B-shaped bare custom-provider model IDs had hyphens stripped to spaces + last letter lowercased by the ollama formatter pre-fix).
[v0.51.56] — 2026-05-13 — Release AF (stage-349 — Tier 1 safe slice — reasoning_content whitelist + fork-from-here absolute index + Firefox sidebar scroll + provisional session titles)
Added
- PR #2202 by @Jordan-SkyLF — Early session titles on chat start. Pre-fix, new conversations sat as "Untitled" until later title generation completed. Now
/api/chat/startderives a provisional title from the first user prompt and returns it in the response, so the sidebar and topbar sync immediately. Later SSE title refinements replace the provisional via one guarded helper (only when the current title is still known-default/provisional). Manual/custom user titles are protected via exact-normalized-match detection, so user-renamed prefix titles are never treated as automatic placeholders. 167-line regression suite intests/test_early_session_title.pycovering default/eager/manual title behavior, chat-start response shape, JS wiring, and manual-prefix protection.
Fixed
-
MCP Tools in Settings → System now uses a bounded scroll region with 5-item default pages, a per-page selector up to 40 tools, and a visible result summary, so large MCP tool inventories no longer make the settings panel balloon indefinitely.
-
PR #2201 by @MrFant — Multi-turn conversations with thinking-mode providers (MiMo/Xiaomi, DeepSeek, Kimi/Moonshot) no longer 400 with
Param Incorrect: reasoning_content must be passed back. WebUI's_sanitize_messages_for_api()strips fields not in_API_SAFE_MSG_KEYSbefore sending conversation history to the LLM;reasoning_contentwas missing from the whitelist, so when history was replayed on the second turn, the assistant message withtool_callsarrived withoutreasoning_contentand providers enforcing thinking-mode echo-back rejected it. One-line fix: adds'reasoning_content'to_API_SAFE_MSG_KEYS. CLI was unaffected becauserun_agent.pyhas its own_copy_reasoning_content_for_api()that doesn't go through this filter. -
PR #2198 by @Michaelyklam — Fork-from-here keep-count was off-by-one (or larger) for truncated sessions where the visible-message index didn't match the absolute transcript index. JS now sends
_oldestIdx + msgIdx(the absolute message index in the full transcript) askeep_countinstead of the visible-window-relative index — captured before_ensureAllMessagesLoaded()resets_oldestIdx, so the index remains stable. Backendsource_messages[:keep_count]then forks from the correct point even when the user has only loaded a tail window. When the full transcript is loaded (_oldestIdx==0), behavior is unchanged. 186-line regression suite intests/test_issue2184_fork_from_here_absolute_index.pyexplicitly pinskeep_count: absoluteKeepCount(and forbids the oldkeep_count: msgIdxform). -
PR #2200 by @Jordan-SkyLF — Firefox/Waterfox session sidebar scrolling no longer jumps or stutters when background refreshes rebuild the list while the user is interacting. Adds an interaction guard for background refreshes from streaming/gateway polling and gateway SSE — defers only opt-in
renderSessionList({deferWhileInteracting:true})calls while the sidebar is hovered/focused/under pointer interaction; explicit user-triggered refreshes still run immediately. Avoids virtualized-list DOM rebuilds when the computed visible window is unchanged. Disables browser scroll anchoring on.session-listto stop Firefox/Waterfox rubber-banding against virtualized DOM replacement. 84-line regression suite for the deferral path, generation guard, virtualization render path, and scroll-boundary CSS.
[v0.51.55] — 2026-05-13 — Release AE (stage-348 — 9-PR contributor batch — docs/onboarding + compress fixes + steer badge + perf + thinking-card state + #2171 prefilter URL-marker patch)
Added
-
PR #2162 by @franksong2702 — Refresh project-snapshot docs and add an explicit agent-onboarding entrypoint. Updates
AGENTS.md(newly added),README.md,ARCHITECTURE.md,TESTING.md,.env.example,.gitignore,docker_init.bash,docs/onboarding.md, plus a newdocs/onboarding-agent-checklist.md. Refreshes stale current-state claims (test counts, default model semantics, state/log paths, file inventory line counts), adds an explicit agent-safe install/test/run path for AI assistants doing human-assisted reinstalls, and addstests/test_docs_gitignore_policy.pyto pin the gitignore policy. Docs/test-only — no runtime behavior change. -
PR #2187 by @jasonjcwu (split from #2164) — Steer messages now appear in the chat transcript as semi-transparent italic user bubbles with a "Steer" badge while the turn is in flight, giving users clear feedback that their injected message reached the agent context. Previously, when
busy_input_modewassteer, the injected message vanished entirely after a brief toast. The bubble is transient — thedoneevent replacesS.messageswith server state, so the badge disappears once the turn completes. CSS-only styling for the badge; no schema change.
Fixed
-
PR #2171 by @franksong2702 — Session tail-window response (
/api/session?messages=1&resolve_model=0&msg_limit=30) on long sessions is materially faster. Adds a cheap credential-marker prefilter before the full agent+fallback redaction pass — strings without known credential markers return immediately, while strings with likely markers still run the existing hard redaction. Skips the historicalsession.tool_callslist in the payload when returned messages already carry per-message tool metadata, avoiding sending the full historical list for every tail-window request. Same security and tool-card rendering behavior preserved. 173-line regression suite intests/test_session_tail_payload.py+ 81 LOC of new credential-prefilter tests intests/test_security_redaction.py. -
PR #2182 by @LumenYoung — Compression banner no longer drifts away from the actual compaction boundary in long WebUI conversations. Fixes two related cases: (1) windowed transcript rendering —
renderMessages()renders only a slicedrenderVisWithIdxwindow, and when the compression anchor index wasn't found in the rendered window, the previous code passed the full visible index directly into the rendered-window array (usually out-of-window for long sessions), so the compression card fell back toinner.appendChild(node)and appeared near the newest messages instead of near the boundary; (2) persisted compaction reference messages — the[CONTEXT COMPACTION — REFERENCE ONLY]marker is now used as a stronger placement signal than anchor metadata when both are present. 60-line regression suite covering both cases. -
PR #2185 by @jasonjcwu — Switching sessions no longer surfaces a
Compression failed: not foundtoast on the common case (no active compression). The/api/session/compress/statusroute handler was returningNonefromj()instead ofTrue, so in edge cases (stale process state, exception during response write) thedo_GET404 fallback fired. Backend:handle_getnow explicitly returnsTrueafter the status handler call. Frontend:resumeManualCompressionForSessioncatches 404 silently — no compression job means no error to surface. 139-line regression suite intests/test_compress_status_404_fix.py. -
PR #2186 by @jasonjcwu (split from #2164) — Concurrent
send()no longer drops user messages or swallows stream output. Two messages sent in rapid succession (queue drain + user click) could both pass theS.busycheck becausesetBusy(true)only runs after the firstawaitinsidesend(), leaving a window where two asyncsend()calls ran concurrently. Adds a synchronous_sendInProgressflag at the very top ofsend()(before anyawait). Concurrent calls re-queue the message instead of silently dropping.try/finallyensures the flag resets on all exit paths. -
PR #2188 by @LumenYoung — Context progress ring refreshes immediately when automatic context compression completes. Previously the
compressedSSE event only updated the compression card/toast; the context ring kept showing pre-compression token usage until a latermetering/doneupdate or the next message — making the UI look like compression had completed while the session was still near the limit. Backend now includes a live usage snapshot in thecompressedSSE payload; frontend reads it and updatesS.lastUsage+ the composer context indicator atomically with the compression-card transition. -
PR #2189 by @xz-dev — Live metering usage updates are now scoped to the session currently visible in the chat pane. Pre-fix, background streams could overwrite
S.lastUsageand the composer context indicator with metering data from a session the user wasn't looking at, making the indicator misleading on the active session. Four-line scope check inside the metering update path; no schema or SSE payload change. -
PR #2190 by @xz-dev — Thinking-card reasoning updates now update text in place during reasoning deltas instead of rebuilding the card DOM on every append. Preserves expand state and scroll position across reasoning streaming, so users reading a long reasoning block don't get bounced to the top on every chunk. When a thinking card exists, the update path now sets
pre.textContentdirectly; full-rebuild path only fires when no existing card is present.
Stage-348 maintainer fixes
api/helpers.py:_SENSITIVE_LOWER_MARKERS— add"://"URL marker — Opus SHOULD-FIX-pre-merge on PR #2171's credential prefilter. The prefilter listed only specific DB scheme prefixes (postgres://,mysql://,mongodb://,redis://,amqp://) and a closed set of form keys (token=,secret=,password=,authorization=,key=), so OAuth callback URLs (https://example.com/callback?code=AUTH_OPAQUE), URL userinfo (https://admin:supersecret@api.example.com/v1), and signed-URL query params (?signature=...,?session=...) bypassed the hard agent redactor entirely — defeating the "WebUI API responses are a hard safety boundary" comment athelpers.py:189. Adding the generic"://"marker routes every http(s)/ws(s)/ftp URL to the hard redactor (which then selectively redacts only the sensitive substrings — plainhttps://example.com/guide.htmlURLs still pass through unchanged). Regression-pinned with 5 new parametric cases intests/test_security_redaction.py(test_redact_text_prefilter_covers_url_userinfo_and_sensitive_query_params) covering OAuth code, URL userinfo, signed-URL signature, session query param, and WebSocket token — plus a negative-casetest_redact_text_prefilter_admits_plain_urls_without_sensitive_paramsconfirming the redactor doesn't over-redact plain URLs. Verified by reverting the fix locally: all 5 sensitive-URL cases fail; restoring the fix: all 5 pass. ~6 LOC code + ~50 LOC test.
[v0.51.54] — 2026-05-13 — Release AD (stage-347 — singleton self-built — NVIDIA NIM prefix preservation fix #2179)
Fixed
- PR #2179 (self-built, closes #2177) — NVIDIA NIM no longer 404s when WebUI is configured with
provider: nvidiaand anvidia/<model>id.resolve_model_provider()inapi/config.pyhad the_PORTAL_PROVIDERSguard (Nous, OpenCode-Zen, OpenCode-Go, NVIDIA NIM — providers whose APIs require the full namespacedprovider/modelwire format) sitting after theprefix == config_providerstrip branch. Formodel_id="nvidia/nemotron-3-super-120b-a12b"+config_provider="nvidia", the strip branch fired first and returned the barenemotron-...to NIM, which then 404'd because NIM requires the full path. Same bug class as #854 / #894 (Nous portal). The guard was originally added with NVIDIA in mind but the structural ordering was wrong. Fix is a pure reorder of twoifblocks — hoist_PORTAL_PROVIDERSahead of the strip — so all portal providers always preserve the fullprovider/modelid regardless of whether the prefix happens to equal the provider name. Also closes a latent symmetric bug for the Nous case if anous/<model>id ever entered the catalog. Cross-tool trace against hermes-agent'shermes_cli/models.py(line 59, 177, 237-239) andagent/model_metadata.py:68confirms the agent CLI sendsnvidia/nemotron-...verbatim — both tools now agree on the wire format. 118-line regression suite covering the reported case, cross-namespaceqwen/andmeta/ids, every static nvidia model in_PROVIDER_MODELS, the latentnous/<model>ordering pin, and a non-portal-provider regression pin for the anthropic strip behavior. nesquena APPROVED with 200-line end-to-end trace + 12-shape behavioural harness + cross-tool wire-format verification. Reported on Discord by @vishnu in #report-bugs.
[v0.51.53] — 2026-05-13 — Release AC (stage-346 — 10-PR contributor batch — stale-stream guard extension + guarded worktree remove + CSP report collector + perf + i18n + ctl fix + defense-in-depth)
Added
-
PR #2156 by @franksong2702 — Issue #2057 Slice 2: guarded worktree remove action. New
POST /api/session/worktree/removeandremove_worktree_for_session(session, *, force=False)helper. Rejects removal when the worktree is locked by an active stream or terminal, when it has local changes, untracked files, or unpushed commits ahead of origin. Clean removal runs without--force;--forceis only used when the backend is explicitly called withforce=True. Adds explicit per-session UI in the sidebar action menu (i18n strings for 9 locales), confirm dialog with two screenshot artifacts indocs/pr-media/2156/, and a 335-line regression suite intests/test_worktree_remove.pycovering the five fail-closed cases plus the explicitforce=Trueoverride. -
PR #2160 by @franksong2702 — CSP report collector endpoint (closes #2095). New unauthenticated
POST /api/csp-reportaccepts both legacyreport-uriJSON ({"csp-report": ...}) and modernapplication/reports+jsonarray payloads, with per-client in-memory rate limiting; over-limit reports are dropped with a warning while still returning 204 to avoid browser retry amplification. Existing CSP report-only header now advertises the collector viareport-uri /api/csp-report; report-to csp-endpoint, with a matchingReport-Toresponse header. 117-line regression suite covering headers, auth/CSRF carve-out, both payload shapes, and rate-limit behavior.
Fixed
-
PR #2158 by @franksong2702 (closes #2154) — Extends the stale-stream writeback guard from PR #2136 to two additional sites Opus advisor flagged on stage-345 review: the outer exception path (
api/streaming.py:3989) that materializespending_user_messageand appends an_error_message, and the self-heal retry success path (api/streaming.py:3947) that persists_heal_result. Both can run afteractive_stream_idhas rotated to a newer stream — same corruption pattern PR #2136 fixed on the normal success path. Each new site now mirrors the canonical guard:if not _stream_writeback_is_current(s, stream_id): logger.info("Skipping stale stream writeback at <site>"); return. Adds regression coverage that pins both guards before their respective persistence operations. -
PR #2159 by @franksong2702 (closes #2157) —
/api/sessionsno longer serializes staleactive_stream_id/pending_*fields after a stream dies or the server restarts. Adds a bounded route-layer post-pass that only considers rows withactive_stream_idset andis_streamingnot true, loads candidates withmetadata_only=True, and delegates cleanup to the existing safe_clear_stale_stream_state()helper (preserves the #1558 full-load safety path and per-session lock recheck). Re-readsall_sessions()after a cleanup so the JSON response matches the persisted session state. -
PR #2161 by @franksong2702 (closes #2098) — Localized 5 Logs severity-filter keys (
logs_severity_*) forja,ru,es,de,zh,zh-Hant,pt,ko. Removes the affected// TODO: translateplaceholders and adds the missing Traditional Chinese entries for these five keys. Regression coverage verifies each target locale has the expected localized values and that these key lines no longer carry TODO placeholders. -
PR #2173 by @franksong2702 (closes #2172) —
ctl.sh statusandctl.sh stopnow correctly recognize daemons started through a customHERMES_WEBUI_PYTHONwrapper. Persists the resolved Python executable inwebui.ctl.envasPYTHON_EXE, and_is_owned_webui_pid()now recognizes the recorded wrapper path while preserving the existing repo-root state guard. Stabilizes the existing ctl tests by waiting for the fake-wrapper log before reading it. Fixes the Python 3.13 CI failure exposed by PR #2171's session-tail tests. -
PR #2175 by @Michaelyklam (refs #2155) — Softened the session-lineage count badge from
X segmentstoX prior turn(s)in the English base locale. Existing lineage expand/collapse behavior and accessibility attributes unchanged. Focused regression test verifies the new English badge label and forbids the old "segments" wording. -
PR #2176 by @MrFant —
_apply_provider_prefix()no longer crashes withAttributeError: 'dict' object has no attribute 'startswith'when a provider'smodelsconfig contains dict entries ({"id": "x", "label": "y"}) instead of plain strings. Fix extractsidandlabelfrom dict entries while keeping string entries as-is. Resolves/api/modelsand/api/onboarding/status500 errors for users with dict-shaped model lists.
Performance
-
PR #2166 by @franksong2702 — Consolidated session post-render processing into a single
postProcessRenderedMessages(container)pass instead of two overlapping passes after both cached and freshly-rebuilt message DOM (plus a third highlight pass during idle session-loads). Scopes inline preview, tree-view, Mermaid, KaTeX, and code/copy-button passes to one walk over the rendered container. Vanilla JS architecture preserved; no changes to the markdown renderer, session loading, or DOM diffing model. -
PR #2170 by @franksong2702 —
/api/session?messages=0&resolve_model=0metadata loads no longer pay the_lookup_cli_session_metadata()Agent/CLI scan for native WebUI sessions. New_needs_cli_session_metadata()predicate keeps the Agent metadata merge path for imported CLI sessions, messaging-backed sessions, read-only sessions, and external-agent sessions, but skips it for ordinary WebUI-native sessions. Profiling on real production state showed this was the remaining hot path after PR #2166 removed the duplicate browser post-render work.
Stage-346 maintainer fixes
server.pyCSP-report auth carve-out scoped to POST only — Opus SHOULD-FIX from stage-346 review on PR #2160. The original carve-out (parsed.path != "/api/csp-report" and not check_auth) bypassed auth for all write methods on the endpoint, not just the POST that browsers actually use for CSP violation reports. PATCH/DELETE to that path currently fall through to a 403 (CSRF check) or 404 (routing), so the broad bypass was harmless — but defense-in-depth says scope the carve-out to its actual use. New check:parsed.path == "/api/csp-report" and self.command == "POST". ~6 LOC. CSP report regression suite (6 tests) still passes.
[v0.51.52] — 2026-05-12 — Release AB (stage-345 — 2-PR low-risk batch — stream-ownership guard + Refresh-usage button on provider quota card)
Fixed
- PR #2136 by @LumenYoung — Stale stream writebacks no longer poison the active session transcript.
cancel_stream()intentionally clearsactive_stream_idearly so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later fromrun_conversation()and persist its stale result over the newer transcript, causing visible transcript / turn journal /state.dbto disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check_stream_writeback_is_current(session, stream_id)(token equality againstsession.active_stream_id) and short-circuits both finalize paths: the success path in_run_agent_streamingand the cancel-handler path incancel_stream(). When the stream no longer owns the writeback, both paths logSkipping stale stream/cancel writebackand return cleanly without persisting. 89-line regression suite intests/test_stale_stream_writeback.py; companion updates totests/test_issue1361_cancel_data_loss.pyandtests/test_sprint42.pyfor the new return-without-persist behavior.
Added
- PR #2150 by @Jordan-SkyLF — "Refresh usage" button on the Provider quota card in Settings → Providers. Calls
/api/provider/quota?refresh=1&ts=<now>withcache: 'no-store'to bypass browser, service worker, and reverse-proxy caches that may have stamped a previous quota response, then re-renders just the quota card from the fresh response and shows aLast checked ...timestamp. DisabledRefreshing…state during the in-flight request; success toast on completion or failure toast if the refresh fails. Note: therefresh=1query param is a no-op at the server today (get_provider_quota()has no in-process cache layer), so the win is strictly browser-side cache-bust + theno-storefetch option. A future maintainer follow-up may add server-side TTL caching of OAuth account-limit fetches, at which point therefresh=1param becomes load-bearing on both sides.
[v0.51.51] — 2026-05-12 — Release AA (stage-344 — 16-PR contributor batch — i18n + insights bucketing/mobile + manual-compress async + workspace recovery + iOS PWA scroll + Cloudflare login health + fr locale)
Added
-
PR #2130 by @dso2ng — Lazy lineage-report fetch on sidebar segment-badge expand. The sidebar already showed
N segmentsfor collapsed compression lineage rows (refs #1906, #1943) and the backend report endpoint is now shipped (refs #2012), but some rows only had a backend_compression_segment_countfrom/api/sessionswhile the browser hadn't materialized the older segment rows — clicking the badge couldn't reveal the full bounded list. Adds a small per-sidebar-cache lineage-report cache/inflight map instatic/sessions.js, invalidates it on each fresh/api/sessionsrefresh, and on expand fetchesGET /api/session/lineage/report?session_id=<tip>only when_sessionSegmentCount(s)exceeds the locally-materialized_lineage_segmentscount. Merges returned reportsegmentsbysession_idwith existing client segments, skipping the visible tip andchild_sessionrows. Leaves reportchildrenout of the compression-segment list so subagent/fork child semantics remain separate. 132-line regression suite covering fetch-needed detection, report-segment merging/dedup, endpoint construction, and inflight cache de-duping. -
PR #2142 by @legeantbleu — French (
fr) locale. ~938 UI strings translated via Google Translate then sanitized for JS string escaping. Inserted at the end ofstatic/i18n.js'sLOCALESmap (insertion-order convention used by every locale sinceitlanded). Stage-344 maintainer fix added the matching tuple entries intests/test_issue1488_composer_voice_buttons.py:TestComposerVoiceButtonI18n.LOCALES+ siblingTestVoiceModePreferenceGate.LOCALES, plus the matching_LOGIN_LOCALE['fr']block inapi/routes.pyso the login page localizes for French users (issue #1442 parity contract), plus an inverted_resolve_login_locale_key('fr')assertion intests/test_login_locale_parity.pythat previously assumed fr falls back to en. Mirrors the stage-340 fix for theitlocale (PR #2067).
Fixed
-
PR #2120 by @Michaelyklam (closes #2103) — Daily Tokens chart no longer overflows its card on 90/365 day ranges. Adds
_bucketDailyTokensForChart()instatic/panels.jsthat keeps ≤30 rows per-day and buckets longer ranges into summed chart rows (90→45 bars at 2-day buckets, 365→46 bars at 8-day buckets, ≤52 ceiling). Updates the Daily Tokens render loop to use bucketed chart rows, date-range labels, and summed tooltip values. Switched chart columns to shrink-safeminmax(0,1fr)so the bars stay inside the card. Backend/api/insightspayload unchanged. 130-line regression suite covering short-range preservation, long-range bounding, label/title shape on bucketed rows, render-loop usage, and shrink-safe CSS. -
PR #2121 by @Michaelyklam (refs #2104) — Token Breakdown + Models row stacks on mobile instead of forcing horizontal page overflow. New
insights-usage-gridclass wraps the row with a scoped@media (max-width: 640px)rule that flips it togrid-template-columns: 1fr. Contains remaining model-table overflow inside the card. 27-line regression suite covering the mobile breakpoint, single-column layout, containedoverflow-x, and presence of the scoped rule. -
PR #2123 by @Michaelyklam (closes #2112) — Portuguese (
pt) locale parity: 5 missing session-management keys (bulk delete/archive, select mode, select all, selected count, no-selection text) added so Portuguese users stop silently falling back to English. Extendedtests/test_login_locale_parity.pywith a session-management key parity check across all locale blocks. -
PR #2125 by @Michaelyklam (closes #2093) — Renamed
_patch_skill_home_modules→patch_skill_home_modulesinapi/profiles.pysince the helper is imported by streaming code and asserted by tests across modules. Updated streaming import/fallback/call sites inapi/streaming.pyand the env-lock regression test expectations. Expandedapi/compression_anchor.py's module docstring to explain manual vs automatic compression anchoring andauto_compression=Truebehavior. Documentation/rename-only — no runtime behavior change. -
PR #2128 by @franksong2702 (closes #2087) — Manual
/compressno longer fails behind reverse proxies that time out long synchronous requests. AddsPOST /api/session/compress/start(start or reuse an in-process manual compression job keyed bysession_id) +GET /api/session/compress/status?session_id=...(pollrunning/done/error/idle). Reuses the existing_handle_session_compressimplementation inside the worker so the save path, provider resolution, sanitization, and the legacy synchronous endpoint stay aligned. Adds a stream-state guard before save so a compression worker can't overwrite a session that started another stream while compression was running. 10-minute cleanup for terminal job results, with successfuldonepayloads released after first status consumption.static/commands.js/compressand/compactnow start, poll, and apply the saved compressed session; session-load resume wiring picks up in-flight compression on page reload. -
PR #2129 by @Michaelyklam (closes #2092) —
_purgeStaleInflightEntries()now iteratesINFLIGHTkeys and explicitly drops ids absent from the current session list. Pre-fix the cleanup only removed entries for sessions still present in_allSessionsand marked non-streaming, so deleted/archived/filtered-out sessions left ghost entries indefinitely. Preserves still-streaming sessions. 124-line regression suite covering absent/present-non-streaming/present-streaming cases. -
PR #2135 by @franksong2702 (closes #2126, refs #2131) —
/api/models/live?provider=custom:<slug>now only returns models from the requested named provider entry instead of everycustom_providers[].model. Direct/v1/modelsfallback uses the matched named provider'sbase_url+api_keypair instead of the active profile'smodel.base_url/model.api_key.custom:<slug>reads only the matching named entry; barecustomreads only unnamed entries. Includes model IDs from both singularmodeland pluralmodelsconfig forms. Cache key behavior preserved (already provider-scoped). Regression coverage for named-provider scoping, bare-custom scoping, and direct fetch endpoint/key selection. -
PR #2137 by @franksong2702 (closes #2122) — Login page health probe now sends
credentials: 'same-origin'instead ofcredentials: 'omit'. Cloudflare Access and similar same-origin reverse proxies need the access cookie to reach the proxy, so the prior omit caused WebUI to falsely disable login before/healthever resolved. Keeps the health URL mount-relative (health) for subpath deployments. Static regression test pins same-origin credentials and forbids the omit variant. -
PR #2138 by @dobby-d-elf — Live Hermes WebUI chats no longer get stuck with
Error: Path does not exist: ...when the session points at a deleted workspace. Workspace fallback now looks up the liveDEFAULT_WORKSPACEinstead of using a stale import-time snapshot. Old sessions with deleted implicit workspaces are repaired to the current valid workspace during chat start, so the next send recovers instead of erroring. 71-line regression suite for both the stale-fallback and missing-session-workspace recovery paths. -
PR #2139 by @Michaelyklam (refs #2097) — Turn-journal terminal-collision audit slice.
derive_turn_journal_states()now returns(states, terminal_collisions); collisions carry theturn_idplus terminal events in timestamp order when a turn records more than one terminal event (completed + interrupted both fire). Latest-by-timestamp derived state behavior preserved for existing callers; session recovery audit and existing tests updated to unpack the new tuple. Audit-only: no multi-process append safety in this PR. -
PR #2140 by @franksong2702 (closes #2133) — WebUI fallback activation now passes
api_keyandkey_envin the normalized fallback entry toAIAgent, matching what the CLI path preserves. Hermes Agent fallback resolution already knew how to use these — WebUI was dropping them, leaving env-backed fallback providers unauthenticated after a primary provider 401. Legacy single-dictfallback_modeland list-formfallback_providersselection behavior unchanged. -
PR #2141 by @franksong2702 (closes #2102) — Settings → System header no longer clips off the right edge on phones. Section header now stacks vertically under the existing Settings mobile breakpoint; the System update/version control group wraps to use available width; individual version badges keep their text intact while the group wraps. CSS-only change inside the existing breakpoint scope. Mobile layout static regression added.
-
PR #2143 by @dobby-d-elf — iPhone PWA chat bottom-scroll stutter fixed. Removed the Start/End scroll controls from the transcript scroll layout — they were sticky children inside
#messages, which on iOS momentum/elastic scrolling perturbed the scroll surface at the bottom boundary. Now the transcript is wrapped in a.messages-shelland the controls render as absolute overlays outside#messages, so#messagesis back to a plain native scrolling container. Adds a small visibility dead zone for the down-arrow button so elastic bottom pulls don't flash the button while already at the bottom. -
PR #2132 by @Michaelyklam (refs #2096) — Docs-only: added
Synchronous durability design rationaletodocs/rfcs/turn-journal.md. Documents why submitted-event journaling stays synchronously fsync-backed today, qualitative fsync latency expectations for SSD/HDD/Docker-overlay filesystems, and maintainer benchmark guidance for measuring p50/p95/p99 append/fsync latency before any future async lifecycle journaling.
Stage-344 maintainer fixes
api/routes.py:_handle_session_compress_start/status(#2128 polish) — Opus SHOULD-FIX from stage-344 review. Two related UX bugs in the new async manual-compression flow: (1)compress/statuspopped thedonejob entry on first read, which left a second open tab with{status:"idle"}and a "Compression job is no longer available" toast — fixed by letting the existing 10-minute TTL handle eviction so all tabs see the same terminal payload; (2) re-invokingcompress/startwithin the 10-minute TTL returned the stale priordonepayload instead of running a new compression — fixed by always dropping the existing entry and starting a fresh worker, so a user closing a tab mid-compress and re-running/compresson a fresh open gets a new result. Both are 1-block tweaks; existingtests/test_sprint46.py10/10 still passes. The third Opus SHOULD-FIX (#2135cfg["model"]fallback whenprovider=custom:Xdoesn't match any entry) is deferred to a follow-up — it's strictly no-worse-than-master behavior, but worth tightening to skip the URL probe when no entry matched.
[v0.51.50] — 2026-05-12 — Release Z (stage-343 — single-PR — ctl.sh bash 3.2 macOS compat fix + regression test suite)
Fixed
- PR #2117 by @ayushere —
ctl.sh startno longer crashes on macOS (bash 3.2) withpreserved[@]: unbound variable. The dotenv-preserve loop in_load_repo_dotenv_preserving_env()iterated"${preserved[@]}"underset -euo pipefail, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default/usr/bin/bashon macOS) treats as an unbound-variable error. Guards the iteration withif [[ ${#preserved[@]} -gt 0 ]]; then ... fi— matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land inctl.sh(prior:025f137fguardedCTL_BOOTSTRAP_ARGS[@]with the${arr[@]+...}pass-through pattern,630981a0replaced[[ -v ${key} ]]with[[ -n "${!key+x}" ]]). Defense-in-depth: addedtests/test_ctl_bash32_compat.py(5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (declare -A,mapfile,[[ -v ]],${var^^},${var,,}) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh.
[v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback)
Added
- PR #2109 by @franksong2702 — Read-only worktree status endpoint for the #2057 lifecycle tracker.
GET /api/session/worktree/status?session_id=...returns the session-owned worktree path, filesystem existence, dirty/untracked state, ahead/behind counts when an upstream is configured, and live stream/embedded-terminal lock flags. Usesgit worktree list --porcelain,git status --porcelain --untracked-files=normal, andrev-list --left-right --count HEAD...@{u}only — no mutating git state, 2-second per-call timeouts (tightened from PR-submitted 5s during stage review). Session-id scoped (rejects non-worktree sessions with 400), does not accept arbitrary filesystem paths. This is the non-destructive status surface Nathan requested as the next slice before any future explicit remove-worktree action. 221-line regression suite covering clean/dirty/untracked/missing-path/live-stream-lock/embedded-terminal-lock/endpoint-success/non-worktree-rejection cases.
Fixed
-
PR #2113 by @franksong2702 (closes #2111) — Session archive/delete success toasts now prefer the backend
worktree_retainedresponse over the cached session-sidebar snapshot. Pre-fix a stale sidebar snapshot (other browser tab archived the session, server-side mutation moved the worktree, etc.) could make the success toast say "worktree preserved on disk" even when the backend response said no worktree was retained. Frontend now treatsresponse.worktree_retained: true/falseas source of truth and falls back tosession.worktree_pathonly when the backend doesn't return the flag (older-server compatibility). Both single-session and batch (Promise.all) archive/delete paths updated; batch retained-count derived from per-response flags instead of the pre-POST cached_worktreeSessionCount. The pre-flight confirm dialog still uses the cached snapshot (it renders before the POST exists), but the post-POST toast now reflects backend truth. -
PR #2116 by @starship-s — OpenAI Codex provider quota card no longer reports "unavailable" when Codex chat requests actually work. Runtime requests authenticate via the modern
agent.credential_pool, but the account-usage probe only tried the legacy singleton Codex token path. Adds a Codex-only credential-pool fallback inside the existing isolated_account_usage_subprocess: whenagent.account_usage.fetch_account_usage()returns no available snapshot, the fallback selects the activeopenai-codexcredential-pool entry, derives the Codex usage endpoint from the runtime base URL (handles/backend-api/codex→/wham/usageand custom bases →/api/codex/usage), and serializes the existing snapshot shape expected by the WebUI. Stays inside the child process so active Hermes profile context remains isolated; legacy unavailable diagnostics preserved when the pool fallback can't produce a usable result; non-Codex providers unchanged. Returns only quota display data — never credential labels, access tokens, or raw exception strings. 151-line regression suite covers the success path, both URL-resolution branches, and the unavailable-fallthrough case.
Stage-342 maintainer fixes
api/worktrees.py:_run_gitdefault timeout 5s → 2s — Opus SHOULD-FIX from stage-342 review: PR #2109's new/api/session/worktree/statusendpoint runs up to fourgitsubprocess calls per request, each defaulting to a 5-second timeout. Worst case 20 seconds per polling request piling up on theThreadingHTTPServerthread pool is risky given today's_cron_env_locknear-miss on production 8787. Status probes should fail fast — a worktree that takes longer than 2 seconds to enumerate is already in trouble, and the client can retry. Mechanical 1-LOC default-arg change; all four call sites already passcwdpositionally and rely on the default. ~1 LOC.
[v0.51.48] — 2026-05-12 — Release X (stage-341 — 3 contributor PRs — Hermes run adapter RFC + title-retry loop fix on reasoning-only models + worktree archive/delete confirm copy)
Added
- PR #2105 by @Michaelyklam — Hermes run adapter contract RFC at
docs/rfcs/hermes-run-adapter-contract.md(refs #1925). 315-line spec/gap matrix that defines the event/control compatibility contract WebUI needs before browser-originated chat turns can be routed to Hermes-owned runtime execution. Documents the ownership boundary (Hermes Agent owns run creation, lifecycle, event ordering, replay, terminal state, approvals, clarify, cancellation; WebUI owns browser auth, transcript rendering, tool cards, approval/clarify widgets, workspace UX), the minimumstart_run/observe_run/get_run/cancel_run/queue_or_continue/respond_approval/respond_clarifyIPC surface, and a gap matrix mapping currentSTREAMS/CANCEL_FLAGS/AGENT_INSTANCES/callback queues to Hermes-owned targets with explicit "no private callback queue" / "no runtime surrogate" non-goals. First success criterion is restart/reattach (start a non-trivial run, restart hermes-webui, browser reconnects, replays from last cursor, cancels with Hermes-emitted terminal state) — not "basic chat streamed once." Status: Proposed.
Fixed
-
PR #2107 (self-built, closes #2083) — Title-generation budget-doubling retry loop on reasoning-only model responses. Reporter @darkopetrovic on LM Studio with Qwen3.6-35B-A3B (and the broader class: DeepSeek-R1, Kimi-K2, other Qwen3-thinking variants) saw GPU never going idle after each prompt — the chat turn finished cleanly but the auto-title generation request burned its 500-token budget on hidden
reasoning_content, emittedcontent=""withfinish_reason=length, got classified asllm_length, retried at 1024 tokens, returned the same shape, then iterated through_title_prompts()'s two prompts for ~3000 reasoning tokens per new chat. The agent-sideis_lmstudioclassifier inrun_agent.py:9468missescustom:providers pointing at LM Studio, so thereasoning_effort: "none"adapter never fires for that route. WebUI-side belt-and-braces fix: (1)_extract_title_response()reorders the empty-response classification to checkreasoning_contentfirst regardless offinish_reason— reasoning presence is the diagnostic signal, not finish_reason; (2)_title_retry_status()dropsllm_empty_reasoning{,_aux}from the retry set (length-without-reasoning still retries — legitimate budget-truncation case); (3) new_title_should_skip_remaining_attempts()short-circuits the prompt-iteration loop, both aux and agent routes break to_fallback_title_from_exchangefor a local-summary title. Net: 4 calls → 1 call per chat.tests/test_title_aux_routing.pyinverts the old reasoning-retry assertions and adds two new tests for the legitimate length-without-reasoning retry path. nesquena APPROVED with 200-line end-to-end trace + behavioral harness confirming the 4→1 call reduction. -
PR #2064 by @franksong2702 — Worktree session archive/delete confirm copy now reassures users that the underlying worktree directory remains on disk (refs #2057). Pre-fix the confirm dialogs said only "Delete this conversation?" / "Archive this conversation?" without clarifying that worktree-backed conversations preserve the worktree files even when the conversation row is removed — users were reasonably afraid of losing local work. Adds an explicit
worktree_retainedboolean on the/api/sessionpayload that the frontend reads to surface "The worktree at /path will remain on disk." (single) and "N worktree-backed conversation(s) will keep their worktree directories on disk." (bulk) variants in both archive and delete dialogs. 81-line i18n update across all 9 locales (en/it/ja/ru/es/de/zh/pt/ko) with an English-bundle locale-leak fix caught during screenshot capture (several worktree strings had landed under Russian in error). Regression coverage intests/test_issue2057_worktree_lifecycle.py+tests/test_issue2057_worktree_ui_static.py. UX-gate cleared with 5 viewports (4×1280px desktop covering single + bulk archive/delete confirms, 1×390px mobile of single-delete confirming dialog fits without overflow).
Stage-341 maintainer fixes
-
docs/rfcs/README.md— Added a single bullet to the conventions block clarifying that RFCs are design directions, not invitations to file implementation PRs against fragments. Implementation slices need maintainer confirmation in the tracking issue first. Applied alongside PR #2105 to head off the speculative-fragment pattern we just had to put on hold with PR #2071 (well-written 651-LOC collector with no callers). ~6 LOC. -
static/i18n.js:itblock — Opus SHOULD-FIX from stage-341 review: PR #2064 was branched before stage-340 landed theitlocale (#2067), so the 9 newsession_*worktree*keys were missing for Italian users. Mechanical add inside theit:block at the parallel position to en/ja. Falls back to English silently without this fix; with this fix, Italian users see the worktree-retention reassurance copy in their locale. Parallels the stage-340cron_toast_notifications_*fix exactly. ~9 LOC. -
api/streaming.pyshort-circuit observability — Opus SHOULD-FIX from stage-341 review: PR #2107's new_title_should_skip_remaining_attemptsshort-circuitbreakwas silent in both the aux and agent title-generation paths. Added alogger.debugcall before eachbreakso production logs surface why the prompt-iteration loop exited early (nesquena flagged this as non-blocking; landed as polish in the same release). Also expanded the function's docstring to document the membership criterion explicitly so future additions (llm_safety_blocked,llm_oauth_quota, etc.) have a clear inclusion test. ~16 LOC.
[v0.51.47] — 2026-05-11 — Release W (4-PR contributor batch — per-cron toast toggle + Italian locale + stale-gateway agent-health fix + CI/console hygiene)
Added
-
PR #2100 by @ai-ag2026 — Per-cron toast notification toggle. New
toast_notificationsboolean on cron job payloads (default-true for legacy preservation) wired through_renderCronForm,_renderCronDetail,openCronCreate,openCronEdit,duplicateCurrentCron, andsaveCronForm. The polling loop instartCronPolling()gatesshowToast(...)onc.toast_notifications !== falseso muted jobs still update the Tasks badge and new-run marker but skip the toast. Full i18n parity (9 locales: en/it/ja/ru/es/de/zh/pt/ko after PR #2067 landed) and 158-line regression suite intests/test_cron_toast_notifications.py. -
PR #2067 by @samuelgudi — Italian (
it) locale. ~280 UI strings translated covering boot, messages, MCP, commands, goals, settings, sessions, kanban, panels, and the offline state. Inserted alphabetically (en → it → ja) instatic/i18n.js'sLOCALESmap and mirrored in theLOGIN_LOCALESserver-rendered table inapi/routes.py. UpdatedTestComposerVoiceButtonI18n.LOCALESto include"it"; siblingTestVoiceModePreferenceGatealso gets the tuple so its newly-adaptivelen(self.LOCALES)count assert resolves.
Fixed
-
PR #2075 by @LumenYoung — Stale
gateway_state == "running"runtime status is now reported asalive: null(unknown) instead ofalive: false(refs #1879). In multi-container WebUI+gateway deployments the older gateway builds only refreshgateway_state.jsonon lifecycle changes, not every tick — so a stalerunningfile means "WebUI cannot see the gateway" rather than "gateway is down". New_runtime_status_is_stale_running()helper sits in front of the existing_runtime_status_is_stale_stopped()branch inbuild_agent_health_payload()so the heartbeat banner no longer flips to a confirmed-outage state when the gateway is actually fine but PID-checking across containers is impossible. 52 LOC including the inversion of the matching assertion intest_issue1879_cross_container_gateway_liveness.py. -
PR #2070 by @ai-ag2026 — CI and console-noise hygiene. (1) Quoted
"pyyaml>=6.0"in.github/workflows/tests.ymlinstall step so the shell stops parsing the unquoted>as stdout redirection. (2) Registered theintegrationpytest marker in a newpytest.inito suppress collection-time warnings on tests that hit the live test server. (3) Lowered the live-model success diagnostic in_fetchLiveModels()fromconsole.logtoconsole.debugso model-fetch chatter no longer floods the default browser console. Newtests/test_ci_hygiene.py(29 LOC) pins all three regressions.
Stage-340 maintainer fixes
-
tests/test_issue1488_composer_voice_buttons.py:TestVoiceModePreferenceGate— DefinedLOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")on the class. PR #2067 madetest_settings_pane_has_voice_mode_i18n_keyscount adaptive vialen(self.LOCALES)but only definedLOCALESon the siblingTestComposerVoiceButtonI18n, so CI failed withAttributeError. Mirroring the tuple is the surgical fix; the alternative (back to a hard-coded9) would have rotted next time someone adds a locale. ~2 LOC. -
static/i18n.js:itblock — Opus SHOULD-FIX from stage-340 review: added the fourcron_toast_notifications_*keys (label, hint, enabled, disabled) inside theit:block. PR #2067 inserted theitlocale betweenenandja; PR #2100 added those keys to the other 8 locales but missedit. ~4 LOC, mechanical add immediately aftercron_profile_server_default_hintto mirror the en/ja position.
[v0.51.46] — 2026-05-11 — Release V (5-PR contributor batch — CSP report-only + logs panel polish + plugin slash commands + turn-journal crash-safe writer + lifecycle events)
Added
-
PR #2059 by @ai-ag2026 — Append-only WebUI turn journal helper at
api/turn_journal.py(new file, ~128 LOC). Writes one JSONL file per session under_turn_journal/and fsyncssubmitted-turn events before the worker thread starts via/api/chat/start(after pending session state is saved and beforethreading.Thread(...)starts).recovery_auditextended to report non-terminal journal turns asturn_journal_pending_turnwhen the submitted user message is not present in the sidecar. Intentionally the minimal slice fromdocs/rfcs/turn-journal.md(RFC #2042): writer + reader + state derivation + audit-only reporting. No replay or repair yet. -
PR #2062 by @ai-ag2026 — Turn-journal lifecycle events on top of #2059's submitted-event writer. Records
worker_startedwhen the streaming worker begins,assistant_startedbefore the final session save once an assistant message exists,completedafter the final save, andinterruptedon the provider-error path.append_turn_journal_event_for_stream(...)reuses theturn_idassociated with the stream's submitted event. Still audit-only / journaling-only — does not replay turns or repair assistant output. The little WAL goblin remains on a leash. -
PR #2089 by @plerohellec — Plugin-defined slash commands now surface in the WebUI command picker and execute via a new
/api/commands/execroute (closes #1935).list_commands()inapi/commands.pymergeshermes_cli.plugins.get_plugin_commands()into the/api/commandspayload withcategory: "Plugin"; the frontend intercepts plugin commands instatic/messages.jsandstatic/commands.jsto route through the plugin execution endpoint instead of falling through to the agent. Pre-fix the WebUI only learned slash commands fromhermes_cli.commands.COMMAND_REGISTRY(commands.py:23), so plugin-registered commands were invisible to the picker, autocomplete, and routing — they fell through to the agent as raw text and the agent's response was about an unknown command. This is the WebUI half of the parity fix; the corresponding agent-side plumbing already existed inhermes_cli/plugins.py:1424(get_plugin_commands()).
Fixed
-
PR #2085 by @bergeouss — Logs panel: clipboard
_copyText()fallback + severity filter (closes #2081). Pre-fixcopyLogsAll()callednavigator.clipboard.writeText()directly with no fallback — failed silently on large payloads / non-secure contexts / unfocused pages, leaving users with a useless error toast. Now routes through_copyText()fromui.jswhich already has a<textarea>+document.execCommand('copy')fallback. Also adds a Severity dropdown (All / Errors / Warnings+) that filters the in-memory log cache without re-fetching —errors.logis ~90% WARNING tool noise so filtering down to ERROR/CRITICAL is a real triage time-saver.copyLogsAll()copies the FILTERED subset when a filter is active. 5 new i18n keys in all 9 locales. -
PR #2084 by @ai-ag2026 —
Content-Security-Policy-Report-Onlyheader (refs #1909). All WebUI responses now ship a CSP slice in report-only mode — non-enforcing, so the browser collects violations without blocking page behavior. Current UI allowances ('unsafe-inline'for scripts and styles, plushttps://cdn.jsdelivr.netfor the Prism/xterm/katex CDN assets thatstatic/index.htmlloads with SRI hashes) are explicit so future tightening passes can replace them one constraint at a time.object-src 'none',base-uri 'self', andframe-ancestors 'self'are already enforced because they don't break the current UI. Server-side change only (server.pyheaders), zero client-side risk.
Stage-339 maintainer review (Opus advisor)
-
server.py:_CSP_REPORT_ONLY— Dropped'unsafe-eval'after Opus verified by grepping all production JS that nothing useseval(),new Function(), or string-formsetTimeout/setInterval. Keeping the allowance would have been a gratuitous privilege that defeats the purpose of the dry-run. ~1 LOC. -
server.py:_CSP_REPORT_ONLY— Addedhttps://cdn.jsdelivr.nettoscript-srcandstyle-src.static/index.htmlloads Prism, xterm.js, and katex CSS from jsdelivr with SRI integrity hashes. Without the allowance, every page load would fire known-good CSP violations and drown out the real dry-run signal. ~2 LOC. -
api/commands.py:execute_plugin_command— Sanitized the plugin error message. Previously returnedf"Plugin command error: {exc}"which would leak paths / env / internal state from aFileNotFoundError('/etc/something/secret.key')-shape exception verbatim to the user-facing chat. Now returns onlytype(exc).__name__; the full traceback is logged at WARNING vialogger.warning(..., exc_info=exc). ~4 LOC.
[v0.51.45] — 2026-05-11 — Release U (9-PR contributor batch — themes docs + gitignore policy + kanban parity + skill cache patching + fork lineage + sidebar spinner + custom provider slug + session recovery polish + compression anchor refactor)
Added
-
PR #2074 by @franksong2702 —
_patch_skill_home_modules(home)centralizes patching of bothtools.skills_toolandtools.skill_manager_toolmodule-level skill paths so process-wide HERMES_HOME switches and per-request streaming switches stay aligned. Closes #2023. Closes the cleanup gap from the original #2023 fix where the streaming per-request path patched both modules but the process-wide switch path only patchedtools.skills_tool. Preserves the no-import-under-_ENV_LOCKinvariant from #2024. -
PR #2077 by @franksong2702 — Compression anchor visibility helpers collapsed into a single shared module
api/compression_anchor.py(new file, 77 lines) so the manual/api/session/compresspath inapi/routes.pyand the streaming auto-compression path inapi/streaming.pyshare one canonical implementation. Net effect: 48 lines removed fromroutes.py, 41 fromstreaming.py, plus 59-line regression suite. Closes #2028.
Fixed
-
PR #2068 by @franksong2702 — Stuck sidebar spinners on completed sessions (closes #2066).
_isSessionLocallyStreaming()no longer consultsINFLIGHTfor non-active sessions — INFLIGHT entries for non-active sessions are always artifacts and never affect spinner state. Added_purgeStaleInflightEntries()cleanup pass and 71-line regression filetests/test_issue2066_stale_sidebar_spinner.pycovering the abnormal-termination cases (page refresh / network drop / gateway restart) that the symptomatic 5-minute-staleness alternative would have left broken. -
PR #2056 by @franksong2702 — Custom provider name slugs no longer preserve slug-hostile punctuation (closes #2047). Friendly setup names like
Local (127.0.0.1:15721)now becomecustom:local-127.0.0.1-15721instead ofcustom:local-(127.0.0.1:15721). The latter shape collided with the@provider:modelgrammar and could corrupt the model into15721):deepseek-v4-flash. Endpoint-derivedcustom:<host>:<port>slugs continue to flow through the host-port parser unchanged._custom_provider_slug_from_name()is now reused by both model resolution and available-model lookup instead of duplicatinglower().replace(" ", "-"). -
PR #2065 by @franksong2702 — Four low-severity polish items from the v0.51.42 Opus pre-release review (closes #2050). (1)
state.dbrows withsource='webui'but zero readable messages now emitstate_db_orphan_webui_row/unsafe_to_repair/manual_reviewinstead of being silently dropped. (2)repair_safe_session_recovery()returns an explicitcleanflag preservingokfor compatibility;/api/session/recovery/repair-safe200/409 dispatch keys offclean, so a 409 now means "audit still has findings" rather than "repair code failed." (3)MEDIA_ALLOWED_ROOTSsplits onos.pathsep(POSIX:/ Windows;) instead of a hard-coded colon. (4) Replaced the confusingdetails[-1:]one-element slice with an explicit local detail-recorded flag. -
PR #2063 by @dso2ng — Explicit
session_source="fork"sessions are kept out ofread_session_lineage_report()continuation chains. The query now fetches optionalsession_sourceso the existing continuation helper can see fork metadata; pre-fix the backend read-only lineage report bridge added in #2012 contradicted the sidebar collapse logic taught in #2014 (where forks are explicit branches, not compression continuations). Regression covers a fork child whose parent ended via compression.
Refactored
- PR #2077 (cross-listed) — see Added.
Documentation
-
PR #2088 by @michael-dg —
THEMES.mdre-aligned with the post-#627Theme × Skinarchitecture. The old monolithic palette names (Dark,Light,Slate,Solarized Dark,Monokai,Nord,OLED) no longer match the actual two-picker model (ThemeSystem/Dark/Lightapplied as.darkclass on<html>, plus Skin — 8 named accent palettes — applied asdata-skin="<name>"). The Settings → Appearance panel exposes both pickers plus Font Size, and/theme <name>accepts theme + skin tokens. -
PR #2073 by @ai-ag2026 — Top-level Markdown docs (
docs/*.md) are now tracked instead of silently ignored by the broaddocs/*rule. Arbitrary scratch/reference files underdocs/(non-.md) remain ignored by default. Regression tests cover the intendedgit check-ignorebehavior on both paths.
Tests
- PR #2076 by @franksong2702 —
test_kanban_locale_parity(added totests/test_kanban_ui_static.py) catches missing-key regressions across ~86kanban_*i18n keys × 9 locales (en, ja, ru, es, de, zh, zh-Hant, pt, ko). Follows the existingtest_lineage_segment_locale_keys_are_defined_for_sidebar_localespattern. Issue #1973 flagged that this regression class was previously caught only by manual review during the Opus pre-ship audit.
Stage-338 maintainer review (Opus advisor)
api/providers.py:1049— Custom provider entries that slugify to an empty string were silently dropped, which made misconfigurations hard to diagnose.logger.warning()now surfaces the bad config entry. ~4 LOC; pure observability change.
[v0.51.44] — 2026-05-11 — Release T (5-PR contributor batch — security + worktree sessions + LM Studio + onboarding docs + transcript dedup, plus comprehensive test-suite network isolation)
Added
-
PR #2052 by @franksong2702 —
docs/onboarding.md(181 lines) covering install path choices, safe wizard re-runs with isolatedHERMES_HOME/HERMES_WEBUI_STATE_DIR, provider groups, Docker/local-server Base URL rules (the most common Discord support question —localhostinside a container is not the host running LM Studio or Ollama), workspace setup, password step, files written by the wizard, and issue-reporting diagnostics. README pointer added from the quick-start section and Docs list. Stale~/.hermes/webui-mvp→~/.hermes/webuicorrection in.env.exampleand the README env-var table (the running app uses~/.hermes/webuiperapi/config.py:42). -
PR #2053 by @franksong2702 — Worktree-backed session creation.
POST /api/session/newaccepts aworktree: trueflag that calls the agent's_setup_worktree()helper to create an isolated git worktree at<repo>/.worktrees/hermes-XXXX, persistsworktree_path/worktree_branch/worktree_repo_root/worktree_created_aton the WebUISession, surfaces a "New conversation in worktree" action in the workspace menu, and shows a subtle sidebar worktree indicator. Empty worktree sessions stay visible in the sidebar (the empty-session filter atapi/models.py:1067/1107exempts sessions with aworktree_path). Note: the underlying Hermes Agent helper may add.worktrees/to the repository.gitignorethe first time a worktree is created for that repo — operators will see a small uncommitted edit to.gitignoreafter their first worktree session. Cleanup lifecycle (auto-remove on session delete/archive) is deliberately deferred to a follow-up PR — needs explicit safeguards for active streams, terminals, dirty files, and unpushed commits. Closes #1955. -
PR #1970 by @dobby-d-elf — First-class LM Studio provider support with live model discovery. A dedicated
elif pid == "lmstudio":branch inget_available_models()callshermes_cli.provider_model_ids("lmstudio")first, falling back to a direct GET<base_url>/modelsrequest when env vars (LM_API_KEY+LM_BASE_URL) haven't been injected yet — this fixes the race where the provider's.envisn't loaded intoos.environbefore the picker runs. Detection indetected_providersnow also fires onLM_API_KEY+LM_BASE_URLenv vars and oncfg["providers"]["lmstudio"]config entries. The new_get_provider_base_url()helper plus the change toresolve_model_provider()fromreturn bare_model, provider_hint, Nonetoreturn bare_model, provider_hint, _get_provider_base_url(provider_hint)lets users withproviders.<id>.base_urlinconfig.yamlflow that URL through model resolution consistently (pre-fix they had to also set it undercfg["model"]). The "Configured" badge code from the initial PR submission was dropped per maintainer review — see PR #1970 thread for the UX discussion.
Fixed
-
PR #2048 by @Hinotoi-agent —
[security]Session import validatesworkspacefield againstresolve_trusted_workspace(). Pre-fix, a crafted JSON import with"workspace": "/"was persisted as theSession.workspace, after which/api/file?session_id=<sid>&path=etc/hostsresolved against/and served host files. The patch routes the imported value through the same resolver every other workspace-bearing endpoint already uses (/api/session/new,/api/branch,/api/fork,/api/clone), returning 400 onValueError(blocked system root) orTypeError(non-path workspace value like{"not": "a path"}). Severity is highest on0.0.0.0-bound / reverse-proxied / LAN-exposed deployments with password auth wherePR:Lapplies — there the bug turned "authenticated session creation" into "authenticated read of any process-readable file." Default loopback-only deployments without auth were lower risk because anyone on loopback can usually read/etc/hostsdirectly. Includes 105 LOC of regression coverage intests/test_session_import_workspace_validation.pyand a belt-and-braces invariant test against the resolver itself. -
PR #2055 by @franksong2702 — Duplicate assistant transcript merge.
_merge_display_messages_after_agent_result()atapi/streaming.py:1754now skips adjacent duplicate assistant messages by merge identity (role + content + tool_call_id + json.dumps(tool_calls, sort_keys=True)). Some provider/result replay paths produced two copies of the same assistant bubble in the current delta, which then got persisted intos.messagesand sent back to the browser in thedoneSSE payload, producing duplicate assistant chat bubbles. The guard is intentionally adjacent-only so two separate turns that happen to produce identical assistant text remain visible — confirmed via the new negative-path test. Closes #2051.
Fixed (maintainer review on stage-337)
-
PR #1970 lmstudio regression — the new lmstudio branch in
get_available_models()only looked atcfg["providers"]["lmstudio"]["base_url"], missing the historical config shape where users putbase_urlundercfg["model"]whenmodel.provider == lmstudio. Three pre-existing tests intests/test_issue1527_lmstudio_base_url_classification.pybroke on stage-337 because of this gap. The fix enhances_get_provider_base_url()to fall back tocfg["model"]["base_url"]whencfg["model"]["provider"]matches the requested provider id, then routes the lmstudio branch through the helper. Belt-and-suspenders negative-case test assertsmodel.base_urldoes NOT leak to non-active providers (so a user withmodel.provider: anthropic+model.base_url: <anthropic-proxy>+providers.openaiwithout base_url still gets None for openai, not the anthropic proxy URL). 6 new regression tests intests/test_pr1970_lmstudio_base_url_fallback.py. -
PR #2053 × PR #2041 state.db worktree recovery silent data loss — Opus advisor caught this during stage review. PR #2041 (v0.51.42) added state.db sidecar reconciliation that rebuilds a missing
<sid>.jsonfrom the canonical state.db row. PR #2053 added worktree-backed sessions with new metadata fields._state_db_row_to_sidecar()was hard-coding'workspace': ''and not propagatingworktree_path/worktree_branch/worktree_repo_root/worktree_created_at/message_countfrom the row to the rebuilt sidecar. Result: a worktree-backed session that lost its JSON sidecar and got rebuilt from state.db disappeared from the sidebar (the empty-session filter atapi/models.py:1067exempts sessions withworktree_path, but the rebuilt sidecar had none) and downstream tools (terminal panels, file pickers usings.workspace) operated on empty string. Fix: extend the_read_state_db_missing_sidecar_rows()SELECT to include the missing columns (each gated by_sql_optional_col()for older state.db schemas) and propagate them in_state_db_row_to_sidecar(). Three new regression tests intests/test_state_db_worktree_recovery.pylock the round-trip, the non-worktree no-spurious-propagation case, and the empty-worktree-session-must-stay-visible invariant.
Test infrastructure
-
Hermetic network isolation across the whole test suite. Before this release, an accidentally-leaking outbound TLS handshake from the test_server fixture (Anthropic IPv6, Amazon, OpenRouter, observed via
ss -tnpduring stage-337 debugging) was adding 60+s of wall-time to pytest runs and creating a class of flaky failures. Two new layers now enforce no-outbound by default:-
Pytest process (tests/conftest.py module-level monkey-patch on
socket.create_connection+socket.socket.connect). Allowed destinations: loopback (127.0.0.0/8,::1), RFC1918 (10/8,172.16/12,192.168/16), link-local (169.254/16), RFC5737 TEST-NET-3 (203.0.113/24), RFC2606 reserved TLDs (.invalid,.test,.example,.local,localhost). Everything else raisesOSError("hermes test network isolation"). Tests that legitimately need real outbound opt back in via the newallow_outbound_networkfixture (zero current callers). -
test_server subprocess (server.py).
HERMES_WEBUI_TEST_NETWORK_BLOCK=1env var (set by tests/conftest.py on every spawn) activates an identical guard at the top of server.py at import time, before any api/* module loads. The env var is unset in production, so the guard is a no-op outside the test harness. Without this, the pytest-side block didn't cover the spawned subprocess.
-
-
test_dns_resolution_failurerefactored to mocksocket.getaddrinforaisinggaierrorinstead of relying on real DNS for a*.invalidhostname. Hermetic now, and matches the mock-based pattern every other test in the same file uses. -
tests/test_conftest_network_isolation.pywith 9 adversarial tests proving (a) outbound to the exact Anthropic IPv6 + Amazon IPv4 + Google DNS destinations we observed leaking is now blocked, (b) loopback / RFC1918 / link-local / reserved-TLD destinations pass through, (c) theallow_outbound_networkopt-in fixture works.
Tests
5,166 → 5,192 collected (+26 net new across the 4 new regression test files). All passing on Python 3.11/3.12/3.13. Full suite wall-time: 161s → 95s (the previously-leaking outbound TLS handshakes were the long tail).
Contributors
@Hinotoi-agent (×1, first contribution) · @franksong2702 (×3) · @dobby-d-elf (×1, first contribution) · @nesquena (3 maintainer review fixes)
Notes
-
The state.db × worktree recovery interaction (PR #2053 × PR #2041) is the second case in two releases where Opus advisor caught a real cross-PR data-loss bug that neither PR's individual test suite would have surfaced (the first was the v0.51.43 CSS breakpoint asymmetry). The pattern is worth its weight — cross-PR adversarial review with grep-grounded prompts catches what unit tests miss when the failure mode lives at the seam between two features.
-
LM Studio support is now first-class. Live model discovery + base URL discovery from either
providers.<id>.base_urlORcfg["model"]["base_url"](whenmodel.providermatches) means users with either config shape get a populated model picker without manualconfig.yamledits.
[v0.51.43] — 2026-05-11 — Release S (fused community PR — desktop sidebar collapse)
Added
- PR #2054 by @jasonjcwu and @spektro33 (fused, co-authored) — Desktop users can now collapse the session-list sidebar by clicking the already-active rail icon, or with Cmd/Ctrl+B. State persists across reloads via localStorage and survives bfcache restores. Two discoverability paths, no new visible UI affordance — default appearance is identical to master, only users who actively try to toggle ever see a difference. Cross-panel rail clicks behave exactly as before (no collapse, just panel switch). Mobile (<641px) is unaffected. The behaviour is gated behind one new
opts.fromRailClickflag onswitchPanel()so every programmatic call-site (commands, deeplinks, internal state changes) preserves master semantics exactly. Inline<script>flash-prevention in<head>setsdata-sidebar-collapsed='1'on<html>BEFORE the stylesheet loads, so cold loads with persisted-collapsed state paint correctly from frame 0 with no flicker.aria-expandedmirrors open/collapsed state on the active rail button for screen-reader announcements. Smooth.24s cubic-bezier(.22,1,.36,1)slide animation matches the workspace-panel collapse on the right. Drag-resize cursor stays instant viabody.resizing .sidebar { transition:none }. Closes #1884 (jasonjcwu) and #1924 (spektro33).
Fixed (maintainer review on PR #2054)
- CSS breakpoint asymmetry — pre-fix, the JS
_isDesktopWidth()guard matchedmin-width:641px(where the rail itself becomes visible) but the.sidebar-collapsedCSS rules were inside@media(min-width:901px)(copied from the workspace-panel block without thinking). In the 641-900px band (tablet portrait, small laptop windows), clicking the active rail icon would write.sidebar-collapsedto the DOM, setaria-expanded='false', and persistlocalStorage='1'— but the sidebar would visually stay open at 300px because CSS didn't match. User sees no visual change, screen reader announces "collapsed" for a still-visible sidebar, then resizing ≥901px later collapses by surprise. Fix hoists the three.sidebar-collapsedrules into their own@media(min-width:641px)block. Caught by @nesquena reviewing PR #2054; new regression testtest_css_breakpoint_matches_js_isdesktopwidthparses both files at every CI run and asserts the JS / CSS thresholds match.
Test infrastructure
-
AWS_EC2_METADATA_DISABLED=trueset at conftest module load — botocore's credential chain probes EC2 IMDS (169.254.169.254) by default during agent imports. On VPS hosts where IMDS is reachable but rate-limited (HTTP 429), this dragged a 161s test run to 600+s. Matches the guardhermes_cli/doctor.pyalready uses in its parallel-probe block. -
Credential-strip allowlist expanded from 6 prefixes to 40+ — the test_server fixture now strips MEM0, XAI, MISTRAL, OLLAMA, GROQ, AWS, Azure OpenAI, messaging bot tokens, search-engine API keys, image-gen keys, GitHub tokens, etc. before spawning the test server. Defence-in-depth against accidental outbound API calls from tests; a real outbound TLS connection to a provider's IPv6 endpoint was observed during test runs before the expansion. The test server uses a mock config and should never make real provider calls.
Tests
5,120 → 5,166 collected (+46 net new across the 35-test structural suite for sidebar collapse, the CSS-breakpoint regression guard nesquena added, and small per-locale i18n additions in dependent suites). All passing on Python 3.11/3.12/3.13.
Notes
- This is the first PR in the repo where the maintainer review caught a real defect (CSS breakpoint asymmetry) before merge AND the fix was pushed directly onto the contributor's branch with a regression test. The merged commit includes both the original fusion and the fix as separate authored commits, preserving the audit trail.
[v0.51.42] — 2026-05-11 — Release R (5-PR contributor batch — session recovery state.db reconciliation + RFC convention + MEDIA_ALLOWED_ROOTS + Slack cron delivery)
Added
-
PR #2040 by @ai-ag2026 — Read-only
GET /api/session/recovery/auditendpoint that returns the existing audit report (live +.bak+state.dbcross-check) over HTTP, andPOST /api/session/recovery/repair-safethat runs the same deterministic repairs as startup recovery (recover_all_sessions_on_startup) and returns before/after audit evidence. The POST returns409when repairable/unsafe findings remain rather than reportingokfor an incomplete repair. Both routes inherit the globalcheck_auth()gate atserver.py:133. CLI parity:python -m api.session_recovery --repair-safefor operators on the box without HTTP access. -
PR #2041 by @ai-ag2026 — DB-backed reconciliation for WebUI-origin sessions whose JSON sidecar is missing. When
state.db.sessionshas asource='webui'row but~/.hermes/webui-public/sessions/<sid>.jsonis gone (failed save, manualrm, restore-from-backup with mismatched dirs), the newrecover_missing_sidecars_from_state_db()materializes a safe sidecar from the canonical row plus orderedmessagesrows. Never overwrites an existing sidecar. Atomic write via per-pid/per-tid.json.reconcile.tmp.<pid>.<tid>+os.link()create-or-fail (closes the TOCTOU window against concurrentSession.save(); on race-loss the live sidecar wins and reconciliation silently skips). Onlysource='webui'rows are materialized; CLI/messaging/cron rows stay on their existing bridge path. Rows without readable message bodies are skipped (no blank-shell sidecars). Audit reports unrepaired rows asstate_db_missing_sidecar/repairable. Includes a round-trip schema-parity test that loads a materialized sidecar throughSession.load()to catch future drift between_state_db_row_to_sidecar()andSession.__init__(). -
PR #2042 by @ai-ag2026 — Crash-safe turn-journal RFC at
docs/rfcs/turn-journal.md. Establishes thedocs/rfcs/convention with a small README explaining when an RFC applies (durability/recovery, schema, new architectural primitives) and the status header format. The RFC itself proposes a JSONL write-ahead log per session that records turn intent before the worker starts, so crash recovery can replace inference-from-fragments with deterministic replay. Status: Proposed; ships as a design document, not as an implementation. -
PR #2044 by @watzon —
MEDIA_ALLOWED_ROOTSenvironment variable extends/api/mediafile-serving whitelist at runtime. The built-in allowed roots (~/.hermes,/tmp, active workspace) remain the default; settingMEDIA_ALLOWED_ROOTS=/home/user/models:/home/user/Pictures(colon-separated absolute paths) appends to the list. Non-existent or invalid entries are silently skipped. Resolves the "local MEDIA: path blocked outside allowed roots" usability gap for power users who keep ComfyUI outputs, model assets, or shared media in custom directories. Path-traversal validation (Path.resolve()+commonpathcontainment check) unchanged; SVG-as-attachment guard unchanged; image-MIME inline-only guard unchanged. Static unit test confirms the env var is referenced in source. -
PR #2045 by @georgebdavis — Slack appears in the cron delivery dropdown alongside Local / Discord / Telegram. The WebUI cron handler at
api/routes.py:7066passesbody.get("deliver")straight through tocron.jobs.create_job, and hermes-agent already routesdeliver=slackto the Slack platform adapter — this was a frontend-only gap. First-time contributor.
Fixed (maintainer follow-up to PR #2041)
- Concurrency hardening — Two data-corruption vectors flagged in Opus review of #2041, fixed in the staged release rather than left as follow-up: (1) the
.reconcile.tmpfilename now includes pid+tid (was a fixed path per SID, vulnerable to two-operator interleaved writes corrupting the same tmp); (2)tmp.replace(target)swapped foros.link()+unlink(tmp)so a race with a concurrentSession.save()for the same SID can't overwrite a live sidecar (skips withsidecar_appeared_during_reconcileinstead). Matches the existingSession.save()convention atapi/models.py:484.
Tests
5108 → 5120 passing, 8 skipped, 1 xfailed, 2 xpassed, 0 regressions (+12 net passing across new test files for session-recovery-API HTTP-shape contracts, state.db sidecar reconciliation including the round-trip schema-parity guard and the per-pid tmp-suffix guard, and the MEDIA_ALLOWED_ROOTS static reference). Full suite ~161s on Python 3.11 with HERMES_HOME isolation.
Notes
- New convention:
docs/rfcs/for design documents on durability, recovery, schema, and cross-cutting infrastructure. First entry is the turn-journal RFC from #2042; future contributors are invited to file design proposals there before large changes.
[v0.51.41] — 2026-05-11 — Release Q (3-PR contributor batch — session recovery audit + run-lifecycle health + transcript dedup)
Fixed
-
PR #2035 by @ai-ag2026 — Recover orphaned
<sid>.json.baksnapshots on startup (extends #1558 P0 fix). The existing post-#1558 recovery path only scanned*.json, so a crash that left only the.baksnapshot meant data was on disk but invisible to/api/sessionsand the sidebar. Now the startup self-heal looks up the orphansidinstate.db.sessions; if the row exists, the snapshot is restored, the session index rebuilt, and the live sidecar appears again. Ifstate.dblacks the row (explicit tombstone), the orphan is left alone. Companion change inapi/routes.pyunlinks<sid>.json.bakon explicit delete so intentional deletes don't get resurrected later. Fail-open onstate.dbunreadable/locked/older-schema — recovery stays best-effort. -
PR #2036 by @ai-ag2026 — Read-only
audit_session_recovery()report + module CLI (python -m api.session_recovery --audit --session-dir <dir> [--state-db <db>]). Classifies shrunken live sidecars, orphan backups, orphans without astate.dbrow, and stale_index.jsonentries. Pure read-only audit — no writes, no rebuilds, no restores. Outputs machine-readable JSON. Stacked on #2035 (and auto-closed it). -
PR #2038 by @franksong2702 — Closed the message-identity dedup gap in
/api/sessionmessaging transcript merges (closes #2027). The dedup key now prefersid/message_idwhen message identity is available; legacy role/content/timestamp/tool-metadata key remains as fallback for messages without IDs. Prevents silent loss of legitimate retries (rare but high-impact when it hits).
Added
- PR #2039 by @ai-ag2026 — Active-run lifecycle visibility in
/health. SSEactive_streamsonly describes channel state; a worker can outlive its SSE stream while unwinding, blocked in a provider call, handling cancellation, or waiting on delegated work. Addsactive_runs, per-run metadata/age,oldest_run_age_seconds,last_run_finished_at, and idle grace timing. Restart/update guards now have visibility into worker lifecycle, not just SSE channel state. Worker lifecycle wired through_register_run/_update_run/_unregister_runin streaming.
Tests
5100 → 5108 passing, 0 regressions (+8 net new across new test files for session-recovery audit, run-lifecycle health, transcript dedup, and orphan-backup recovery). Full suite ~160s on Python 3.11 with HERMES_HOME isolation.
Notes
- 3 PRs from 2 different authors (#2035 stacked under #2036 — auto-closed when #2036 merged).
api/routes.pywas touched by all three PRs with disjoint hunks (#2039 at lines 2529/2609, #2038 at 3040, #2036 at 4147).CHANGELOG.mdwas the only true conflict (#2038predates v0.51.40 release entry); resolved by preserving v0.51.40 history and re-adding the #2038 bullet under [Unreleased] before promoting.
Follow-ups
- Test isolation: at least one test in
test_update_banner_fixes.pyortest_updates.pytriggers a realos.execvthat re-executes the entire pytest suite. Suite still passes (~5108 each loop) but full run takes 4× the time. Worth a targeted fix in the next maintenance batch.
[v0.51.40] — 2026-05-11 — Release P (4-PR contributor batch — quota subprocess hardening + env-lock prewarm + cron one-shot warning + Xiaomi env key)
Fixed
-
PR #2030 by @Michaelyklam — Hardened the account-usage quota probe subprocess path (#1912 slice 1 of N): added a module-level bounded semaphore to cap concurrent profile-isolated probe children, set
stdin=subprocess.DEVNULLfor the child, and wiredpreexec_fn+prctl(PR_SET_PDEATHSIG, SIGTERM)so probe children receive SIGTERM if the WebUI parent dies. Persistent warm worker reuse remains the next follow-up if this slice is not enough under load. -
PR #2032 by @Michaelyklam — Moved skill-tool imports outside the streaming
_ENV_LOCKcritical section (closes #2024). First-timetools.skills_tool/tools.skill_manager_toolimports now run via_prewarm_skill_tool_modules()before the lock is acquired; the in-lock path usessys.modules.get(...)lookups and existingHERMES_HOME/SKILLS_DIRattribute patching. Keeps the lock critical section limited to lightweight env/cache mutation so concurrent streams don't wait behind cold import latency. AST/source-level regression test guards against reintroducing in-lock imports. -
PR #2033 by @franksong2702 — Surfaced one-shot cron schedule semantics in the WebUI Scheduled Jobs form (refs #2031). Hermes Agent treats bare durations/dates (
30m,2h,2026-05-11T08:00) as one-shot schedules that get removed after they run; the form now classifies the input and shows a live warning hint pointing users towardevery 30mor a cron expression for recurring jobs. Static regression coverage for the classifier, warning wiring, i18n keys, and CSS class. -
PR #2034 by @franksong2702 — Closed the Xiaomi MiMo
XIAOMI_API_KEYenv-detection gap (issue #2025). WebUI now treats Xiaomi like the other API-key providers: exported or.env-storedXIAOMI_API_KEYenables the Xiaomi model group fallback inget_available_models(), Settings provider-key detection via/api/providers, and onboarding provider metadata with the direct API base URL. README/CHANGELOG provider notes updated; provider-env scrub lists extended so real local Xiaomi keys don't leak into tests.
Tests
5082 → 5100 passing, 0 regressions (+18 net new across the four new test files for #2024 invariant, quota subprocess, cron one-shot warning, and Xiaomi env detection). Full suite under 152s on Python 3.11 with HERMES_HOME isolation.
Notes
- 4 PRs from 3 different authors.
api/providers.pywas touched by #2030 (+110/-7 in quota probe path) and #2034 (+1 in_PROVIDER_ENV_VARmap) with disjoint hunks.CHANGELOG.mdUnreleased section was the only true conflict (#2033 + #2034 both added bullets); resolved by keeping both entries. Stage merge otherwise clean.
[v0.51.39] — 2026-05-10 — Release O (4-PR contributor batch — Railway docker fix + Stop-button race + provider resolver + live context tracking)
Fixed
-
PR #2017 by @michael-dg —
docker_init.bashfailed on user-namespaced rootless container runtimes (Railway). In-container UID 0 maps to a host UID outside the writable subuid range, sosave_env /tmp/hermeswebui_root_env.txtfailed withPermission deniedeven thoughid -ureturns 0. The existing read-only-rootfs guard at:192-197only covered/etc/group//etc/passwdwritability and didn't catch this signature. Adds a writability probe beforesave_envand a fallback chain (${itdir}/hermeswebui_root_env.txt→/app/.hermeswebui_root_env); exports_HW_ROOT_ENV_PATHso the post-su phase finds the same file. State-dir verifier left intact (silent degradation there would mask real volume-permission misconfig). Closes #2010. -
PR #2018 by @rhelmer — Stop button didn't refresh after
/api/chat/startreturned astream_id. The client became busy before it had a new stream id, updated the send button at that moment, but never updated again once the id arrived — so the Stop button only fixed itself when something else triggered a refresh (e.g. the user typing). Now refreshes when the new stream id is received and again when an oldactiveStreamIdis cleared, so the button doesn't lie about whether stop/cancel is valid. Includes regression coverage intests/test_1062_busy_input_modes.py. -
PR #2022 by @Michaelyklam —
resolve_model_provider()inapi/config.pycheckedcustom_providers[]first, so when the configured default model also appeared in a custom provider entry, the request routed tocustom:<name>instead of the explicit active provider. Users hit confusing 401/auth errors from a provider they didn't intend to use (#1922). The narrow fix skips custom-provider shadowing only for the configured default model when the active provider is an explicit non-custom provider. Existing custom-provider routing for explicitly selected custom-models and slash-containing endpoint model IDs is preserved. Regression tests added forai-gatewayandxiaomioverlap cases. Closes #1922.
Added
-
PR #2009 by @dobby-d-elf — Live context-window tracking during streaming. Two gaps closed in the WebUI context indicator:
- Updates during tool calls. Token usage and context length were previously updated only after a full response completed; the indicator now receives live
usageevents mid-stream while tools are executing, so users see real-time consumption instead of stale numbers. Server emits_live_usage_snapshot()payloads during tool execution; frontend merges them via_syncCtxIndicator(). Tracks input tokens, output tokens, estimated cost, context length, threshold tokens, and last prompt tokens. - Reset on new sessions.
_syncCtxIndicator()is now called fromnewSession()so the indicator starts from the fresh session's reading instead of carrying stale values from the previous conversation.
Live metering events are tagged with the real WebUI
session_idso the frontend session filter accepts them. Token-driven metering events include the liveusagepayload to keep the indicator moving while the agent is actively streaming. Reused cached agents refreshtool_start_callbackandtool_complete_callbackso live tracking continues after the first turn in a session. - Updates during tool calls. Token usage and context length were previously updated only after a full response completed; the indicator now receives live
Tests
5066+ → 5071+ passing, 0 regressions (+5 net new across test_1062_busy_input_modes.py, test_model_resolver.py, test_issue1617_tps_message_header.py). Full suite under 160s on Python 3.11 with HERMES_HOME isolation.
Notes
- 4 PRs from 4 different authors.
static/messages.jswas the only multi-PR file (#2009 + #2018), with disjoint hunks at lines ~1159 and ~210/244/261 respectively.api/streaming.pyonly touched by #2009. Stage merge clean with no conflicts.
[v0.51.38] — 2026-05-10 — Release N (UI polish — toast + mobile + diff renderer + sidebar)
Fixed
-
PR #1988 by @Michaelyklam — Auto-compression toast lifetime increased so the user sees the boundary summary long enough to register what happened. Auto-compression rewrites session context, so its completion toast carries more trust weight than a generic "settings saved" notification. Per #1834 Option A — the smallest safe slice. Adds regression coverage.
-
PR #2007 by @insecurejezza — Wrap markdown code blocks on mobile instead of forcing horizontal scrolling. Desktop behavior unchanged. Includes Prism token spans, preview markdown, and diff line spans in the mobile wrapping rules. Regression coverage in
test_mobile_markdown_wrapping.py. -
PR #2008 by @franksong2702 — CLI session patch diff rendering. Historical CLI sessions that predate session-level
tool_callsreconstruct tool cards from per-message metadata instatic/ui.js; that fallback truncated tool results to 200 chars and only showed the first 120 chars of tool arguments, soapply_patch/edit diffs recorded withverbosity=allcould disappear behind a genericSuccessresult. The renderer now preserves diff-like tool outputs, promotesapply_patch/edit payloads into the tool-card snippet when the result is non-diff, and labels long diff expanders asShow diff. 245-line regression test (test_issue1824_cli_patch_diff_rendering.py) covers both the API payload preservation and the renderer fallback. Closes #1824. -
PR #2013 by @ai-ag2026 — Avoid sidebar jumps when the active session is already visible. Previously the virtualized session sidebar always re-anchored on the active row, which produced a jump even when the row was inside the current window. Now only re-anchors when the active row is outside the rendered window. Regression coverage in
test_issue500_session_list_virtualization.py.
Tests
5049 → 5057 collected, 5057 passing, 0 regressions (+8 net new). Full suite 154s on Python 3.11 with HERMES_HOME isolation.
[v0.51.37] — 2026-05-10 — Release M (compression / lineage backend)
Fixed
-
PR #2004 by @franksong2702 — Persisted compression boundary summary for reload UI. Both manual
/session/compressand auto-compression paths now persistcompression_anchor_summary,compression_anchor_visible_idx, andcompression_anchor_message_keyso the compression card renders correctly after a page reload. Closes #1833. -
PR #2006 by @qxxaa — Stamp profile on continuation session after context compression. In multi-profile deployments, memory writes after auto-compression silently targeted the default profile's
MEMORY.md, regardless of which profile the browser session was using. Root cause: the compression migration block in_periodic_checkpointdid not carrys.profileacross to the continuation session, so subsequent requests fell back to the default profile'sHERMES_HOME. Fix resolves the profile name froms.profile(orget_active_profile_name()while TLS still holds) at streaming-thread start, then stampss.profile = _resolved_profile_nameon the continuation session. Verified evidence: session0dfefbhad read the wrong profile'sMEMORY.md(16% / 4 entries) instead of the troubleshooting profile's bank (72-77% / 5000+ chars). -
PR #2011 by @ai-ag2026 — Sidebar lineage collapse: prefer the latest compressed segment when a parent row is touched. Previously the sidebar collapse helper picked representatives by timestamp only, which could surface a touched-parent row instead of the newer compressed tip. Now keys on
_compression_segment_countso the highest-count segment wins. Regression test added. -
PR #2014 by @ai-ag2026 — Keep explicit
/api/session/branchforks out of compression-lineage collapse. Forked sessions now marksession_source="fork"on creation, and the sidebar lineage helper guards against folding fork rows into the compression-collapse path even when the parent isn't currently in the rendered window. Backend marker test + sidebar guard test added. -
PR #2015 by @Jellypowered — Stitch continuation-lineage transcripts in WebUI. Sessions split by continuation events (compression boundary, CLI-close) could show only the latest segment in the WebUI message history.
get_cli_session_messages()now walks the valid continuation lineage and stitches messages across sessions so the full conversation is visible.
Added
- PR #2012 by @dso2ng — New read-only
/api/session/lineage-report/<sid>endpoint exposing a bounded JSON diagnostic of a session's compression/branching lineage. Pure backend probe — no client UI changes. The sidebar lineage UI (#1906/#1943) already covers user-facing affordances; this fills the bounded backend probe gap for CLI/scripting use.
Tests
5049 → 5058 collected, 5058 passing, 0 regressions (+9 net new across test_session_lineage_collapse.py, test_session_lineage_full_transcript.py, test_session_lineage_report.py, test_465_session_branching.py, test_auto_compression_card.py, test_sprint46.py). Full suite 157s on Python 3.11 with HERMES_HOME isolation.
Notes
api/routes.py(4 PRs touched it) andapi/streaming.py(2 PRs) were the multi-PR files. All hunks at distinct anchors; stage merge clean with no conflicts.- Theme coherence: every PR in this batch addresses session compression, lineage, or continuation-stitching — the same conceptual surface from different angles.
[v0.51.36] — 2026-05-10 — Release L (locale + provider + cross-cutting)
Fixed
-
PR #1992 by @29n —
ctl.shline 42 used[[ -v ${key} ]], which requires bash 4.2+. macOS ships with bash 3.2 →conditional binary operator expectederror. Replaced with[[ -n "${!key+x}" ]]— a portable variable-set check that works on bash 3.2+, zsh, and POSIX-compatible shells. No behavior change. -
PR #1998 by @franksong2702 — Localized
/goalruntime status strings. Added 13 i18n keys (goal_evaluating_progress,goal_working_toward,goal_continuing_toast,goal_status_*,goal_set/paused/resumed/cleared/no_goal,goal_achieved,goal_paused_budget_exhausted,goal_continuing) across all locales; new keys reachstatic/messages.jsandstatic/commands.jsso the goal UI no longer hardcodes English. Closes #1933. -
PR #2000 by @qxxaa — Skill tools resolve from the wrong profile after per-request profile switch.
tools/skills_tool.pyandtools/skill_manager_tool.pycacheHERMES_HOMEas a module-level constant at import time. The process-wideswitch_profile()path patches both modules via_set_hermes_home(), but the per-request path (switch_profile(process_wide=False), introduced in #1700) only updatedos.environ['HERMES_HOME']and skipped the module patching. Result: agents on non-default profiles always saw the root profile's skills. Fix adds the same monkeypatching to the per-request branch inapi/streaming.py. Closes the parity gap with #1700. -
PR #2001 by @franksong2702 —
clarify.timeoutconfig was ignored by WebUI clarify prompts. The callback used a hardcodedtimeout = 120. Now readsclarify.timeoutfromapi.config.get_config()with bounded fallback (defaults to 120 on missing/invalid config), and threadstimeout_secondsinto theapi.clarify.submit_pendingpayload so the frontend countdown matches the backend timeout. Regression test intests/test_sprint42.py. Closes #1999. -
PR #2005 by @vikarag — Added Xiaomi as a first-class provider in the WebUI's model catalog.
hermes-agentalready registered Xiaomi (verified athermes_cli/models.py:782+ auth entries) butapi/config.pywas missing the corresponding_PROVIDER_DISPLAY/_PROVIDER_ALIASES/_PROVIDER_MODELSentries, so the provider list showed Xiaomi asUnsupportedand the model dropdown fell back to OpenRouter. Addsxiaomidisplay name,mimo/xiaomi-mimoaliases, and 5 MiMo models (V2.5 Pro/V2.5/V2 Pro/V2 Omni/V2 Flash).
i18n
-
PR #2002 by @eov128 — Refreshed Simplified Chinese (zh) translation. Two kinds of changes:
- Decoded
\uXXXXescape sequences to literal CJK characters in already-translated strings (semantically identical at runtime; improves source readability and grep-ability) - Translated 30+ previously-untranslated strings tagged
// TODO: translate— covering MCP server status (mcp_status_active,mcp_status_configured, ...), MCP tools panel, session toolsets, workspace hidden files, terminal pane, and personality switch hint
Stage 330 conflict resolution: #1998 added new
goal_*English keys interleaved with thecmd_interruptblock that #2002 was rewriting; resolved by preserving #1998's new English keys (TODO: translate) above the section while taking #2002's CJK literals forcmd_*/settings_*keys.Stage 330 test fix:
tests/test_chinese_locale.py::test_chinese_locale_includes_representative_translationswas pinned to the source-encoded\uXXXXform forsettings_titleandlogin_title. Broadened to accept either\uXXXXor literal CJK (same runtime behavior). Other source-form assertions in this test were already on literal CJK. - Decoded
Tests
5049 → 5049 collected, 5049 passing, 0 regressions (one PR added new tests in test_kanban_ui_static.py already counted in stage 329; stage 330 net is flat). Full suite 158s on Python 3.11 with HERMES_HOME isolation.
Notes
api/streaming.pywas the high-collision file (4 PRs touched it: #1998 #2000 #2001 #2006-not-in-this-stage). Stage merge clean; #2000 and #2001 each added separate ~17-LOC blocks at distinct anchor points, no overlap.- All 6 PRs from 6 different authors except for #1998+#2001 (both @franksong2702). Disjoint themes.
[v0.51.35] — 2026-05-10 — Release K (kanban polish + i18n DE pluralization)
Fixed
-
PR #1990 by @franksong2702 — Kanban dispatcher race guard. Adds
_kanbanIsDispatchingflag aroundrunKanbanDispatcher()andnudgeKanbanDispatcher()instatic/panels.js; both Run/Preview buttons go disabled while the call is in-flight, so a fast double-click can't fire the dispatcher twice (which would post duplicate POSTs and surface duplicate toasts). Re-enables on success or error infinally. Closes #1984. -
PR #1991 by @franksong2702 — German
profile_skill_countpluralization. The DE locale hadprofile_skill_count: '{count} Fähigkeiten'as a literal string with the placeholder token still in it (so 1, 2, 5 skills all rendered as{count} Fähigkeiten). Switched to the same(count) => …interpolation function form already used by the other locales. Regression testtests/test_issue1989_profile_skill_count.pypins DE to function form and asserts the literal token never reaches the rendered string. Closes #1989. -
PR #1993 by @franksong2702 — Kanban assignee-dropdown profile cache invalidation.
_kanbanProfileNamesCachewas populated lazily on first modal open and never expired; creating or deleting a profile elsewhere in the UI didn't refresh it, so the assignee dropdown could show a freshly-deleted profile or miss a freshly-created one. Added a 30-second TTL (_kanbanProfileNamesCacheAt+_KANBAN_PROFILE_NAMES_CACHE_TTL_MS) and an explicit_invalidateKanbanProfileCache()helper called fromsaveProfileForm(),deleteCurrentProfile(), anddeleteProfile(). Closes #1985. -
PR #1995 by @franksong2702 — Kanban modal focus trap + edit-mode status hint. Two related fixes bundled (#1995 was rebased on top of #1994 in the contributor's branch):
- Focus trap (#1974). Tab/Shift-Tab in the Kanban task and board modals could move keyboard focus to controls behind the modal. Added a shared
_trapModalFocus(modalEl)helper instatic/panels.js; wired intoopenKanbanCreate(),openKanbanEdit(),openKanbanCreateBoard(), andopenKanbanRenameBoard(). Cleanup tracker_kanbanTaskModalFocusCleanupremoves the trap on close so a sequence of open→close→open doesn't leak listeners. - Status hint (#1986). When opening Edit on a task whose real status is
running/blocked/done/archived(which the dropdown displays astriagebecause the dispatcher only writes totriage/todo/ready), the modal now shows an inline hint explaining the displayed-vs-real mismatch. The dropdown behaviour is unchanged — only an additional UX cue. New CSS for.kanban-status-hint, new i18n keykanban_status_hint_realacross all 8 locales.
Closes #1974, #1986.
- Focus trap (#1974). Tab/Shift-Tab in the Kanban task and board modals could move keyboard focus to controls behind the modal. Added a shared
-
PR #1996 by @franksong2702 — Kanban modal locale parity regression test. Adds
tests/test_kanban_ui_static.py::test_kanban_modal_locales_have_full_modal_vocabularythat anchors on the existingkanban_no_commentskey and asserts every locale supporting Kanban has the modal vocabulary. Hardens locale-block parsing to handle quoted locales. Pure test addition.
Tests
5049 → 5054 collected, 5054 passing, 0 regressions (+5 net new). Full suite 154s on Python 3.11 with HERMES_HOME isolation.
Stage augmentation
9242305a— Opus advisor flagged thatkanban_status_original_hint(added by #1995) was missing in thezh-Hantblock, so Traditional Chinese users would get the English fallback. Added the Traditional Chinese translation (實際狀態:{0}。此對話框僅支援編輯 Triage/Todo/Ready。) at line 6537 and extendedtests/test_kanban_ui_static.py::test_kanban_modal_locales_have_full_modal_vocabulary'smodal_keyslist to assert the key — so any future kanban modal key added without zh-Hant translation will fail CI.
Notes
static/panels.jswas the high-collision file in this batch (5 PRs touched it). Stage merge cleanly; one syntactic conflict at the_kanbanProfileNamesCachedeclaration block when #1995 landed on top of #1993 — both PRs added new module-levelletdeclarations adjacent to_kanbanProfileNamesCache. Resolved by preserving both declaration blocks (the variables are independent).- Six PRs in batch, all from @franksong2702. Disjoint concerns, disjoint i18n keys, disjoint tests. The 5-files panels.js overlap was the only nontrivial integration risk and resolved cleanly.
[v0.51.34] — 2026-05-09 — Release J (kanban edit/dispatch + zh-Hant kanban i18n)
Added
-
PR #1981 by @nesquena-hermes — Three connected Kanban-UX fixes that were load-bearing for the actual work-queue lifecycle:
- Edit task — new
.kanban-edit-btnon the detail-view header opens the existing#kanbanTaskModalpre-filled from a fresh server fetch. Submit branches POST→PATCH for edit mode. Backend already supported_patch_taskatapi/kanban_bridge.py:338-424; pure UI gap closed. - Run dispatcher — new
runKanbanDispatcher()posts/api/kanban/dispatchWITHOUTdry_run=1after ashowConfirmDialog. Two UI surfaces: lightning-bolt button in the board header and primary "Run dispatcher" button in the sidebar bulk bar._kanbanFormatDispatchResult()produces concrete summaries (Dispatched: 1 spawned, 2 skipped (no assignee)) instead of a generic OK toast. ExistingnudgeKanbanDispatcher()preserved as the dry-run preview path. - Assignee dropdown —
<input list>→<select>populated from/api/profiles(Hermes profile names) + historical board assignees (under<optgroup label="Other">) + explicit "— Unassigned (won't auto-run) —" option. Helper text under the field explains the dispatcher claim contract. Soft warning if the user picks Ready + Unassigned (proceeds on second submit).
Side effect: default new-task status changed from
triage→readyso the dispatcher actually picks up newly created tasks without an extra status change. Improvements to.kanban-modal-errorstyling benefit the existing create-board modal too.Stage-328 hotfix per nesquena's pre-merge review: caught a destructive edit-mode regression — opening Edit on a
running/blocked/done/archivedtask and saving without changing the status would silently demote the task totriage(because_kanbanEditableStatusFor()maps non-editable originals to'triage'for the dropdown display, andsubmitKanbanTaskModal()was unconditionally including the dropdown value in the PATCH payload). Fixed in commit8e0eedd1by introducing a module-scoped_kanbanTaskModalInitialDisplayedStatustracker that records the dropdown value at modal open; the submit path only includesstatuswhen the user has actually changed it from the displayed value. Addedtests/test_kanban_ui_static.py::test_kanban_edit_mode_preserves_status_when_dropdown_untouchedpinning the invariant.19 new i18n keys × 8 locales = 152 entries (zh-Hant added in stage augmentation, see below). 4 new regression tests.
Closes #1982.
- Edit task — new
Fixed
-
PR #1979 by @Michaelyklam — Backfilled the previously-empty zh-Hant kanban locale block in
static/i18n.js. The Traditional Chinese locale never had Kanban keys at all, so Traditional Chinese users saw English fallbacks for every Kanban label since the panel shipped. Now zh-Hant has 68 kanban keys at parity with the other 7 supported locales (en/ja/ru/es/de/zh/pt/ko). Closes #1972.Stage augmentation (
3fbecc48): when #1981 added 17 NEW kanban keys for the edit/run/assignee work, those went into the 8 existing kanban-supporting locales but missed zh-Hant again (since #1981 was authored before #1979 landed). Stage-328 added a maintainer commit backfilling the 17 new keys into zh-Hant with Traditional Chinese translations adapted from the Simplified Chinese (zh) versions. Result: every locale now has the same 85 kanban keys — zero gap.
Tests
5043 → 5049 collected, 5049 passing, 0 regressions (+6 net new from #1981's 4 + nesquena's status-preservation regression + the augmentation parity guard). Full suite ~145 s on Python 3.11 (HERMES_HOME isolated). One known-flake (test_parallel_session_switch.py::TestGitInfoParallel::test_parallel_faster_than_serial — timing benchmark that re-passes 3/3 in isolation, see existing flake history).
Pre-release verification
- Full pytest under
HERMES_HOMEisolation: 5049 passed, 8 skipped, 1 xfailed, 2 xpassed in 145.20 s; one timing-flake re-passes in isolation. - JS syntax check (
node -c) clean onstatic/i18n.js+static/panels.js(the 2 modified static files). - Conflict-marker scan: clean.
- Silent-revert check: per-file additions match between contributor branches and stage HEAD.
- Independent reviews: nesquena APPROVED on #1981 with end-to-end audit; #1979 qualifies for self-review per project policy (i18n.js only, CI green on 3.11/3.12/3.13).
- Opus advisor: SHIP-WITH-FIXES (all required code-correctness items pass; the "fixes" were CHANGELOG entries to add — applied here).
Follow-up items filed (non-blocking)
Three nice-to-have polish items called out by Opus that don't block this release:
-
_kanbanIsDispatchingflag to disable the Run/Preview buttons during in-flight POST (current double-click path is benign — atomicclaim_taskserver-side prevents destructive double-spawn — but produces a "0 spawned" second toast). -
Profile-cache invalidation hook for
_kanbanProfileNamesCacheso profile create/delete from elsewhere in the WebUI propagates without a reload. Current behavior is graceful degradation (orphaned-profile assignee → dispatcher logsskipped_nonspawnable, user can re-edit). -
Status-display hint near the modal status
<select>for non-editable original states (running/blocked/done/archived → mapped totriagein the dropdown). The tracker fix makes untouched-submit harmless, but a small visual hint like "(real status: running)" would reduce user confusion. -
bug(profile/mcp): non-default profile MCP servers never load (#1968).
_run_agent_streamingcalleddiscover_mcp_tools()~100 lines BEFORE the per-sessionos.environ['HERMES_HOME'] = _profile_homemutation, so MCP discovery always read the default profile's~/.hermes/config.yamlregardless of which profile the session was stamped with. Result: switching profiles in the WebUI dropdown was effectively cosmetic for MCP — non-default profiles never registered their stdio (npx/node) MCP servers. Fix relocates thediscover_mcp_tools()call past the_ENV_LOCKenv-mutation block soget_hermes_home()resolves to the session's actual profile home. Adds 4 static regression tests (tests/test_issue1968_mcp_profile_discovery.py) pinning the call ordering, lock-release placement, single call site, and try/except wrapping. Caveat (out of scope, agent-side):_serversintools/mcp_tool.pyis a process-global dict keyed only by server name, so concurrent use of multiple non-default profiles in the same WebUI process still has a "first profile wins per name" issue. Fully fixing that requires keying_serversby(profile_home, name)upstream in hermes-agent. This PR ships layer 1 only.
[v0.51.31] — 2026-05-09 — Release H (12-PR contributor batch: image-mode + race fixes + composer drafts + locale parity)
Added
-
PR #1956 by @JKJameson — Persistent composer draft. The chat composer textarea (
#msg) is now persisted per-session server-side underSession.composer_draft = {text, files}, so drafts survive page refreshes and sync across clients. NewPOST/GET /api/session/draftendpoints (input validation: text clamped to 50 KB, files clamped to 50 entries, types coerced to str/list — Stage-326 hardening per Opus advisor). Frontend: 400 ms debounced auto-save on textareainput, immediate fire-and-forget save before session switch, save on clarification card lock._restoreComposerDraftguards against stale responses from rapid session switching. Co-authored by Minimax. -
PR #1957 by @hermes-gimmethebeans — Configurable session TTL. New
_resolve_session_ttl()helper with three-layer precedence:HERMES_WEBUI_SESSION_TTLenv var >settings.jsonsession_ttl_seconds> 30-day default. Out-of-range values [60s, 1y] fall through to the default. Resolved dynamically at everycreate_session()andset_auth_cookie()call so settings changes take effect immediately without restart. TheSESSION_TTL = 86400 * 30module constant is preserved as the named fallback (Stage-326 reconciliation: existing regression tests pin the constant; #1957 originally deleted it). Closes #1954.
Fixed
-
PR #1939 by @ai-ag2026 — Test-only follow-up: tightens the theme-color bridge tests so the pre-paint script must update every theme-color meta tag and remove stale media attributes; asserts the runtime theme sync updates both the canonical id tag and fallback theme-color tags; adds regression coverage that service-worker shell assets use network-first with cache fallback.
-
PR #1941 by @ai-ag2026 — Preserve chat scroll across final render. When a stream completed, the
donehandler replaced the live transcript with persisted session messages viarenderMessages({ preserveScroll: true }). ThepreserveScrollpath avoided forcing bottom-scroll, but did not preservescrollTopitself; during the DOM rebuild the browser could reset#messages.scrollTopto0, sending a reader who had scrolled up to the first message. Now captures the scroll position before the rebuild and restores it for unpinned readers; pinned/near-bottom readers keep the existing bottom-follow behavior. -
PR #1945 by @franksong2702 — Localized the six session-jump-button keys (Start/End labels, aria labels, Appearance setting copy) for ja/ru/es/de/zh/zh-Hant/pt/ko. The opt-in
session_jump_buttonssetting in #1928 (Release G) had English fallbacks in non-English locale blocks; this completes the parity. Strengthened the regression test so future changes cannot leave English literals in non-English locales. Closes #1938. -
PR #1947 by @happy5318 — Show the same model from different named custom providers in the dropdown instead of silently dropping the second provider's entry. The
_seen_custom_idsglobal bucket inget_available_models()was seeded fromauto_detected_modelsand used a bare model id as the dedup key, so a second named provider exposing the same model id (e.g. bothbaiduandhuoshanexposingglm-5.1) had its entry dropped. Switched the dedup key tof"{slug}:{model_id}"so each provider's models track independently. Maintainer-augmented with a regression test (test_pr1947_same_model_multiple_custom_providers.py) that fails on master and passes on the fix. Co-authored by @hacker1e7 (independently filed #1874 with broader scope; closed in favor of the narrower fix). -
PR #1949 by @Sanjays2402 — Closes the v0.51.30 regression race between endless-scroll prefetch and Start-jump's
_ensureAllMessagesLoaded(Issue #1937). With both opt-ins ON, an in-flight_loadOlderMessagesracing withjumpToSessionStart → _ensureAllMessagesLoadedcould prepend a duplicate page if the prefetch resolved last. The naive same-flag-check approach (proposed in #1942 and #1962, both closed in favor of this PR) is a no-op for the post-await race because the prefetch has already cleared the entry-gate. The actual fix is a generation-token + mutex pair: (1)_loadOlderMessagessnapshots a module-scoped_messagesGenerationcounter before itsawait api(...)and re-checks it after, aborting the prepend cleanly if any wholesale-replace bumped the token mid-flight; (2)_ensureAllMessagesLoadedclaims the_loadingOldermutex, bumps the generation token before mutatingS.messages, yields until any in-flight prefetch'sfinallyreleases the mutex, then claims the mutex itself. Also adds same-session and_loadingSessionIdguards that the original ensure-all body was missing post-await. 12 new regression tests pin the wait → lock → fetch → mutate → unlock invariant. Co-authored by @franksong2702 and @Michaelyklam (parallel-discovery PRs). Closes #1937. -
PR #1950 by @franksong2702 — Mute stale stopped gateway heartbeat. When the root
gateway_state.jsonhadgateway_state == "stopped"and was older than the freshness threshold, the existing logic still treated it as a configured-but-down gateway, surfacing a persistent heartbeat-down alert for users running only profile-scoped gateways. New stale-stopped helper inapi/agent_health.pyreportsalive: nullwith reasongateway_stale_stopped_stateinstead ofalive: false. Fresh stopped states still report down (so a recently stopped configured root gateway continues to surface as an outage), and stalegateway_state == "running"still reports down (preserving the #1879 false-positive guard). Closes #1944. -
PR #1951 by @amlyczz — Gate the goal evaluation hook on goal-related turns only (Issue #1932). Pre-fix,
evaluate_goal_after_turn()fired on every completed assistant turn when a goal was active, including unrelated user messages — burning the goal budget, triggering continuation prompts that interrupted unrelated conversations, and making/goal statusnumbers misleading. AddedSTREAM_GOAL_RELATED(dict) +PENDING_GOAL_CONTINUATION(set) flags inapi.config;_run_agent_streamingaccepts agoal_related=Falsekwarg and skips the goal evaluation section when not goal-related;goal_continueadds the session toPENDING_GOAL_CONTINUATIONso the next stream is auto-marked; routes propagate the flag and the/api/goalkickoff path passesgoal_related=True. Co-authored by @franksong2702 (parallel #1946 closed in favor of this PR's broader test coverage). Closes #1932. Stage-326 hotfix per Opus advisor: removedPENDING_GOAL_CONTINUATION.discard(session_id)from the streaming worker'sfinallyblock — that race-erased the marker before the consumer inroutes.pycould read it; the consumer already discards atomically on read. 5 new regression guards pin the corrected ordering. -
PR #1953 by @lucky-yonug — Skip the
#1776provider-peel for custom host:port slugs.model_with_provider_contextcan emit@custom:<host>:<port>:<model>when the model provider is derived from an OpenAIbase_urlauthority (e.g.custom:10.8.0.1:8080). The existing colon-count heuristic mistook those extra colons for an over-split model id and prepended the port segment onto the bare model (8080:Qwen3-235B), breaking WebUI while CLI/curl stayed correct. Now detects endpoint-style slugs (IPv4 / localhost / dotted-hostname + numeric port) and skips the peel in that case. References #1776. -
PR #1960 by @Michaelyklam — Translate the
workspace_show_hidden_fileslabel for ja/ru/es/de/zh/zh-Hant/pt/ko, replacing the English fallbacks in seven non-English locales. Closes #1841. -
PR #1961 by @sbe27 — WebUI now respects
image_input_modeinstead of unconditionally embedding nativeimage_urlparts._build_native_multimodal_message()was bypassing the agent'simage_input_modeconfig, causing silent turn failures with non-vision models or text-only fallbacks. Added_resolve_image_input_mode(cfg)mirroringdecide_image_input_mode()and wired into the multimodal message builder; when mode resolves to"text", returns a plain string sovision_analyzehandles images instead. Closes #1959.
Cluster-resolution decisions
Three duplicate-PR clusters consolidated to one canonical PR each, with Co-authored-by attribution preserved on the merge commit:
-
#1937 race — three competing fixes filed within 24h: #1942 (synchronous mutex), #1949 (generation-token + mutex), #1962 (serialization + browser evidence). Selected #1949 as the canonical fix; the synchronous-mutex approach in #1942/#1962 doesn't reach into a prefetch's resolved callback once it's past the entry-gate. Browser evidence under
docs/pr-media/1937/was not absorbed (the fix in stage covers what the evidence demonstrates). -
#1932 goal hook — same-shape fixes in #1946 and #1951. Selected #1951 for the materially better test coverage (10 dedicated regression tests vs handful in #1946); both PRs ship the
goal_relatedflag through/api/chat/start→ streaming worker. -
Custom-provider dedup — #1874 (broad scope including a behavior change to
_deduplicate_model_ids) vs #1947 (4-LOC minimum-correct fix). Selected #1947; #1874's_deduplicate_model_idschange can be revisited as a separate PR if the underlying gap is real.
Stage-326 fixes applied per Opus advisor
-
CRITICAL #1951 PENDING_GOAL_CONTINUATION race fix. The original PR's
finally-block discard atapi/streaming.py:3553race-erased the marker before the frontend's SSE-receive →POST /api/chat/startround-trip could consume it. Removed the discard; the consumer inroutes.pydiscards atomically on read. 5 new regression guards intests/test_stage326_pending_goal_continuation_race.pypin the corrected ordering. -
#1956 composer-draft input validation. Added size + type clamps (text 50 KB max str-coerce, files 50 entries max list-coerce) to the
POST /api/session/drafthandler. Without this, a misbehaving client could persist multi-MB strings into the session JSON via the 400 ms debounced auto-save. 5 new validation tests intests/test_stage326_composer_draft_validation.py. -
#1957 SESSION_TTL constant preserved. The original PR deleted the
SESSION_TTL = 86400 * 30module constant; existing regression tests (test_v050258_opus_followups::test_redirect_session_ttl_30_days,test_auth_sessions::test_session_ttl_is_24_hours) pin it as a guard against the daily-kick-out regression from #1419. Restored as the named fallback for_resolve_session_ttl(). Reconciled the newTestSessionTtlResolutionclass to use unittest setUp/tearDown env snapshotting rather than the pytestmonkeypatchfixture (incompatible withunittest.TestCasesubclasses) and aligned clamp tests with the actual fall-through-to-default behavior.
Tests
5006 → 5028 collected, 5028 passing, 0 regressions (+51 net new across the 12 PRs + 10 stage-326 hardening tests). Full suite ~143 s on Python 3.11 (HERMES_HOME isolated). JS syntax check (node -c) clean on all 5 modified static/*.js files. Browser API sanity harness (port 8789): all 11 endpoints + 20 QA tests PASS. Manual live verification on stage-326 server (port 8789): composer-draft validation working (50 KB clamp, 50-entry files clamp, type coercion); session TTL resolution honors env var (3600 s) and falls through on out-of-range. Opus advisor: SHIP-WITH-FIXES (all required + recommended fixes applied in 404e24ac + 8782fd26 stage commits).
Pre-release verification
- Full pytest under
HERMES_HOMEisolation: 5028 passed, 8 skipped, 1 xfailed, 2 xpassed, 1 warning, 8 subtests passed in 142.61 s. - Browser API harness against port 8789: all 11 endpoints + 20 QA tests PASS (111.19 s).
- Manual live verification on stage-326 server (port 8789): composer-draft API + TTL resolution + custom-provider model groups all behave as expected.
node -con all 5 modifiedstatic/*.jsfiles: clean.py_compileon all 6 modifiedapi/*.pyfiles: clean.- No leftover merge-conflict markers anywhere in the tree (companion
tests/test_pwa_manifest_sw.pyregression check + grep sweep). - Stage diff: 28 files, +1609/-116.
- Opus advisor pass: VERDICT=SHIP-WITH-FIXES with all critical + recommended fixes now applied. Re-verified on the patched stage HEAD.
- Pre-stamp re-fetch of all 12 PR heads: no contributor force-push during the build window.
Closed in favor of canonical PRs (with Co-authored-by attribution)
- #1942 (franksong2702 — synchronous mutex for #1937) → closed in favor of #1949
- #1962 (Michaelyklam — serialization + browser evidence for #1937) → closed in favor of #1949
- #1946 (franksong2702 — goal_related flag for #1932) → closed in favor of #1951
- #1874 (hacker1e7 — broader custom-provider dedup) → closed in favor of #1947's 4-LOC fix
- #1311 (lost9999 — codex cache invalidation; superseded on master)
[v0.51.30] — 2026-05-08 — 3-PR contributor batch (Release G: offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll)
Added (3 PRs, all from @ai-ag2026)
-
PR #1891 — Browser offline recovery and PWA cache hardening. Adds an offline/recovery banner that probes
/healthand auto-refreshes when Hermes is reachable again. Defers stream error handling while the browser is offline so reconnecting does not immediately surface a terminal chat error. Makes service-worker shell assets network-first with cache fallback (so local hotfixes are not hidden behind stale cached JS/CSS), while preserving offline-launch capability viainstallpre-caching of SHELL_ASSETS. Keeps PWA/native chrome colors aligned with the dark Hermes background. Stream-error deferral only triggers when the banner is visible ORnavigator.onLine===false— so Hermes-up + browser-online flows errors through normally; no swallowed auth errors. Supersedes the recovery/PWA portion of #1888. -
PR #1928 — Opt-in session Start/End jump buttons (
session_jump_buttonssetting, default OFF). Adds an Appearance setting that surfaces a stickyStartpill (loads full history and jumps to beginning) and expands the existing scroll-to-bottom button into anEndpill. Localized text, tooltip, and aria labels for the jump controls. The opt-in default keeps the existing UI unchanged for users who don't want the floating pills. -
PR #1929 — Opt-in session endless-scroll (
session_endless_scrollsetting, default OFF). Adds automatic prefetching of older transcript pages while scrolling upward (1.5x viewport prefetch window). Builds on #1927's viewport-preservation fix (shipped in v0.51.29) so prepended pages have scroll runway and don't jump. Replaces the previous auto-trigger-at-scrollTop<80 behavior — when the setting is OFF, users get the manual "Load earlier" button path (_wireMessageWindowLoadEarlierButton).
Conflict resolution applied during stage merge
#1928 and #1929 both touch static/ui.js, static/i18n.js, static/index.html, static/panels.js, api/config.py. Mechanical conflicts (both add new settings keys / locale entries / HTML toggles / accessor branches) were resolved by keeping both — the features are independent opt-in toggles. The static/ui.js scroll-listener conflict required an intent-based resolution: #1929 INTENTIONALLY replaces the el.scrollTop<80 auto-trigger block with the gated prefetch block, so the old block was removed. Test tests/test_session_endless_scroll.py::test_scroll_listener_prefetches_older_messages_only_when_enabled enforces this. CHANGELOG conflicts auto-resolved during rebase (took ours strategy).
Tests
4960 → 4977 collected, 4977 passing, 0 regressions (+17 net new). Full suite ~140s on Python 3.11 (HERMES_HOME isolated). JS syntax check (node -c) clean on all 6 modified static/*.js files. Browser API sanity harness (port 8789): all 11 endpoints + 20 QA tests PASS. Manual browser verification on stage-325 server (port 8789): both new settings toggles render in the Settings panel; window._isSessionEndlessScrollEnabled() correctly reflects toggle state; _updateSessionStartJumpButton function is exposed; offline-banner template + "Check now" button present in HTML. Opus advisor: SHIP-WITH-FIXES (one tracked race fast-follow + one i18n polish fast-follow, both non-blockers per Opus's own recommendation "Ship the batch").
Pre-release verification
- Full pytest under
HERMES_HOMEisolation: 4977 passed, 8 skipped, 1 xfailed, 2 xpassed, 8 subtests passed in 140.56s. - Browser API harness against stage-325 on port 8789: all 11 endpoints + 20 QA tests PASS (111.35s for QA phase).
- Manual browser verification: stage-325 server up on 8789, navigated to /, verified new toggles render in Settings panel, verified helper functions exposed correctly, verified offline-banner template loaded.
node -con all 6 modifiedstatic/*.jsfiles: clean.- Stage diff: 16 files, +649/-30.
- Opus advisor pass on stage-325 brief: VERDICT=SHIP-WITH-FIXES with explicit "Ship the batch" recommendation. Two fast-follows filed for tracking, neither is blocking.
- v0.51.29 carry-overs verified preserved (no in-batch changes to
_strip_workspace_prefix,evaluate_goal_after_turn,_profiles_match,mcp_server.py). - Pre-stamp re-fetch of all 3 PR heads: no contributor force-push during Opus window.
Follow-up items filed (non-blocking)
- Race between endless-scroll prefetch and Start-jump's
_ensureAllMessagesLoaded— with both opt-ins ON, an in-flight prefetch (started by 1.5x-viewport trigger) racing withjumpToSessionStart→_ensureAllMessagesLoadedcould produce duplicate messages if the prefetch resolves last. Narrow window, but the fix is to gate_ensureAllMessagesLoadedon the existing_loadingOlderflag. Resolved in Unreleased — see #1937 entry above; final fix uses generation-token + mutex rather than the originally-suggested flag gate, which would not have closed the race. - #1928 locale parity —
session_jump_*andsettings_*_session_jump_buttonskeys are English literals in ja/ru/es/de/zh/zh-Hant/pt/ko. Default-OFF + English fallback works, but breaks the locale-parity standard set by #1929 and #1891 in the same release.
Added (1 PR)
- PR #1919 by @franksong2702 — Persist login rate limit attempts (closes #1910). Stores failed-login buckets in
STATE_DIR/.login_attempts.jsoninstead of in-process memory, so password-auth deployments keep the same failed-attempt window across restarts. Atomic temp+rename writes,0600permissions, prunes expired entries on load. If the file is missing, malformed, or unwritable, the auth path falls back to current in-memory behavior with debug-level logging — no infinite-loop risk.
Fixed (5 PRs)
-
PR #1920 by @franksong2702 — Remove dead
kanban_card_starti18n key. PR #1886 removed the Kanban card-level Start action (directrunningtransitions are now owned by the dispatcher), but thekanban_card_startlocale key was left present in every locale block. Removed across all 9 locales and strengthened the Kanban static regression test so the dead key cannot be reintroduced. -
PR #1921 by @Michaelyklam — Production Docker image hardening (closes #1908). Removes passwordless sudo path, drops the
hermeswebuitoosudo-capable staging user, and reworksdocker_init.bashso privileged setup runs in an explicit root init block before re-execing as thehermeswebuiuser without sudo. Init scratch state now uses owner-only permissions (umask 0077,0700directory,0600files). Addeddocs/docker.mdwith production-image security model notes. A shell gained through the WebUI runtime no longer has a passwordless sudo path to root inside the production container. -
PR #1926 by @ai-ag2026 — Prevent chat scroll resets after final render. The final-render path could write/rebuild DOM, queue native scroll events, and then lose the explicit bottom pin before delayed layout growth settled. Separately, clicking the already-open session still ran the
loadSession()teardown/setup path. Fix: keep explicit bottom scroll pins stable acrossrenderMessages({preserveScroll: true})and late Markdown/layout growth, and make clicking the currently-active sidebar session a no-op beforeloadSession()mutates state. -
PR #1927 by @ai-ag2026 — Preserve viewport when loading older messages. Pre-fix, prepending older history could snap the viewport to the bottom or surface only a larger hidden-count marker. Fix: expand transcript render window before rendering newly fetched older messages, then anchor at the current viewport instead of snapping. Adds focused regression coverage for older-history viewport anchoring.
-
PR #1930 by @ai-ag2026 — Collapse stale compression sidebar segments. The sidebar collapse key treated any row whose
parent_session_idpointed at another visible row as a non-collapsible child/fork row — correct for subagent/fork sessions, but wrong for automatic compression continuations that already carry_lineage_root_id/lineage_root_idand should collapse by lineage even when stale optimistic parent segments are still locally visible. Fix: prefer explicit lineage metadata before the visible-parent guard.
Tests
4947 → 4960 collected, 4960 passing, 0 regressions (+13 net new). Full suite ~145s on Python 3.11 (HERMES_HOME isolated). JS syntax check (node -c) clean on static/i18n.js, static/sessions.js, static/ui.js. Browser API sanity harness (port 8789): all 11 endpoints + 20 QA tests PASS. Opus advisor pass: SHIP-READY (only flag was a #1919 CHANGELOG conflict already auto-resolved during stage rebase).
Pre-release verification
- Full pytest under
HERMES_HOMEisolation: 4960 passed, 11 skipped, 1 xfailed, 2 xpassed, 8 subtests passed in 145.24s. - Browser API harness against stage-324 on port 8789: all 11 endpoints + 20 QA tests PASS (110.90s for QA phase).
node -con all 3 modifiedstatic/*.jsfiles: clean.- Stage diff: 18 files, +588/-150.
- Opus advisor pass on stage-324 brief: VERDICT=SHIP-WITH-FIXES (single fix: #1919 CHANGELOG rebase — already auto-resolved during stage merge). Coexistence verified for #1926/#1927/#1930 sharing
static/sessions.js(different functions, scroll-pin and viewport-anchor cannot fight; lineage metadata degrades gracefully on legacy sessions). - v0.51.28 carry-overs verified preserved (no in-batch changes to
api/routes.py:_strip_workspace_prefix,api/streaming.py:evaluate_goal_after_turn,api/profiles.py:_profiles_match,tests/test_mcp_server.pymodule-restoration logic). - Pre-stamp re-fetch of all 6 PR heads: no contributor force-push during Opus window.
Added (2 PRs)
-
PR #1895 by @samuelgudi — MCP server Option A rewrite (#1616). Replaces the fragile MCP integration with a clean
mcp_server.py(567 LOC) implementing project CRUD, session listing, and session mutations (rename/move) over Hermes's HTTP API. Importsapi.models/api.profilescanonically rather than carrying duplicate slug-matching helpers. Relocates_profiles_matchfromapi/routes.pyintoapi/profiles.pyas the single source of truth (mcp_server.py and api/routes.py both now import the canonical helper — re-introducing a local copy in either module trips a parity test immediately). Adds env-aware WEBUI_URL (HERMES_WEBUI_HOST/HERMES_WEBUI_PORT). New behaviour:delete_projectREFUSES to touch session JSONs whenHERMES_WEBUI_PASSWORDis unset, returning{ok:true, unassigned_sessions:0, warning:"…"}instead — preventing data-loss when an MCP client tries to delete a project on an unauthenticated server. 53-test coverage intests/test_mcp_server.py(914 LOC) including HTTP wire-format integration tests, profile-scoped isolation, legacy untagged row visibility, and--profile fooCLI ordering regression. Closes #1616. -
PR #1866 by @Michaelyklam — WebUI
/goalcommand for goal-tracking with budget enforcement and continuation prompts. Newapi/goals.py(489 LOC) implements goal lifecycle (set / pause / resume / clear / status), per-profile SQLiteSessionDBcache, andevaluate_goal_after_turn()SSE hook that emitsgoalandgoal_continueevents fromapi/streaming.pyafter assistant turns. Wire-up:api/routes.pyadds/api/goalendpoint (POST set/pause/resume/clear, GET status) and_start_chat_stream_for_session()extraction so kickoff prompts can run through the canonical streaming path;static/commands.jsadds/goalautocomplete (cmdGoal handler) with i18n description;static/messages.jshandles new SSE event types with continuation-toast UI;static/i18n.jsadds 9 new strings across all locales. 4 documentation screenshots added underdocs/pr-media/{1866,1808}/. Closes #1808.
Mid-stage absorbed fixes (test isolation, per blocker investigation)
- #1857 polluter root-cause —
tests/test_issue1857_usage_overwrite.pywas usingmock.patch.dict(sys.modules, {...}), which DELETES any keys added during the patched scope on__exit__. That silently evicted lazily-imported pydantic submodules (e.g.pydantic.root_model), producingKeyError: 'pydantic.root_model'intest_mcp_server.pydownstream when the full pytest suite ran. Fixed by replacing with manual save/restore using a_MISSINGsentinel. - #1895 module-attribute restoration —
tests/test_mcp_server.pymutates module-level constants onapi.config/api.models/mcp_server(STATE_DIR,SESSION_DIR,PROJECTS_FILE, …) so the MCP server reads from a tmpdir. Without restoration, downstream tests (test_pytest_state_isolation,test_provider_quota_status,test_provider_management) read deleted tmpdirs fromapi.config.STATE_DIR. Fixed by snapshotting originals on first_reimport_mcp()call and restoring in_cleanup_state_dir(). - #1895
_profiles_matchparity test parent-attribute leak —test_profiles_match_single_source_of_truthpopsapi.routes/api.profilesfromsys.modulesand re-imports for the canonical-helper identity check. When restoringsys.modulesonly, fresh modules still leaked through becauseimport api.routes as rresolves viasys.modules['api'].routes(parent-package attribute), NOT viasys.modules['api.routes']directly. Fixed by ALSO restoring parent-package attributes — without this, sibling tests (test_plugins_panel,test_pr1350_sse_notify_correctness,test_version_badge) that patchapi.routes.jand call handlers viaimport api.routes as routeswould fail because the patch hits one module object and the handler reads from another.
Tests
4898 → 4947 collected, 4947 passing, 0 regressions (+49 net new). Full suite ~140s on Python 3.11 (HERMES_HOME isolated). JS syntax check (node -c) clean on static/commands.js, static/i18n.js, static/messages.js. Browser API sanity harness (port 8789): all 11 endpoints + 20 QA tests PASS. Opus advisor pass: SHIP-READY, no blockers (2 follow-up items filed: goal hook firing on unrelated turns; English-only runtime strings in goal UI).
Pre-release verification
- Full pytest under
HERMES_HOMEisolation: 4947 passed, 8 skipped, 1 xfailed, 2 xpassed, 8 subtests passed in 140.41s. - Browser API harness against stage-323 on port 8789: all 11 endpoints + 20 QA tests PASS (110.66s for QA phase).
node -con all 3 modifiedstatic/*.jsfiles: clean.- Stage diff: 16 files, +2692/-105.
- Opus advisor pass on stage-323 brief: VERDICT=SHIP-READY. No coexistence bugs between #1895 and #1866 (disjoint hunks in routes.py, SSE event names align,
_profiles_matchresolution unambiguous either way, no path collisions). - v0.51.27 fixes verified preserved:
_strip_workspace_prefix(callers at routes.py:1446/1485),on_interim_assistant(streaming.py:2120),_max_iterations_cfg(streaming.py:2331-2410),if input_tokens > 0:guard (streaming.py:2933). - Pre-stamp re-fetch of #1866 (sha
f2aacf4) + #1895 (sha766c91e): both MERGEABLE, no force-push during Opus window.
Follow-up items (filed for next sweep)
- Goal hook fires on unrelated turns — while a goal is
active, every completed assistant turn runsevaluate_goal_after_turnand ticksstate.turns_used += 1, even on user messages unrelated to the goal. UX surprise but not bug-broken; consider gating onuser_initiatedor a goal-context flag. - English-only runtime strings in goal UI —
messages.js:889("Evaluating goal progress…"),commands.js:651("Working toward goal…"),messages.js:914("Continuing toward goal…" toast); also backend strings ingoals.py(status_line, "⊙ Goal set …", "⏸ Goal paused …", "↻ Continuing …"). Thecmd_goalautocomplete description IS localized across all 9 locales — only the runtime status strings are missed.
Fixed (4 PRs)
-
PR #1916 by @Michaelyklam — Make Kanban detail view scrollable. The app shell sets
body { overflow: hidden }, so the Kanban main view must own vertical scrolling. Pre-fix, a selected task with a long body could push the board below the viewport with no way to reach it. Fix: addoverflow-y: autotomain.main.showing-kanban > #mainKanban(one CSS property + regression test). Closes #1915. -
PR #1914 by @ai-ag2026 — Keep streaming chat pinned after final render. During streaming, bottom-pinned scroll worked, but after the
doneevent late Markdown layout growth could unpin the viewport — the user would see the last token, then suddenly the chat would scroll up by hundreds of pixels as render reflowed. Fix: add explicit upward-intent gating (MESSAGE_UPWARD_INTENT_MS=450ms window for wheel/touch events) so passivescrollTopdecreases from windowing/reflow no longer count as user upward intent. Pre-replacementshouldFollowOnDonecapture instatic/messages.jscallsscrollToBottom()if pin or near-bottom (<=1200px) was true before render.scrollIfPinnedandscrollToBottomnow write_lastScrollTopand clear the programmatic flag in a rAF so the next listener pass doesn't see a synthetic upward delta. -
PR #1918 by @franksong2702 — Fix workspace prefix sentinel handling (closes #1913 follow-up filed in v0.51.25). The pre-fix strip regex
^\s*\[Workspace:[^\]]+\]\s*was too permissive — a user prompt starting with[Workspace: /path/to/explain]would be silently eaten, and workspace paths containing]would truncate at the first]. Fix introduces a versioned sentinel format[Workspace::v1: ...](double-colon distinguishes from natural English) AND escapes]in the path with\]. New helpers:_workspace_context_prefix(path),_escape_workspace_prefix_path(path), and_strip_workspace_prefix(text, *, include_legacy=False)with optional legacy fallback for transcript-compaction identity matching during the migration window. Closes #1913.Mid-stage absorbed fixes (per Opus advisor on stage-322):
- #1918 missed second injection site at
api/routes.py:6689(_handle_chat_sync, thePOST /api/chatsynchronous handler). Without this fix, the sync chat path would still inject legacy[Workspace: ...]while the streaming path injected[Workspace::v1: ...]— producing user bubbles that visibly leak the prefix on the sync surface, and a system-prompt format string that no longer matches reality. Maintainer routed the sync injection through_workspace_context_prefix(...)and updated the surrounding system-prompt text to v1 form, mirroring the streaming.py block. - #1918 backwards-compat gap in
static/ui.js:_stripWorkspaceDisplayPrefix— existing on-disk transcripts saved before the v1 migration still carry the legacy format. Without a JS legacy fallback, pre-upgrade sessions would render the literal[Workspace: /tmp/proj]prefix in user bubbles after upgrade. Maintainer added a legacy-regex fallback paralleling the Pythoninclude_legacy=Truebranch on the streaming side; updated the regression test that previously asserted the legacy regex was absent.
- #1918 missed second injection site at
-
PR #1814 by @hualong1009 — Custom named provider API key resolution. Adds new top-level helper
resolve_custom_provider_connection(provider_id) -> (api_key, base_url)that resolvescustom:*provider IDs to credentials fromconfig.yaml > custom_providers[]. Supportsapi_keyas literal value,${ENV_VAR}interpolation, orkey_envenv-var hint. Usesget_config()snapshot (per-profile aware). Fallback to single-entrycustom_providerswhen slug doesn't match exactly. Also adds fallback inapi/streaming.pyself-heal paths so an agent rebuild after a transient failure can re-fetch credentials. Deferral re-evaluated (per prior sweep notes): the priormaintainer-reviewflag noted feared overlap with #1818, but #1818 already shipped (v0.51.19) with its slug-matching helpers. Re-checking against current master post-#1818: the newresolve_custom_provider_connection()is purely additive (no helper duplication). Style observation (non-blocking): PR's local_slugifyhas slightly different normalization (_→-, collapse--, strip leading/trailing-) than master's canonical_custom_provider_slug_from_name. Internally self-consistent (both pid and entry name go through the same local slugify before comparison) so it works for matching, but a follow-up could unify the slug semantics. The 6-call-site fallback pattern (3 inapi/routes.py, 3 inapi/streaming.py) is also a candidate for a singleapply_custom_provider_fallback()helper.
Tests
4890 → 4898 collected, 4884 passing, 0 regressions (+8 net new). Full suite ~145s on Python 3.11 (HERMES_HOME isolated). JS syntax check (node -c) passes on static/messages.js and static/ui.js. Browser API sanity harness (port 8789) all-green: 11 endpoints + 20 QA tests verified. Opus advisor pass: 2 BLOCKERS identified and fixed in-stage (per absorb-in-release default), then SHIP.
Pre-release verification
- Full pytest under
HERMES_HOMEisolation: 4884 passed, 11 skipped, 1 xfailed, 2 xpassed, 8 subtests passed in 145.18s. - Browser API harness against stage-322 on port 8789: all 11 endpoints + 20 QA tests PASS.
node -constatic/messages.js,static/ui.js: clean.- Stage diff: 13 files, +348/-22 (pre-Opus-fix); 14 files, +382/-31 (post-Opus-fix incorporating the routes.py legacy-injection fix and ui.js legacy-fallback fix).
- Opus advisor pass on stage-322 brief: identified 2 BLOCKERS in PR #1918 (missed
routes.pyinjection site + missing JS legacy fallback). Both absorbed in-stage per absorb-in-release default. Test that asserted "legacy regex absent" updated to assert legacy regex IS present (mirrors Pythoninclude_legacy=Truebranch). - v0.51.26 fixes verified preserved across rebase:
_strip_workspace_prefix(10),on_interim_assistant(2),_max_iterations_cfg(9),if input_tokens > 0:(1),get_default_hermes_root(3),_sessionSegmentCount(9),_active_skills_dir(6). - Pre-stamp re-fetch of all 4 PR heads: no contributor force-pushes during the Opus window.
Opus-applied fixes (absorbed in-release)
From stage-322 absorption:
-
#1918 second injection site —
api/routes.py:_handle_chat_syncwas injecting legacy[Workspace: ...]and telling the agent that's the active format. Fixed: routed through_workspace_context_prefix(str(s.workspace)); updated surrounding system-prompt strings to reference[Workspace::v1: ...]consistently. -
#1918 JS legacy fallback —
static/ui.js:_stripWorkspaceDisplayPrefixwas changed to v1-only regex with no legacy fallback. Fixed: added fallthrough to legacy regex when v1 strip doesn't match, mirroring the Pythoninclude_legacy=Truebranch. Updated testtest_workspace_display_prefix_helper_strips_leading_metadata_onlyto assert the legacy regex IS present (was inverted to assert it was absent).
[v0.51.26] — 2026-05-08 — 5-PR follow-on contributor batch (Release D: profile-isolation hardening across cache + skills + gateway-health, context-length config-override threading, sidebar segment count UI polish)
Fixed (5 PRs + 1 absorbed test)
-
PR #1901 by @Michaelyklam — Use root-level Hermes home for gateway health status. Hermes gateway runtime state (
gateway.pid,gateway_state.json) is a root-level singleton shared across all profiles, but WebUI under a profile-scopedHERMES_HOMEwas looking inside the profile's home directory — always missing the canonical files. Fix: resolve gateway PID path throughget_default_hermes_root()(which correctly handles the<root>/profiles/<name>case by walking up to the un-profiled root). Standard~/.hermesand Docker/opt/datalayouts both work. Graceful degradation when bundled hermes-agent isn't available (try/exceptreturns None, falls through to pre-fixread_runtime_status()/get_running_pid()calls — preserves WebUI-only installs). Closes #1878. -
PR #1906 by @dso2ng (first-time contributor) — Sidebar UI polish: show collapsed session segment count. The sidebar already collapses continuation/compression lineage rows and carries
_lineage_collapsed_count/_lineage_segmentsmetadata. Backend can also expose_compression_segment_counteven when the full segment list isn't materialized client-side. Pre-fix the UI showed one compact row without making it clear that it represented multiple collapsed segments. Adds_sessionSegmentCount(s)helper picking the largest available count,i18nsession_meta_segmentskeys for 9 locales (en/es/de/zh/zh-Hant/ru/ja/pt/ko), and a threshold-of->1rendering check that suppresses single-segment cases. Empty-array case (Math.max(0, ...[])= 0) gracefully falls through to omitting the badge. -
PR #1903 by @Michaelyklam — Scope skills endpoints to active profile. The Skills tab was using Hermes Agent's startup-time
SKILLS_DIR, so switching browser profiles via thehermes_profilecookie did not change which local skills were listed or edited. Fix: resolveget_active_hermes_home() / "skills"at request time across list/content/save/delete endpoints (api/routes.py), without mutating process-wide state. Per-request resolution is microsecond-scale (TLS attribute lookup + path concat, no filesystem I/O). Net security improvement:_handle_skill_deletenow validatesskill_namefor/and..beforerglob. Closes #1880. -
PR #1898 by @nesquena-hermes (production fix) + functional test from PR #1904 by @Michaelyklam — Same-session profile switches were silently reusing the cached
AIAgentfrom the previous profile. The agent's_cached_system_prompt(built fromload_soul_md()at construction time) is sourced fromHERMES_HOME— so when a user switched personas mid-session, the second turn carried the first profile's SOUL.md and any other profile-scoped context. Reported by @AvidFuturist in Discord (May 8 2026): two custom personas, mid-session switch, second turn loaded the wrong identity. Fix: append_profile_home(already resolved at line 1958, well before the signature blob at line ~2399) to theSESSION_AGENT_CACHEsignature blob withor ''fallback for empty-HERMES_HOME deployments. Profile switches now produce a different signature, force a cache miss, and rebuild the agent under the new profile'sHERMES_HOME. Test absorption (Co-authored-by: Michael Lam): replaced #1898's source-string-only test with @Michaelyklam's superior functional regression from PR #1904 — creates two synthetic profile homes with distinctSOUL.mdcontents, runs_run_agent_streaming()three times on the same session (profile A, profile A, profile B), and assertsprompts_used_for_runs == [ALPHA, ALPHA, BETA]. Kept the source-string ordering checks (_profile_homeresolved before signature,or ''fallback) since the functional test alone wouldn't catch ordering regressions. Closes #1897. -
PR #1900 by @nesquena-hermes — The two
get_model_context_length()fallback callsites inapi/streaming.py(one for session persistence ~L2950, one for the SSE usage payload ~L3050) were calling the resolver with onlymodel + base_url, omittingconfig_context_length,provider, andcustom_providers. When the agent'scontext_compressorreports 0 (fresh / cached / transitioning agent), context-length resolution falls all the way through toDEFAULT_FALLBACK_CONTEXT = 256_000even when the user has setmodel.context_length: 1048576inconfig.yamlor has a 1M model with acustom_providersper-model override. For users with a context-management plugin, this cascades into a session-killing failure mode: auto-compression triggers far too early → flood of compress requests → 429s → credential pool exhaustion → fallback also 429s → "API call failed after 3 retries". Reported by @AvidFuturist in Discord with deepseek-v4-flash (1M context window). Reproduced 5×. Fix: threadconfig_context_length=_cfg_ctx_len(parsed from_cfg.get('model', {}).get('context_length')with safe int validation),provider=resolved_provider or '', andcustom_providers=_cfg_custom_providersthrough both fallback callsites. The bundled hermes-agent's resolver consults these in Step 0 ("Explicit config override — user knows best") before any probing, so a user-set context_length always wins over the 256K default. Both callsites wrapped intry/except TypeErrorfor back-compat with users who pin hermes-agent to a pre-kwargs version (dead-code-defensive in production deployments running the bundled agent — kept as a safety net for mismatched-version installs). Closes #1896.
Tests
4872 → 4890 collected, 4879 passing, 0 regressions (+18 net new). Full suite ~136s on Python 3.11. JS syntax check (node -c) passes on both modified .js files. Browser API sanity harness (port 8789) all-green: 11 endpoints + 20 QA tests verified. Opus advisor pass: SHIP with three release-note call-outs, none blocking.
Pre-release verification
- Full pytest under
HERMES_HOMEisolation: 4879 passed, 8 skipped, 1 xfailed, 2 xpassed, 8 subtests passed in 136.03s. - Browser API harness against stage-321 on port 8789: all 11 checks PASS + 20 QA security/regression tests PASS.
node -constatic/i18n.js,static/sessions.js: clean.- Stage diff: 13 files, +1220/-32.
- Opus advisor pass on stage-321 brief: SHIP. All 5 PRs verified correct with test coverage solid. Three call-outs incorporated above (#1901 helper name corrected, #1898+#1904 combo retained, #1900 legacy fallback documented).
- v0.51.25 fixes verified preserved across rebase:
_strip_workspace_prefix(×3),on_interim_assistant(×2),_max_iterations_cfg(×6),if input_tokens > 0:Opus defensive guard (×1). - Pre-stamp re-fetch of all 6 PR heads (incl. #1904 absorbed): no contributor force-pushes during the Opus window.
Notes for users
- #1900 mismatched-version safety net: WebUI v0.51.26 paired with a pre-kwargs hermes-agent (one that doesn't yet support
config_context_length/custom_providerskwargs onget_model_context_length()) will exercise the legacy 2-arg fallback. Users running the bundled agent take the new fast path and never touch the fallback. - #1905 closed as superseded — Michaelyklam filed a parallel-iteration take on #1896 with a slightly different shape (factored helper vs inline kwargs). Closed without merge per the same-author parallel-iteration pattern; #1900's review history was further along.
[v0.51.25] — 2026-05-08 — 6-PR streaming/runtime contributor batch (Release C: profile-isolated quota probes, request wedge diagnostics, max_turns config honor, per-turn usage overwrite, interim_assistant SSE wiring, workspace-prefix transcript dedup)
Fixed (6 PRs)
-
PR #1873 by @franksong2702 — Subprocess-based profile isolation for quota fetches. The original #1831 attempt added per-profile locks but CI exposed that approach as unsafe —
cron_profile_context_for_home()mutates process-globalos.environ['HERMES_HOME']and cron module globals. Per-profile locks would let different profile homes enter concurrently and one thread could observe another profile's home. This rework spawns subprocess workers (one per profile) that run quota probes in their own process with their own env vars, communicating results back via JSON over stdout. Eliminates the env-mutation race entirely. Closes #1831. Operational follow-up filed: worker-pool refactor +prctl(PR_SET_PDEATHSIG)+BoundedSemaphoreconcurrency cap before this hits busy multi-profile installs (current synchronous-spawn-per-probe is correct but inefficient under load). -
PR #1860 by @franksong2702 — Targeted slow-request diagnostics for the two #1855 paths (
POST /api/chat/start+GET /api/sessions). Adds a lightweightRequestDiagnosticswatchdog that only starts for those two paths. If a request is still running after the configured threshold, it logs a structured warning with request id, method, path, start time, elapsed time, current stage, accumulated stage timings, and Python thread stack snapshots. Completed requests that exceed the same threshold also log their stage timings (without thread stacks). Does NOT alter locking or request semantics — pure observability slice._diag_stage()is a no-op shim whendiag=None(the 99% path), so per-request overhead is near-zero. Refs #1855. -
PR #1877 by @Michaelyklam — Read
agent.max_turnsconfig when constructing WebUI streamingAIAgentinstances. Pass the parsed positive value asmax_iterationswhen the installed agent supports it ('max_iterations' in _agent_paramsgating, same pattern asmax_tokens/reasoning_config). Include the parsed budget in the per-session agent cache signature so budget changes rebuild cached agents instead of reusing stale instances. Closes #1876. -
PR #1861 by @franksong2702 — Session usage counters (
input_tokens,output_tokens,estimated_cost) were being accumulated on every completed turn. Because prompt tokens represent the full current context (which already contains all prior turns), accumulation double-counts and inflates long-session usage. Fix: store the most recent turn's values rather than the cumulative sum. Defensive in-stage absorption (per Opus advisor on stage-320): added> 0/is not Noneguards before overwritings.input_tokens/s.output_tokens/s.estimated_costso a rebuilt-from-cache-miss agent (post-restart, post-LRU-eviction) doesn't zero out persisted disk totals on its next turn. Closes #1857. -
PR #1865 by @franksong2702 — Wire runtime's
interim_assistant_callbackcontract through the WebUI SSE stream. Pre-fix, the runtime emitted user-visible interim assistant commentary (e.g. "I'll inspect the workspace files now.") via the callback contract on AIAgent, but WebUI's SSE stream had no event path for it and the messages were swallowed. Fix: forward the callback through toput('interim_assistant', {'text': visible, 'already_streamed': bool})SSE events; frontend renders them as separate-but-non-tool live segments. Thealready_streamedflag tells the renderer not to duplicate text already emitted viatokenevents (Codex-style backends). Single-purpose PR after the contributor split out earlier scope creep into separate PRs (#1869 / #1870 / #1871 / #1873). -
PR #1889 by @ai-ag2026 — WebUI sends model-facing
[Workspace: ...]prefix to user prompts; transcript compaction was treating the prefixed and unprefixed forms as different turns and creating adjacent duplicate user bubbles. Fix: strip workspace prefix during current-user identity matching so context-compaction merges don't duplicate. The visible bubble's display content gets cleaned of the prefix during compaction merge — a desirable side effect. Refs #1217. Follow-up filed: consider distinguishing-sentinel format ([Workspace::v1: ...]or nonce) so user-typed[Workspace: ...]text isn't silently eaten; also handle workspace paths containing]. Pre-existing behavior in master (api/streaming.py:1054already used the same regex), this PR extends the same convention.
Tests
4858 → 4872 collected, 4861 passing, 0 regressions (+14 net new). Full suite ~145s on Python 3.11. JS syntax check (node -c) passes on static/messages.js. Browser API sanity harness (port 8789) all-green: 11 endpoints verified. Opus advisor pass: SHIP with three Medium-severity follow-ups (one absorbed in-release, two filed for follow-up PRs).
Pre-release verification
- Full pytest under
HERMES_HOMEisolation: 4861 passed, 8 skipped, 1 xfailed, 2 xpassed, 8 subtests passed in 145.96s. - Browser API harness against stage-320 on port 8789: all 11 checks PASS.
node -constatic/messages.js: clean.- Stage diff: 13 files, +1216/-196 (heavy in tests).
- Opus advisor pass on stage-320 brief: SHIP with three Medium-severity concerns (one absorbed in-release: #1861 restart-zeros-totals defensive guard; two filed as follow-ups: #1873 worker-pool ops refactor, #1889 sentinel/nonce regex tightening).
- Pre-stamp re-fetch of all 6 PR heads: no contributor force-pushes during the Opus window.
Opus-applied fixes (absorbed in-release)
From stage-320 absorption (this release):
- #1861 restart-zeros-totals defensive guard. Opus identified that the new per-turn overwrite at
api/streaming.py:2925-2927would zero outs.input_tokens/s.output_tokens/s.estimated_coston the first turn after a WebUI restart or LRU cache eviction (the rebuilt agent'ssession_*running totals start at zero and would overwrite the persisted disk values). Added> 0/is not Noneguards before each overwrite. Test still passes; the guard preserves PR #1861's intended fix while preventing the restart-induced regression. <10 LOC, clearly defensive.
[v0.51.24] — 2026-05-08 — 5-PR contributor batch (Release B: local-server custom-provider model preservation, oversized upload preflight, ai-gateway phantom Custom group fix, Kanban lifecycle controls, cross-container gateway liveness)
Fixed (5 PRs)
-
PR #1862 by @franksong2702 — Recognize
custom:<local-server>provider ids as local model server providers (Ollama, LM Studio, vLLM, Tabby) and preserve full slashed model ids on non-loopback hosts. Pre-fix, slashed model ids from non-loopback Ollama instances were stripped because_is_local_server_provider()did not unwrapcustom:prefixes. Now: explicit set membership check across the standard local-server provider slugs (lmstudio,lm-studio,ollama,llamacpp,llama-cpp,vllm,tabby,tabbyapi,koboldcpp,textgen,localai). Note: renamed local-server providers (custom:my-vllm-prod) on non-private hostnames are still handled via the existing_base_url_points_at_local_server()LAN/loopback fallback; a follow-up could thread the configuredkind/providerfield for full coverage. Closes #1830. -
PR #1868 by @franksong2702 — Add browser-side upload size preflight check matching the server's 20 MB limit. Pre-fix, Firefox would attempt a 182 MB multipart upload and surface
NS_ERROR_NET_RESET/NetworkErrorto the user instead of the server's clean 413 JSON. Now:static/ui.jschecks file size before starting upload and surfaces a clear error message in the user's locale viastatic/i18n.js. Closes #1867. -
PR #1883 by @Sanjays2402 — Two cooperating bugs in
get_available_models()produced a phantom Custom group when the active provider was ai-gateway withcustom_providersdeclared inconfig.yaml. (1)custom:*PIDs not in_named_custom_groupswere dropped at the wrong stage, leaving entries that should have been pre-filtered to slip through. (2) The fallback Custom group was synthesized for any leftover entries, including auto-detected ai-gateway models that weren't supposed to be in the Custom group at all. Fix scopes both checks correctly. Cross-talk between fix paths verified to be impossible (the two fixes operate on disjoint PID shapes). Closes #1881. -
PR #1886 by @franksong2702 — Three Kanban UI lifecycle improvements: (1) remove Kanban card Start and bulk Running controls (PATCH-task-to-running was unsafe — bypassed dispatcher claim flow). (2) Rename dispatcher dry-run action from "Nudge dispatcher" to "Preview dispatcher" so the UI matches what
/api/kanban/dispatch?dry_run=1actually does. (3) Add empty-board guidance (kanban_work_queue_hint) framing the Kanban panel as the Hermes Agent work queue. Mid-stage maintainer notes: PR was based against pre-v0.51.23 master, so during stage rebase the maintainer (a) resolved the CHANGELOG.md conflict (accept master), (b) merged the Kanban i18n additions with #1863's Japanese refresh (Japanese hint translated; other locales fall back to English to match existing kanban_* fallback pattern), and (c) restored two silent reverts from #1886's stale-base diff: #1872'sstatic/index.htmlworkspace-heading change (no role=button/tabindex) and #1871'sstatic/panels.js:837_cronPreFormDetailreference. Both restorations verified by Opus advisor against post-merge master. Co-authored-by trailer preserves Frank Song's authorship. Closes #1885. -
PR #1887 by @Sanjays2402 — Cross-container gateway liveness via state-file freshness fallback.
gateway/status.py:get_running_pid()walks two PID-namespace-scoped checks (file lock viafcntl.flock(LOCK_EX | LOCK_NB)ongateway.lock, and/proc/<pid>access checks). Both fail across container boundaries — WebUI in container A can't see the gateway in container B even when both share a writable volume. Adds a state-file freshness fallback: if the canonical lock+pid checks fail but the gateway'sgateway.jsonwas updated within the last 120s (two cron ticks), treat the gateway as alive. Implementation note: parses the embeddedupdated_atISO-8601 string from inside the JSON content (more robust against NFS lazy mtime updates thanos.path.getmtime()). Tolerates clock skew up to 120s in the future, rejects naive timestamps, requiresgateway_state == "running"in the file (prevents trusting cleanup-skipped crashes). Closes #1879.
Tests
4830 → 4858 collected, 4847 passing, 0 regressions (+28 net new). Full suite ~143s on Python 3.11. JS syntax check (node -c) passes on all 3 modified .js files. Browser API sanity harness (port 8789) all-green: 11 endpoints verified. Opus advisor pass: SHIP with two follow-up flags, neither blocking.
Pre-release verification
- Full pytest under
HERMES_HOMEisolation: 4847 passed, 8 skipped, 1 xfailed, 2 xpassed, 8 subtests passed in 142.86s. - Browser API harness against stage-319 on port 8789: all 11 checks PASS.
node -constatic/i18n.js,static/panels.js,static/ui.js: clean.- Stage diff: 11 files, +849/-43.
- Opus advisor pass on stage-319 brief: SHIP with one minor follow-up (#1862 narrow gap on renamed local-server provider non-private hostnames). No MUST-FIX.
- Pre-stamp re-fetch of all 5 PR heads: no contributor force-pushes during the Opus window. Stage commits match contributor heads.
- Mid-stage edits applied (test failures from #1886's stale-base reverts of #1871 + #1872): both fix-restorations re-applied surgically, full pytest re-run clean post-fix.
[v0.51.23] — 2026-05-08 — 7-PR contributor batch (Release A: stale-cleanup pending-turn preservation, title refresh marker persistence, Japanese i18n refresh, Kanban predicate hardening, cron edit snapshot fix, workspace heading affordance polish)
Fixed (7 PRs)
-
PR #1856 by @ai-ag2026 — Materialize a pending user turn before stale stream cleanup clears runtime fields. Prior to this fix, when
_clear_stale_streams()ran while a session had a pending user turn (assistant hadn't started responding yet), the cleanup path cleared runtime fields including the pending turn's metadata — turn lost. Fix: materialize the pending turn into the saved transcript before the cleanup, preserving timestamp + attachments. Dedup via_materialize_pending_user_turn_before_error()scans the last 8 messages so retries can't produce duplicate-on-disk. New regression coverage intests/test_issue1361_cancel_data_loss.pyexercises the stale-cleanup pending-turn path, complementing the existing stream-error coverage. -
PR #1859 by @ai-ag2026 — Persist
llm_title_generatedmarker through Session load/save cycles._maybe_schedule_title_refresh()only refreshes sessions wheresession.llm_title_generated == True, but that flag wasn't being included into_dict/from_dictround-trip — so a WebUI restart silently lost it and the adaptive title refresh logic short-circuited indefinitely. Fix adds the field to the serialization round-trip. Migration note: sessions whose title was LLM-generated pre-fix may incur a one-time title regeneration on their next eligible turn (bounded bystill_auto— user-titled or already-good titles are preserved). Regression coverage intests/test_session_save_mode.pypins both the constructor and disk round-trip behavior. -
PR #1863 by @koshikai — Refresh the Japanese (
ja) locale bundle for keys that drifted out of date — onboarding connection probes, MCP-tools section, session_stop_response, and several other recently-added keys. Pure i18n string substitution instatic/i18n.js; no logic change. 108 lines added / 108 lines removed (balanced English→Japanese substitution). -
PR #1869 by @franksong2702 — Parametrize the Kanban double-404 regression test across HTTP methods (GET/POST/PATCH/DELETE) where prior coverage exercised only GET. Tests-only PR, defense-in-depth follow-up to PR #1843's double-404 guard fix. Closes #1845.
-
PR #1870 by @franksong2702 — Tighten the browser predicate that detects "stale Kanban client" via 404. Pre-fix, the predicate also accepted bare
not found404 messages, which would misclassify future genuine 404s as stale-client. Now requires the explicit Kanban-stale-client server message string. Backward-compat note: old browser tabs running against pre-#1828 servers no longer get the "Hard refresh now" hint for bare-404 cases — they'll see a normal-error path instead. Acceptable since WebUI server and client ship together. Closes #1839. -
PR #1871 by @franksong2702 — Fix
saveCronForm()to readno_agentfrom_cronPreFormDetail(the explicit edit source-of-truth captured at form-open) rather than_currentCronDetail. Two-character source change with matching regression coverage. Closes #1840. -
PR #1872 by @franksong2702 — Disable workspace heading affordance when the session has no registered workspace. Pre-fix, the heading still rendered as a button (cursor-pointer + hover state) even though click and context-menu actions couldn't do useful work. Now:
_syncWorkspaceHeadingState()toggles class + role/tabindex/title based onS.session.workspace; CSS scopes hover/focus to.workspace-panel-heading--enabled. Subtle a11y refinement: focus indicator now uses:focus-visibleso clicks no longer paint an outline but keyboard tabs still do. Closes #1842.
Tests
4817 → 4830 collected, 4819 passing, 0 regressions (+13 new). Full suite ~150s on Python 3.13 with HERMES_HOME isolated. JS syntax check (node -c) passes on all 3 modified .js files. Browser API sanity harness (port 8789) all-green: 11 endpoints verified (health, static assets, settings, session lifecycle, chat stream).
Pre-release verification
- Full pytest under HERMES_HOME isolation: 4819 passed, 8 skipped, 1 xfailed, 2 xpassed, 8 subtests passed in 150.85s.
- Browser API harness (
run-browser-tests.shagainst stage-318 on port 8789): all 11 checks PASS. node -constatic/i18n.js,static/panels.js,static/ui.js: clean.- Stage diff: 14 files, +251/-124 (production code 251 LOC + tests).
- Opus advisor pass on stage-318 brief: SHIP with two release-note items (incorporated above as "Migration note" on #1859 and "Backward-compat note" on #1870). No MUST-FIX. One non-blocking nit on #1869 (redundant GET/else branch in parametrized test) noted for follow-up.
- Pre-stamp re-fetch of all 7 PR heads: no contributor force-pushes during the Opus window. Stage commits match contributor heads.
[v0.51.22] — 2026-05-07 — 3-PR batch (P0 markdown streaming hotfix + CSP source-map allowance + LaTeX delimiter rendering)
Fixed (3 PRs)
-
PR #1851 by @ChaseFlorell — P0 hotfix: ES module import for
static/vendor/smd.min.jsused a bare specifier (import * as smd from 'static/vendor/smd.min.js') which the HTML spec rejects — relative ES module references must start with/,./, or../. Result: the entire<script type="module">block instatic/index.htmlfailed silently,window.smdwas never set, and live token-by-token markdown streaming was broken for all users since the streaming-markdown library landed. Fix: change'static/vendor/smd.min.js'→'/static/vendor/smd.min.js'. 1-LOC change. Browser-verified post-fix:typeof window.smd === 'object'with all expected exports (BLOCKQUOTE, CODE_FENCE, EQUATION_BLOCK, etc.). Closes #1849. -
PR #1852 by @ChaseFlorell — CSP
connect-src 'self'blocked DevTools-initiated fetches of source maps for the three xterm.js libraries (xterm@5.3.0, xterm-addon-fit@0.8.0, xterm-addon-web-links@0.9.0) loaded fromcdn.jsdelivr.net. The script tags loaded fine (covered byscript-src https://cdn.jsdelivr.net), but.js.mapfiles are fetched viaconnectand got blocked, emitting CSP violation errors in the console whenever DevTools was open. Fix: addhttps://cdn.jsdelivr.nettoconnect-srcinapi/helpers.py:_security_headers(), alongside the existing'self'. Consistent with the existing jsDelivr allowlist onscript-src/style-src/font-src. New regression testtest_issue1850_csp_connect_src_jsdelivr.pypins both the new entry and that'self'is preserved. Closes #1850. -
PR #1848 by @Michaelyklam — Backslash LaTeX delimiters (
\[...\]for display,\(...\)for inline) didn't render through the KaTeX pipeline. The renderer already supported$$...$$/$...$, but the prior regex for\\(...\\)/\\[...\\]required a double backslash, which is the JavaScript-source escape form, not the form LLMs actually emit in chat content. Result: multi-line display math from real assistant output appeared as raw\[ ... \]text with<br>line breaks instead of a centered KaTeX block. Fix instatic/ui.js: math-stash regex relaxed to single backslashes, and the user-bubble path (_renderUserFencedBlocks) gets its own pre-escape math stash so backslash delimiters surviveesc()instead of being HTML-escaped to\. Testtest_backslash_latex_delimiters_render_to_katex_placeholdersruns the assistant and user pipelines via Node and asserts no raw delimiter leakage in either rendered output. Closes #1847.
Maintainer-side absorption
-
tests/test_streaming_markdown.py+tests/test_subpath_frontend_routes.py— tightened the smd-import-shape assertions to require the./relative form and forbid BOTH bare specifier (broken by ES spec, #1849) AND root-absolute (breaks/hermes/subpath mounts). The original tests only forbade root-absolute, which let the bare-specifier regression land unnoticed in the first place. PR #1851's original fix used the root-absolute form (which would have re-broken subpath deployments); the corrected./form satisfies both constraints. Subpath safety verified:new URL('./static/vendor/smd.min.js', 'http://host/hermes/').href === 'http://host/hermes/static/vendor/smd.min.js'. -
static/ui.js+tests/test_issue347.py(commitd703959by @nesquena, opus-4.7-paired) — fix code-fence-vs-math stash ordering in_renderUserFencedBlocks. PR #1848 added a math stash to the user-bubble path so backslash LaTeX delimiters surviveesc()and reach KaTeX, but the math stash ran BEFORE the existing code-fence stash. Result: a user-typed code block containing LaTeX-like syntax (e.g.```\n\[ a + b \]\n```) had its math content extracted as KaTeX and rendered as a<div class="katex-block">placeholder INSIDE<pre><code>, replacing the user's literal source with rendered math. The assistant path (renderMd()) had the correct ordering already; the user-bubble path inherited the mistake from the inverted stash order. Fix reorders fences-first, then math, mirroringrenderMd(). Two regression tests added: one fails pre-fix and asserts no KaTeX wrappers inside<pre><code>, one is a sibling guard against an over-correction that would disable user-bubble math entirely. -
tests/test_issue1850_csp_connect_src_jsdelivr.py(absorbed from PR #1852 follow-up by @ChaseFlorell) — switched toPath(__file__).resolve().parents[1]rooting so the test survives being run from a non-repo-root cwd. Matches the pattern intest_issue1112_csp_google_fonts.py.
Tests
4810 → 4817 collected (+7). Three from #1848 augmenting test_issue347.py (Node-driven _run_renderers() harness for assistant + user pipelines), two new in test_issue1850_csp_connect_src_jsdelivr.py, two from the d703959 user-bubble code-fence-vs-math ordering fix.
Pre-release verification
pytest tests/— green- Live browser-verified at port 8789 against stage-316:
window.smdresolves to streaming-markdown module (PR #1851)Content-Security-Policy: ...connect-src 'self' https://cdn.jsdelivr.net...in served headers (PR #1852)renderMd()produces<div class="katex-block">for\[...\]and<span class="katex-inline">for\(...\)with no raw delimiter leakage (PR #1848)
[v0.51.21] — 2026-05-07 — 3-PR batch (P0 hotfix + auto-compression UI + shell route HTML fallback)
Fixed (3 PRs)
-
PR #1843 by @nesquena — P0 hotfix: Avoid double-404 response when Kanban bridge already sent error. Fixes a wire-protocol bug shipped in v0.51.20 #1828 where the new
_kanban_unknown_endpointwrapper double-sent a 404 response whenever the inner bridge handler returnedNone(which happens afterbad(...)calls). Result: concatenated JSON bodies on the wire like{"error":"task not found"}{"error":"unknown Kanban endpoint: GET ..."}. Affected everybad(...)-returning path in the bridge — task not-found, ImportError 503, LookupError 404, ValueError 400, RuntimeError 409, plus SSE board-resolution failures.Fix: in
handle_get/post/patch/delete(4 call sites), only call_kanban_unknown_endpointwhen the bridge returned an explicitFalse(truly unmatched).Nonemeans a response was already sent. New regression testtest_inner_handler_bad_response_does_not_emit_double_404monkey-patches_task_log_payloadto forcebad()and assertsbody.count("}{") == 0.api/routes.py +20/-12, 25 LOC test added. -
PR #1838 by @Michaelyklam — Show auto-compression running state (closes #1832). Bridges Hermes Agent's lifecycle compression status into a WebUI SSE
compressingevent so users see context auto-compression as actively running instead of silently waiting through the LLM summarization pause. Three layers:api/streaming.py +27— new_agent_status_callback(kind, message)closure converts agent lifecycle messages matching'preflight compression','compressing','compacting context', or'context too large'into aput('compressing', {session_id, message})SSE event. Wired through fresh-agent (_agent_kwargs['status_callback']) and cached-agent reuse (agent.status_callback = ...) paths, both gated on'status_callback' in _agent_paramsandhasattr(agent, 'status_callback')for backward compatibility with older agent builds.static/messages.js +18— newsource.addEventListener('compressing', ...)listener mirrors the existingcompressedlistener's session-active gate (returns early ifS.session.session_id !== activeSidAND ifd.session_id && d.session_id !== activeSid). CallssetCompressionUi({phase:'running', automatic:true, ...})when active.tests/test_auto_compression_card.py +50— three new source-regression tests pinning the listener block, the agent-side bridge predicates, and the listener ordering invariant (compressingmust precedecompressedso running phase transitions cleanly to done).
-
PR #1836 by @Michaelyklam — Keep shell route errors HTML (closes #1835). Defense-in-depth fix for restart/update race where the WebUI shell route
/,/index.html, or/session/...could bubble an exception out and render a JSON error page. PR wraps the shell-route block inapi/routes.py:handle_getwith a narrowtry/except Exception, and on failure calls a new_serve_shell_unavailable()that returns a minimaltext/html; charset=utf-8503 page withCache-Control: no-store. API routes still keep their normal JSON error behavior — only the shell-route block is wrapped.api/routes.py +34, 58 LOC test (test_home_route_internal_error_returns_html_503_not_jsonmonkey-patches_INDEX_HTML_PATHwith a broken read, asserts HTML 503 not JSON), 1 PR-media PNG.
Opus-applied fixes (absorbed in-release)
From stage-315 absorption pre-release Opus pass:
api/kanban_bridge.py— Documentedhandle_kanban_get/handle_kanban_post/handle_kanban_patch/handle_kanban_deletethree-valued return contract. After PR #1843 made theFalse-vs-Nonedistinction load-bearing for the caller's_kanban_unknown_endpointdecision, the four entry points still declared-> boolwhile actually returningTrue | None | False. Updated type annotations tobool | Noneand added a docstring onhandle_kanban_get(with cross-references on the three siblings) so a future contributor adding a new return path can't accidentally produce a0/''value that would silently revert the double-404 fix. Per Opus pre-release verdict; production behavior unchanged.
Tests
4805 → 4810 collected (+5). 4799 passed, 8 skipped (sprint3 prong-2 + QA gating + 2 dev-only spawn from v0.51.15), 1 xfailed, 2 xpassed, 0 failed in 148.5s. JS syntax check 1/1 modified file green (node -c static/messages.js). Browser API harness 11/11 endpoints green.
Pre-release verification
- All 3 PRs CI-green individually
- File overlap on
api/routes.pybetween #1843 (Kanban routes) and #1836 (shell route) resolved cleanly via stage-HEAD rebase — disjoint line ranges (~2629/3429/4607/4621 vs ~2496-2535) - Pre-stamp re-fetch: all 3 PR heads still match local rebases (no mid-sweep force-pushes)
- Opus advisor: SHIP verdict, 1 absorbed in-release (return-type annotation + docstring contract), 1 deferred to follow-up issue (parametrize PR #1843's regression test across GET/POST/PATCH/DELETE for defense-in-depth)
- No file deletions, no merge-conflict markers, no Python/JS syntax errors
Closes #1832, #1835. Hotfix for v0.51.20 #1828 wire-protocol regression.
[v0.51.20] — 2026-05-07 — 5-PR contributor follow-on batch (with parallel-discovery resolution)
Fixed (5 contributor PRs)
-
PR #1828 by @Michaelyklam — Surface stale Kanban client recovery (closes #1823). Three coupled fixes for the
Kanban unavailable: not foundfailure mode:- Server-side: explicit Kanban-namespace 404 handler for unknown
/api/kanban/*GET/POST/PATCH/DELETE endpoints (instead of falling through to bare "not found"), with a hint pointing at stale-cached-bundle as the likely cause. - Client-side: new
_kanbanLooksLikeStaleClientErrorpredicate +_kanbanUnavailableHtmlthat swaps the diagnostic for stale-client errors and surfaces aHard refresh nowbutton. The button calls newhardRefreshWebUIClient()whichunregister()s service workers, deletes every Cache-API entry, thenwindow.location.reload()s — gives Mac WKWebView users an in-app escape hatch that doesn't depend on Cmd+Shift+R or DevTools. - Board-pointer drift recovery:
loadKanbannowawaitsloadKanbanBoards()BEFORE board-scoped/api/kanban/configrequests;loadKanbanBoardsclears the saved slug todefaultwhen the saved slug doesn't match any current board;/api/kanban/boardsserver-side falls back to default if the on-disk current-board pointer references an archived/deleted board. api/kanban_bridge.py +12,api/routes.py +29,static/panels.js +47/-3. 92 LOC test coverage across 2 files (test_issue1823_kanban_not_found.py,test_kanban_bridge.py). 1 PR-media diagnostic screenshot.
- Server-side: explicit Kanban-namespace 404 handler for unknown
-
PR #1827 by @Michaelyklam — Sync Codex provider card models with picker (follow-up to v0.51.19 #1812). Replaces #1812's pure-live-fetch hook in
api/providers.pywith a richer live-plus-Codex-cache merge. The agent'sprovider_model_ids("openai-codex")filters IDs withsupported_in_api: false, but Codex CLI still surfaces some of those models in its picker — notablygpt-5.3-codex-spark(#1680). Merging the visible Codex local cache (via existing_read_visible_codex_cache_model_idshelper inapi/config.py) keeps the providers card in sync with what the picker actually shows. Uses the existing private helpers_read_live_provider_model_ids,_read_visible_codex_cache_model_ids,_models_from_live_provider_idsfromapi/config.py(already used by the picker path). 19 net LOC + 50 LOC test (test_provider_management.py::test_openai_codex_provider_card_prefers_live_catalog). -
PR #1826 by @Michaelyklam — Allow no-agent cron edits without prompt (closes #1820). Cron editor now distinguishes agent jobs from no-agent CLI
--no-agent --scriptjobs (which run scripts directly with no prompt). Plumbsno_agentandscriptfrom cron detail/edit data into_renderCronForm(). Detail view shows new Mode badge (no-agent/agent) + a "No-agent script" row. Edit form: prompt textarea isdisabled, removesrequiredattribute, showscron_no_agent_prompt_hintstyled hint listing the script path.saveCronForm()skips client-side prompt validation for no-agent edits and omitspromptfrom/api/crons/updatepayload.static/panels.js +84/-3, 71 LOC test (test_cron_no_agent_edit.py), 1 PR-media screenshot. -
PR #1825 by @ai-ag2026 — Hide workspace file tree cruft by default (closes #1793).
WORKSPACE_HIDDEN_FILE_NAMESset +WORKSPACE_HIDDEN_FILE_PREFIXESarray filter common cruft (.DS_Store,._*,Thumbs.db,Desktop.ini,$RECYCLE.BIN,.git,.svn,.hg,node_modules,__pycache__,.pytest_cache,.mypy_cache,.ruff_cache,.tox,.venv,venv,.Trash-*,.AppleDouble,.Spotlight-V100,.Trashes,.fseventsd,.directory). New_visibleWorkspaceEntries()filter applied inrenderFileTreeand_renderTreeItemsrecursive rendering. "Show hidden files" checkbox toggle in workspace panel header, persisted vialocalStorage['hermes-workspace-show-hidden-files']. Filter is purely client-side display — server-returned tree entries unchanged, toggling re-renders without re-fetching.static/i18n.js +9,static/index.html +4,static/style.css +3,static/ui.js +33, 31 LOC test. -
PR #1822 by @ai-ag2026 — Workspace heading root actions (closes #1786). The "Workspace" panel heading was a static label — the breadcrumb's
~already navigated to root, but the more prominent label didn't. PR makes the heading arole="button"withtabindex="0": click/Enter/Space →loadDir('.'), right-click → context menu with "Reveal in Finder" and "Copy file path" actions. Adds module-level helpers:bindWorkspaceHeadingActions,_workspaceContextMenuItem,_copyTextWithFallback(clipboard API with execCommand fallback),_showWorkspaceRootContextMenu.static/index.html +1/-1,static/style.css +2,static/ui.js +89, 23 LOC test. Sibling-rebased against #1825 in stage; ui.js conflict resolved by concatenating both additive blocks (verified withnode -c).
Opus-applied fixes (absorbed in-release)
From stage-314 absorption pre-release Opus pass:
static/panels.js— Removed duplicateawait loadKanbanBoards()tail call inloadKanban(). PR #1828 added a pre-fetch at the start ofloadKanbanto resolve the active board BEFORE board-scoped requests, but the existing tail-of-function refresh at line 1278 was kept too. Under SSE-driven refreshes (debounced at 250ms via_scheduleKanbanRefresh), this doubled/api/kanban/boardstraffic with no behavioral benefit — the 30-second polling interval started by_kanbanStartPolling()already picks up board-state changes that arrive after the render. Per Opus pre-release verdict.
From stage-314 pre-Opus pytest absorb:
tests/test_issue1807_codex_provider_card_live_models.py— AddedCODEX_HOMEisolation in_configure_codexhelper. v0.51.19's tests didn't isolate the Codex local model cache, but PR #1827's new_read_visible_codex_cache_model_ids()merging makes this load-bearing — without isolation, the dev machine's real~/.codex/models_cache.json(containinggpt-5.3-codex-sparkfrom #1680) leaks into test output. Test-only fix; production code unchanged. Caught by pre-release pytest gate.
Maintainer triage
- PR #1821 by @ai-ag2026 — Closed as parallel-discovery superseded by #1826. Both PRs filed within hours of each other (Michaelyklam predates by ~3 hours), both correctly diagnosed the bug. Same fix shape (form
requiredremoval + validation skip + payload omission), but #1826 covers more surface (Mode badge in detail view,disabledprompt instead of just optional, i18n hint key, screenshot). Closed with structured "superseded" comment crediting the convergent diagnosis — Co-authored-by trailer optional since the fixes are independent, but the convergence is acknowledged in the close comment.
Tests
4790 → 4805 collected (+15). 4794 passed, 8 skipped (sprint3 prong-2 + QA gating + 2 dev-only spawn from v0.51.15), 1 xfailed, 2 xpassed, 0 failed in 156.7s. JS syntax check 3/3 modified files green (node -c on i18n.js, panels.js, ui.js). Browser API harness 11/11 endpoints green.
Pre-release verification
- All 5 PRs CI-green individually
- File overlaps resolved via stage-HEAD rebasing for sibling PRs (#1822 + #1825 both touched
static/ui.jsafterrenderBreadcrumb()and adjacentindex.html/style.cssblocks; conflict inui.jsresolved by concatenation) - Pre-stamp re-fetch: all 5 PR heads still match local rebases (no mid-sweep force-pushes)
- Opus advisor: SHIP verdict, 1 absorbed in-release (loadKanbanBoards perf cleanup), 4 deferred to follow-up issues (lowercase 404 false-positive,
_currentCronDetailvs_cronPreFormDetailrobustness, #1825 i18n debt for 7 locales, #1822 heading no-op when no workspace) - No file deletions, no merge-conflict markers, no Python/JS syntax errors
Closes #1786, #1793, #1820 (via #1826, with #1821 closed as parallel-discovery superseded), #1823.
Note: #1827 is a follow-up enhancement to v0.51.19 #1812 (the original Closes #1807 reference is from when #1807 was still open; #1807 was closed by #1812 in v0.51.19, so this PR's release attribution is "follow-up enhancement to #1812" rather than "closes #1807").
[v0.51.19] — 2026-05-07 — 15-PR contributor sweep + 1 in-stage absorb
Fixed (15 contributor PRs)
-
PR #1798 by @Michaelyklam — Workspace path inaccessibility (closes #1795 P0/M1).
_clean_workspace_list()was destructive on macOS TCC denial —Path(...).resolve().is_dir()returnedFalsefor permission-denied directories, thenload_workspaces()re-persisted the cleaned list, silently deleting registered workspaces. Replaced predicate with non-destructive_safe_resolve()and added_workspace_access_error()branching onFileNotFoundError/PermissionError/OSError/S_ISDIRso error messages distinguish missing vs. inaccessible paths.api/workspace.py +49, 82 LOC test coverage including TCC simulation viaPath.statmonkeypatch. -
PR #1816 by @MacLeodMike — IPv6 bind address support.
ThreadingHTTPServerdefaultedaddress_family = socket.AF_INET, so binding to::or::1raisedEAFNOSUPPORT. NewQuietHTTPServer.__init__detects':'in host string and flipsaddress_family = socket.AF_INET6beforesuper().__init__(). Loopback warning gate adds::1to existing127.0.0.1check.server.py +7, 6 LOC. -
PR #1815 by @Saik0s —
bootstrap.pyvenv creation usessymlinks=True. CPython'svenv.EnvBuilderdefaultssymlinks=Falsefor shared-library Python builds (notably mise/asdf-installed CPython on macOS); the copiedpython3.Xbinary still references@executable_path/../lib/libpython3.X.dylibbut the dylib never gets copied into.venv/lib/, so the first import aborts with SIGABRT. Symlinking the interpreter keeps@executable_pathresolving back to the original install. Falls back to copy mode automatically on Windows withoutSeCreateSymbolicLinkPrivilege.bootstrap.py +9/-1, 1 LOC + 34 LOC test. -
PR #1817 by @Saik0s —
bootstrap.pydiscovers agent dir viahermesCLI shebang. Last-resort fallback after the hard-coded candidate list misses: readswhich("hermes")'s shebang, walks up the parents of the interpreter until it finds a directory containingrun_agent.py. Catches non-standard installs like~/Projects/GitHub/hermes-agentthat were previously rejected with the misleading "Python environment cannot import both WebUI dependencies and Hermes Agent" error.bootstrap.py +44, 106 LOC test. -
PR #1818 by @franksong2702 — Named custom provider routing (closes #1806).
model.provider: ollama-local(or any<custom_providers[].name>) now normalizes to the samecustom:<name>slug the model picker emits, BEFORE picker rendering or model resolution. Eliminates the duplicate-group bug where WebUI was building a stalecustom:local-(127.0.0.1:11434)group from agent-side base-url-derived data while a namedcustom_providers[]entry existed for the same endpoint. The stale slug routes to an unsettable env var name (CUSTOM:LOCAL-(127.0.0.1:11434)_API_KEY) — fixed by base-url-to-named-slug mapping that drops base-url-derivedcustom:*slugs when a named slug owns the same endpoint.api/config.py +151, 116 LOC test (test_issue1806_named_custom_provider_resolution.py). Three new helpers:_custom_provider_slug_from_name,_named_custom_provider_slug_for_provider,_resolve_configured_provider_id._normalize_base_url_for_matchhoisted from inner function to module scope for reuse by_named_custom_provider_slug_for_base_url. -
PR #1805 by @franksong2702 — Provider account quota cards. Extends
/api/provider/quotabeyond OpenRouter to OAuth-backed providers (openai-codex,anthropic)._fetch_account_usage_with_profile_contextenterscron_profile_context_for_home(home)soagent.account_usage.fetch_account_usage()reads the active WebUI profile'sHERMES_HOME(auth.json + .env) instead of the process-default~/.hermes. SerializesAccountUsageSnapshotto JSON withavailable/windows/details/plan/unavailable_reason.static/panels.jsadds_formatProviderQuotaWindowLabelmapping for codex window labels (Session→5-hour limit,Weekly→Weekly limit).api/providers.py +95,static/panels.js +55, 152 LOC test. -
PR #1812 by @franksong2702 — Live Codex models in provider card (closes #1807). The Codex card was building from
_PROVIDER_MODELS["openai-codex"](curated 7-entry static snapshot) which drifted behind whatever ChatGPT was serving for a given account. Now callshermes_cli.models.provider_model_ids("openai-codex")which does live OAuth → ChatGPT model catalog fetch, falls back to agent's hardcoded catalog → WebUI's_PROVIDER_MODELSonly on exception. Mirrors the existing Nous Portal pattern.api/providers.py +101/-0, 81 LOC test. -
PR #1797 by @Michaelyklam — Preserve first-turn sidebar row during refresh (closes #1792).
renderSessionList()was unconditionally clobbering_allSessions = sessData.sessions || [], so a server response that lagged behind a just-started first-turn session would overwrite the optimistic row inserted byupsertActiveSessionForLocalTurn(). Replaced with_mergeOptimisticFirstTurnSessions()gated on a focused_isOptimisticFirstTurnSessionRow()predicate (checksis_streaming/active_stream_id/pending_user_message/pending_started_at/_isSessionLocallyStreaming/_sessionStreamingById).static/sessions.js +65/-1, 17 LOC test. -
PR #1802 by @ai-ag2026 — Cross-surface session continuations stay visible. Backend marks
_cross_surface_child_sessionwhen a parent/child session pair comes from different surfaces (e.g. messaging parent → webui child after compaction). Frontend keeps marked rows as top-level sidebar entries instead of nesting them under the parent surface's row (where they'd be invisible). Same-surface child sessions still nest as before.api/agent_sessions.py +4,static/sessions.js +4, 92 LOC test across 2 files. -
PR #1819 by @dso2ng — Approval/clarify prompts session-owned (closes #1694).
static/messages.jsintroduces_approvalPendingBySession/_clarifyPendingBySessionMaps keyed bysession_id. New gate insideshowApprovalCard/showClarifyCard— caches but does NOT paint when_approvalPromptBelongsToActiveSession(sid)is false.loadSessioncalls_renderPendingPromptsForActiveSession()to render cached prompts when user switches back to the owner session. Polling-empty/SSE-empty branches route through_hideApprovalCardIfOwner(sid)so Sprint 30's 30-second visibility guard for the active pane is preserved while still clearing background-owner caches.static/messages.js +199/-30, 106 LOC test. -
PR #1813 by @ai-ag2026 — Hide workspace metadata in user bubbles. New
_stripWorkspaceDisplayPrefix()strips^\s*\[Workspace:[^\]]+\]\s*from user-bubble display ONLY (start-anchored, mid-text occurrences preserved).m.contentitself unchanged — search/export/history keep metadata.row.dataset.rawTextupdated to usedisplayContentso edit/copy round-trips from visible text.static/ui.js +45/-2, 39 LOC test. (Replaces #1810, which was based on a stale fork branch.) -
PR #1801 by @Michaelyklam — Error toasts copy-friendly (closes #1796).
showToast()switched fromms || 2800toms == nullso explicit0is honored. NewTOAST_ERROR_DEFAULT_MS=20000for type-aware default. Error toasts get inline Copy button (<button class="toast-copy">) — captured viadataset.toastMessageto avoid serializing the button label. Hover/focus pause viaonmouseenter/onmouseleave/onfocusin/onfocusouttoggling the dismiss timer.static/ui.js +47/-2,static/style.css +20, 38 LOC test + 3 PNG screenshots. -
PR #1803 by @franksong2702 — File picker + HTML preview interactions (closes #1800). Three coupled fixes:
static/index.html+static/style.cssmake file input visually-hidden via positionedposition:absolute;left:-9999px;width:1px;height:1px;opacity:0instead ofdisplay:none(some browser shells suppress click ondisplay:noneinputs).static/boot.jsbtnAttachswitched to non-submit handler withe.preventDefault()+ value reset.api/routes.pyHTML media path addsContent-Security-Policy: sandbox allow-scriptsheader only when?inline=1, otherwise serves withContent-Disposition: attachment+X-Frame-Options: DENY.static/ui.jsbuilds inline open URL with?inline=1for HTML attachment badges.api/routes.py +21,static/{boot,index,ui}.{js,html}+style.css~25 LOC, 116 LOC test (test_issue1800 + test_media_inline extension).
-
PR #1809 by @ai-ag2026 — Dedupe workspace-prefixed user turns after compaction. Adds
_strip_workspace_prefix()inapi/streaming.pyand uses it for identity/key comparison in_merge_display_messages_after_agent_result. Compaction returning a[Workspace: …]\n…user turn no longer creates a duplicate visible user bubble alongside the prior optimistic visible turn. Stores the visible user prompt in the display transcript when a model result returns the current user turn with workspace metadata.api/streaming.py +29/-2, 47 LOC test. -
PR #1811 by @ai-ag2026 — Workspace user turn repair script. New standalone
scripts/repair_workspace_user_turns.pyfor historical transcript hygiene. Cleans[Workspace: …]prefixes from sidecar JSON + optionally SQLitestate.db. Strips prefixes, removes adjacent duplicate user turns after normalization, backs up mutated files, refreshes message/tool counts. NOT auto-run on startup — manual operator-invoked migration utility.scripts/repair_workspace_user_turns.py +187(new file), 91 LOC test.
Opus-applied fixes (absorbed in-release)
From stage-313 absorption pre-release Opus pass: none. Opus verdict was clean SHIP after the two pre-Opus pytest-driven absorbs below.
From stage-313 pre-Opus pytest absorb:
-
api/config.py— Addedresolve_alias=Falseflag to_resolve_configured_provider_id(). PR #1818's swap from_resolve_provider_alias()to_resolve_configured_provider_id()was correct for active-provider/badge surfaces but broke #1625's local-server-provider literal-preservation contract. Specifically,'ollama' → 'custom'aliasing caused_LOCAL_SERVER_PROVIDERSmembership check to miss inresolve_model_provider(), breaking the full-model-id-preservation branch for LM Studio/Ollama (which require the unstrippedqwen/qwen3.6-27bform). The new flag preserves the raw provider value when called fromresolve_model_provider, while named-custom-slug + base-url fallback both still run unchanged. All other callers (badge surfaces, auth-store fallback, configured-provider hint resolution) keepresolve_alias=True. Caught by pre-release pytest gate. -
tests/test_bootstrap_discover_agent.py—_isolate_discover_agent_dir()helper now pinsPath.home()viamonkeypatch.setattr(bootstrap.Path, "home", classmethod(lambda cls: tmp_path / "isolated-home")). Original PR #1817 helper clearedHERMES_HOME+HERMES_WEBUI_AGENT_DIRand pinnedREPO_ROOT, but didn't isolate the hard-codedPath.home() / ".hermes" / "hermes-agent"andPath.home() / "hermes-agent"candidates indiscover_agent_dir()— so the dev's real install at~/.hermes/hermes-agentmatched first and tests failed. Test-only fix; production code unchanged. Caught by pre-release pytest gate.
Maintainer triage
- PR #1814 by @hualong1009 — Marked
maintainer-review. Targets the same #1806 root cause as #1818 but operates at the runtime layer (call-site fallbacks inapi/routes.py/api/streaming.py) rather than the config layer. Complementary in principle; held because the PR ships 96 LOC of branchy resolution logic with zero unit tests and includes a slug-normalization helper that duplicates #1818's_custom_provider_slug_from_name. Posted structured comment with three actionable asks (add tests, dedup with #1818's helpers post-merge, extract the 4× duplicated call-site fallback block into a helper). Author can revise on top of v0.51.19 once #1818 has shipped.
Tests
4747 → 4790 collected (+43). 4776 passed, 11 skipped (test-isolation prong-2 + QA gating + dev-only spawn), 1 xfailed, 2 xpassed, 0 failed in 145.9s. JS syntax check 5/5 modified files green (node -c). Browser API harness 11/11 endpoints green.
Pre-release verification
- All 15 PRs CI-green individually
- File overlaps resolved via stage-HEAD rebasing for sibling PRs (sessions.js: 1797/1802/1819; ui.js: 1801/1803/1813; api/providers.py: 1805/1812; bootstrap.py: 1815/1817; CHANGELOG.md stripped from contributor branches before merge)
- Pre-stamp re-fetch: all 15 PR heads still match local rebases (no mid-sweep force-pushes)
- Opus advisor: SHIP verdict, 0 MUST-FIX, 0 SHOULD-FIX in-release. Two narrow follow-ups filed as new issues (named-custom-collides-with-local-provider edge case,
_cron_env_lockprocess-wide serialization). - No file deletions, no merge-conflict markers, no Python/JS syntax errors
Closes #1792, #1795, #1796, #1800, #1806, #1807, #1694.
[v0.51.18] — 2026-05-07 — 5-PR batch (4 contributor + 1 self-built UX polish)
Fixed
-
PR #1783 by @Sanjays2402 — Custom provider +
:free/:beta/:thinkingsuffix mis-resolution. Closes #1776 (the follow-up I filed during the v0.51.15 sweep against PR #1762).api/config.py +13extendsresolve_model_provider()'s rsplit-fallback so@custom:my-key:some-model:freecorrectly resolves toprovider=custom:my-key, model=some-model:free(was previously dropping the suffix). 57 LOC test coverage intests/test_resolve_model_provider_free_suffix.py. Opus verified: non-custom path (@openrouter:tencent/hy3-preview:free) preserved unchanged;@custom:my-key:some-model(no suffix) backward-compatible; no recursion risk. -
PR #1791 by @Michaelyklam — Keep assistant-only stream deltas on the current turn (closes #1787). When an SSE stream produces only assistant content (no user-turn material),
api/streaming.py +27no longer promotes it to a new turn — appends to current. Tool-call responses (role in ('assistant','tool')) correctly trigger user-turn materialization. Pure display-merge logic with no INFLIGHT mutation. 27 LOC test coverage. Includes screenshot of correct transcript order. -
PR #1790 by @Michaelyklam — Keep workspace open from preview breadcrumb (closes #1785).
static/boot.js +6/-1(panel-state preservation via newclearPreview({keepPanelOpen:true})) +static/workspace.js +8/-7(breadcrumb-click handler delegates instead of duplicating mode logic). Compact-viewport routing through existingopenWorkspacePanel('browse')path preserved. No conflict with PR #1758's composer chip lightbox (different code path). 59 LOC test coverage with 2 screenshots. -
PR #1789 by @Michaelyklam — Preserve sidebar scrolling while streaming (closes #1784).
static/style.css +2/-1+static/ui.js +20. Adds{capture:true, passive:true}scroll listeners (non-blocking) that detect non-message scroll intent within a 350ms window usingperformance.now()(monotonic), then suppressesscrollIfPinned()auto-scroll-to-bottom during that window. Auto-scroll still works at-bottom + new message when no recent sidebar gesture. 47 LOC test coverage + screenshot + QA JSON.
Added (UX polish)
-
PR #1794 by @nesquena-hermes — Self-built UX bundle following up on the v0.51.17 tooltip system. APPROVED by @nesquena at exact head SHA
f2d5e9bd. Four fixes:- Rail tooltip cascade fix: removed
.rail .nav-tab:hover::after { content:none }(specificity 0,3,1) which was preventing.has-tooltip:hover::afterfrom firing on rail buttons. Legacydata-labelrule correctly scoped to.sidebar-nav .nav-tabso rail buttons (nodata-label) don't get an empty styled box. - +New-conversation button clipping: introduces new
.has-tooltip--bottom-rightvariant (left:auto; right:0; transform:none) for the#btnNewChatbutton which sits at the right edge of the sidebar header. Tooltip flips to align with the right edge of the trigger instead of extending past the viewport. - Context-menu hover affordance: adds visible
var(--hover-bg)background on.workspace-context-menu li:hover(typo fix fromvar(--hover)which was undefined → no visual feedback). - Rename pre-fill: rename modal now calls
setSelectionRange(0, dot)to pre-select the basename portion of a filename (everything before the last.), so users can immediately type the new name without manually clearing the extension.
static/index.html +1(single attribute swap on#btnNewChatfromhas-tooltip--bottomtohas-tooltip--bottom-right),static/sessions.js +4,static/style.css +26,static/ui.js +69. 168 LOC oftests/test_css_tooltips.pyextensions (regex-vs-source, consistent with existing pattern) + 263 LOC of newtests/test_workspace_context_menu_and_rename.py. - Rail tooltip cascade fix: removed
Tests
4723 → 4747 collected (+24). 4733 passed, 11 skipped (2 dev-only spawn from v0.51.15 + 9 prong-2/QA gating), 3 xpassed, 0 failed in 149s.
Pre-release verification
- All 5 PRs CI-green individually
- File overlaps:
static/style.cssandstatic/ui.js(#1789 + #1794) — different rules/functions, auto-merged cleanly - All JS/Python files syntax-clean
- Browser API sanity (11/11 endpoints): all pass
- Pre-stamp re-fetch: all 5 PR heads still match local rebases
- Opus advisor: SHIP all 5, 0 MUST-FIX, 1 informational SHOULD-NOTE (test pattern divergence — acceptable, matches existing style)
Closes #1776, #1784, #1785, #1787.
[v0.51.17] — 2026-05-07 — 2-PR contributor batch (kanban early-out + tooltip system overhaul)
Fixed
-
PR #1780 by @jasonjcwu — Two small kanban-bridge fixes found while auditing the bridge. (1) Stale module docstring still said "deliberately read-only" — updated to reflect the bridge's now-full CRUD surface (create/patch/bulk-update/archive, multi-board, task links, SSE, comments, dispatch). (2)
_board_counts_for_slug()now does an earlykb.board_exists(slug)check before attemptingkb.connect(), returning an empty dict for boards whose sqlite hasn't been materialized yet (freshly-created boards with no tasks). Avoids an unnecessary connect attempt on the hot board-list path.api/kanban_bridge.py +9/-5,tests/test_kanban_bridge.py +29/-30(addedtest_board_counts_returns_empty_for_nonexistent_board+test_board_counts_returns_real_counts_for_populated_board, replacing the old init_db approach with the cleaner board_exists pattern). -
PR #1782 by @jasonjcwu — Replace native
title=""tooltips with custom CSS tooltips on navigation surfaces (closes #1775; reported by @cygnusignis on the WebUI Discord testers thread: "It would be great to have tooltips for icons in the left ribbon — Edit: Oh wait, they are there. They just take an oddly long time to appear?"). The native browser tooltip's ~1.5s hover delay reads as "no tooltip exists" for a chunk of users. Custom CSS tooltips appear at ~150ms instead. Substantial maintainer-side polish layered on top of the contributor PR during stage prep, addressing issues found via browser-based verification:- Core fix the original PR missed:
static/i18n.jswas settingel.title = valeven when the element hasdata-tooltip, so the slow native tooltip co-fired alongside the fast custom CSS tooltip. Fixed by branching: whendata-tooltipis present, syncdata-tooltipANDremoveAttribute('title'). Same pattern applied to_applyDashboardStatusinstatic/ui.js(was hardcodingbtn.title=warning) and 6 callsites instatic/boot.jsrefactored through a new_setButtonTooltip()helper. Browser-verified: 0 of 73 has-tooltip elements have a stucktitleattribute at runtime (was 94 native + 2 stuck via the dashboard-status JS path before the fix). - CSS rewrite: solid
var(--surface)background (#1A1A2E), gold-tintedvar(--accent-bg-strong)border (subtle brand tie-in), warm-whitevar(--text)foreground, z-index 1500 (was 60 — clears all sidebar/panel stacking contexts), 8px/24px shadow with 0.65 alpha + 1px ring at 0.35 alpha + 1px inner highlight at 0.04 alpha (was 2px/8px / 0.25 alpha — too subtle), 150ms hover-onset / 0ms dismissal delay matching Cygnus's spec in #1775. - Arrow removed entirely: at 5px borders the triangle was too small to read clearly and was rendering as a thin rectangle (the global
box-sizing: border-boxreset made the colored border eat inward from a 10×10 box rather than projecting outward from a 0×0 box). VS Code, Slack, and Linear's rail-icon tooltips also skip arrows — spatial proximity at 8px gap is sufficient association. - Coverage extended to 11 more high-traffic icon buttons:
btnAttach,btnMic,btnVoiceMode(composer icons, side-positioned),btnSend(composer right edge, see--leftvariant below),btnCollapseWorkspacePanel,btnUpDir,btnNewFile,btnNewFolder,btnRefreshPanel,btnClearPreview(workspace panel header, bottom-positioned). Final coverage: 73 elements (rail 12 + sidebar nav-tabs 12 + panel-head 31 + composer/workspace icons 11 + hamburger 1 + dashboard rail 1 + dashboard mobile 1 + breakdown elsewhere ≈ 4). - Container-overflow escape:
.panel-headerwas changed fromoverflow:hiddentooverflow:visibleso workspace-panel-header tooltips can escape the bar (otherwiseNew file,New folder,Refresh, etc. tooltips were getting clipped at the panel-header boundary). The title-text ellipsis is preserved because the inner span.panel-header > span:first-childalready owns its ownoverflow:hidden + text-overflow:ellipsisfor the workspace-name truncation. - Right-edge clipping fix:
btnSend's side-positioned tooltip extended past the viewport edge in narrow viewports ("Se..." visible in maintainer screenshot review). Added new.has-tooltip--leftvariant that flips the tooltip to the LEFT of the trigger viaright: calc(100% + 8px). Applied tobtnSend. Coordinate-math audit at 1280px viewport: all 15 side-positioned tooltips fit within viewport, no clipping. - Removed
btnWorkspacePanelTogglefrom custom tooltip system: the chip'scomposer-workspace-group { overflow: hidden }is required forborder-radius:999pxrounded-pill clipping. Per user feedback ("don't add tooltips when something already has a visible label or it's super obvious what it is"), reverted to nativetitle=since the adjacent.composer-workspace-chiplabel already shows the current workspace path. - 5 pre-existing tests updated to be tolerant of either
title=ordata-tooltip=:tests/test_cron_refresh_button_835.py::test_refresh_button_has_accessibility_labels,tests/test_mobile_layout.py::test_profiles_sidebar_tab_present,tests/test_sprint20.py::test_mic_button_has_mic_btn_class,tests/test_sprint20b.py::test_send_button_has_title_attribute,tests/test_sprint20b.py::test_send_button_still_has_send_btn_class. Onetest_workspace_panel_session_list.pytest updated to recognize thatpanel-headeroverflow handling moved to its inner span. - 3 new regression tests in
tests/test_css_tooltips.py:test_native_title_cleared_when_custom_tooltip_present(pins theremoveAttribute('title')call),test_native_title_path_preserved_for_non_tooltip_elements(pins theel.titlefallback for elements withoutdata-tooltip), plus the original 17 still pass for a total of 19.
Browser-verified each major surface (rail Tasks, rail Settings, composer Attach files, composer Send message [via
--leftvariant], workspace panel New folder). 5 polish iterations + screenshot review with maintainer. - Core fix the original PR missed:
Tests
4716 → 4723 collected (+7). 4716 passed, 4 skipped (2 dev-only spawn from v0.51.15 + 2 prong-2 noise), 3 xpassed, 0 failed in 141s.
Pre-release verification
- All 2 PRs CI-green (PR #1780) / pending-with-fixes-in-stage (PR #1782 — original PR head failed CI on the test-update misses, all addressed in stage-311's maintainer-side polish layer).
- File overlap: NONE — disjoint files between #1780 (
api/kanban_bridge.py) and #1782 (frontend tooltip system). - All JS/Python files syntax-clean.
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789.- Pre-stamp re-fetch: both PR heads still match local rebases.
- Coordinate-math audit: all 15 side-positioned tooltips fit within 1280px viewport (rail Chat/Tasks/Kanban/Skills/Memory/Spaces/Profiles/Todos/Insights/Logs/Settings + composer Attach files/Dictate + workspace toggle + send-message left-flip).
- Browser-verified: zero stuck
titleattributes on has-tooltip elements at runtime. - Opus advisor reviewed PR head + brief; called out (1) CI failures on un-updated tests and (2) i18n.js title leak — BOTH fixed in stage-311's maintainer-side polish layer that Opus couldn't see (it reviews the contributor PR head, not the stage). Verified via
git log+grepthat all polish commits are instage-311before push.
Closes #1775.
[v0.51.16] — 2026-05-07 — 3-PR contributor batch (anthropic env race close, CLI tool metadata, model picker reset)
Fixed
- PR #1768 by @franksong2702 — Serialize Anthropic env fallback reads (closes #1736, the architectural follow-up filed in v0.51.8 sweep). Wraps
_clear_anthropic_env_values()and the runtime-provider resolver behind_ENV_LOCK(the samethreading.Lockalready serializing env save/restore instreaming.py). New helperresolve_runtime_provider_with_anthropic_env_lock()inapi/oauth.pyis called from 3 sites inapi/routes.pyand 2 inapi/streaming.py. Opus stage-310 verified: same-lock not a new lock (no ordering risk), nested acquires are sequential not nested (no deadlock), the lock is released before the agent runs (chat throughput unaffected).api/oauth.py +36,api/routes.py +18,api/streaming.py +16, +52 LOC test coverage intests/test_issue1362_codex_oauth_onboarding.py. Race window in_clear_anthropic_env_valuesnow closed for the chat hot path; remaining detector-style polls inapi/config.pyare UI-only and never bypass real credentials. - PR #1778 by @Michaelyklam — Preserve CLI session tool metadata (closes #1772). The server's CLI session loader was reading only
role,content,timestampfromstate.db.messages, missing tool_calls/tool_results columns.api/models.py +54extends the loader to read those columns plusreasoning_details,codex_reasoning_items,codex_message_items,reasoning_content,reasoningand rehydrate them onto the message dicts.PRAGMA table_info(messages)check ensures legacy state.db schemas without the columns don't error._is_cli_tool_metadata_enrichment()correctly rebuilds sidecars when message count is identical but new metadata is present, and usessave(touch_updated_at=False)to avoid bumping updated_at on passive enrichment.api/routes.py +66, 152 LOC test coverage intests/test_cli_session_tool_metadata.pyplus captured API evidence atdocs/pr-media/1772/cli-tool-metadata-api-evidence.json. - PR #1779 by @Michaelyklam — Reset model picker on session switch (closes #1771). Bug: switching sessions silently kept the previous chat's model selected in the composer (could route an inexpensive chat to an expensive model unnoticed — high-impact for users on premium-credit OAuth providers). Fix in
static/ui.js +88/-29: when session model metadata is missing,unknown, or stale, fall back to configured default model/provider, with first-available dropdown option only as last resort. Auto-fix applied at stage: Opus stage-310 caught a regression in the new!hasSessionModelbranch — it dropped thedeferModelCorrectionguard that the parallel else-branch keeps. Without the guard, every fast-path session view of an empty/unknown-model session fired a spurious/api/session/updatePOST that raced_resolveSessionModelForDisplaySoonand silently wrote to imported/read-only CLI sessions whose model field reads"unknown"(#1778 introduces exactly that surface in this same release). Wrapped the new branch's_persistSessionModelCorrectioncall + state mutation inif(!deferModelCorrection)mirroring the else-branch. Addedtest_sync_topbar_does_not_persist_correction_while_model_resolution_deferredregression test that exercises the fast-path interaction with_modelResolutionDeferred=truefor both empty and"unknown"model values; asserts the visiblesel.valuestill updates for UX but no POST is issued and no state mutation occurs. 192 LOC of original regression coverage intests/test_issue1771_session_model_switch_sync.py(now 215 LOC with the new test), 7 LOC tweak totest_provider_mismatch.pyand 1 LOC totest_session_metadata_fast_path.pyto align existing tests with the new fallback helper.
Tests
4694 → 4702 collected (+8 across 2 new test files plus 1 stage auto-fix regression test). 4695 passed, 4 skipped (2 dev-only spawn from v0.51.15 + 2 prong-2 noise), 3 xpassed, 0 failed in 141.29s.
Pre-release verification
- All 3 PRs CI-green individually.
- File overlap on
api/routes.py(#1768 + #1778) auto-merged cleanly (different functions: oauth env-lock helpers vs CLI session loader extension). node -cclean onstatic/ui.js; Python compile clean on all 6 changed .py files.- pytest: 4695 passed, 0 failed.
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789.- Pre-stamp re-fetch: all 3 PR heads still match local rebases.
- Opus advisor: SHIP #1768 + #1778, #1779 SHOULD-FIX before merge — auto-fix applied at stage with regression test, re-verified clean.
Closes #1736, #1771, #1772.
[v0.51.15] — 2026-05-07 — 4-PR contributor batch + 1 self-built (cron spawn migration, context menu, codex quota, model prefix)
Fixed
- PR #1767 by @Michaelyklam — Use
spawnfor manual cron subprocesses (closes #1754, the architectural follow-up filed in v0.51.12). One-line context changemultiprocessing.get_context("fork")→"spawn"atapi/routes.py:367plus +207 LOC of regression coverage intests/test_issue1574_cron_profile_lock.py. Validates: (a) source-level pin that the helper uses spawn, (b) end-to-end harness showingforkdeadlocks on a parent-thread-held lock whilespawnsucceeds, (c) drain-large-result regression preserved, (d) executes-under-selected-profile-home regression preserved. Auto-fix applied at stage: 2 of the 5 tests fail on dev machines with an editablehermes_agentinstall (the spawn child resolves the realcron.schedulerfirst instead of the fake one written underHERMES_WEBUI_AGENT_DIR). Added_real_hermes_agent_editable_install_present()detector usingimportlib.util.find_specorigin check +pytest.skipguard. Tests skip on dev (where they cannot work as designed) and run cleanly on CI (where no editable install exists). Closes the fork-from-multi-threaded-WebUI hazard class noted in #1754: import-lock and logging-lock inheritance no longer apply, since spawn starts a fresh interpreter. - PR #1770 by @Michaelyklam — Surface Codex usage exhaustion errors (closes #1765). New
quota_exhaustedSSE event for Codex 429/quota responses replaces the previous behavior (empty turn with no inline error) with a clear inline error card._classify_provider_error()distinguishes quota-exhaustion (requires re-auth) from transient rate-limit (just needs to wait) — Opus stage-309 verified the classifier order (quota check first, rate-limit isnot _is_quota AND ...) preserves the distinction. Detection covers Codex OAuth shapes: "plan limit reached", "usage_limit_exceeded", "reached the limit of messages", "used up your usage", plus the multi-token fallback. Both error paths properly clean up runtime state (INFLIGHT, approval/clarify pollers viafinallyblock) and run_materialize_pending_user_turn_before_error()beforepending_user_message = Noneclearing — preserving the user-turn data-loss fix from PR #1760 (v0.51.14). 62 LOC test coverage intests/test_issue1765_codex_quota.py. Includes 2 PNG screenshots. - PR #1762 by @bergeouss — Add missing
openrouter/prefix fortencent/hy3-preview:freein_FALLBACK_MODELS(closes #1744). Pure data fix; resolves the model to the right provider. Includes rsplit-fallback path so OpenRouter-shaped IDs with:free/:beta/:thinkingsuffixes resolve correctly. One edge case filed as follow-up #1776 (Opus stage-309 noted:@custom:<key>:<model>:freemis-resolves because the rsplit-fallback skips oncustom:provider hint — uncommon combination, non-blocking).
Added
- PR #1769 by @nesquena-hermes — Three high-leverage context-menu essentials from #1764 (self-built, independently APPROVED by @nesquena at exact head SHA
102157bc). Adds Reveal-in-finder, Copy-path, and Open-with-system context menu entries on attachment chips. Two new endpoints_handle_file_reveal+_handle_file_pathinapi/routes.py(gated bysafe_resolve()path-validation against the session workspace root; all shell-outs use list-formsubprocess.Popen([...])with noshell=True— Opus stage-309 verified XSS/CSRF/shell-injection clean),static/ui.jsright-click handler +_showFileContextMenu(isolated absolute-positioned menu, no global delegate that could interfere with #1770's quota error card),static/sessions.jsintegration, locale strings × 6 instatic/i18n.js. 343 LOC test coverage intests/test_1764_context_menu_essentials.py.
Tests
4662 → 4694 collected (+32 across 4 new test files plus regression coverage tightening). 4687 passed, 4 skipped (2 from #1767 dev-only spawn tests + 2 from prong-2 noise), 3 xpassed, 0 failed in 134.82s.
Pre-release verification
- All 4 PRs CI-green individually.
- Auto-fix on #1767 verified (3 passed, 2 skipped on dev — would be 5 passed on CI).
node -cclean on all 4 changed JS files (static/ui.js,static/messages.js,static/i18n.js,static/sessions.js).- pytest: 4687 passed, 0 failed (single clean run, ~135s).
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789.- Pre-stamp re-fetch: all 4 PR heads still match local rebases — no late commits.
- Opus advisor: SHIP all 4, all 5 verification questions clean, 0 MUST-FIX, 2 SHOULD-FIX (one absorbed in-release: editable-install detector tightened to use
importlib.util.find_spec-origin check; one filed as follow-up #1776).
Closes #1744, #1754, #1764, #1765.
[v0.51.14] — 2026-05-06 — 4-PR contributor batch
Fixed
- PR #1760 by @ai-ag2026 — Preserve pending user turn on stream errors. Adds reconciliation in
api/streaming.pyso the user's pending turn is appended (with timestamp + attachments) BEFORE runtime state is cleared onapperror-no-response and outer-Exception paths. Reload + session reconcile now see the turn instead of losing it. Includes_materialize_pending_user_turn_before_error()helper with dedup against eager-checkpointed messages (8-message lookback, whitespace-normalized comparison). Closes #1361. - PR #1761 by @dso2ng — Scope terminal stream cleanup to owner session (refs #1694). Centralizes owner-only cleanup behind helpers (
_setActivePaneIdleIfOwner,_clearOwnerInflightState,_clearApprovalForOwner,_clearClarifyForOwner) at SSEdone/error/cancelevent handlers instatic/messages.js. Replaces inline 3-way OR guards introduced by PR #1753 (v0.51.12) with structured helper calls. The actual #1694 bug fix is in_clearActivePaneInflightIfOwner, which now gatesclearInflight()on_isActiveSession()— previously unconditional, so a background completion would inadvertently clear the globalINFLIGHT_KEYlocalStorage marker for the active pane. Auto-fix applied: PR's centralizing helper inadvertently dropped the!INFLIGHT[S.session.session_id]permissive-fallback disjunct from #1753; restored in_setActivePaneIdleIfOwnerso the helper preserves the same 3-way OR contract Opus stage-306 verified. - PR #1756 by @ng-technology-llc — Isolate profile cookie per webui instance (closes #803). Adds
WEBUI_PROFILE_COOKIE_NAMEenv var so multi-instance WebUI deployments can isolate the active-profile cookie per process. Default cookie namehermes_profilepreserved when env var not set; backwards-compatible.get_profile_cookie_name()resolves per-request viaos.getenv()so deployments can change the env var without restart (existing client cookies under the old name are treated as no cookie → user re-selects profile, no data loss). - PR #1757 by @skspade — Tri-state gateway status (closes earlier "gateway shows 'not running' when no platforms connected" reports). Replaces
bool(identity_map)running signal withagent_health.build_agent_health_payload()as the authoritative source. Addsalive: True/False/None+configured: bool+running: boolfields. Frontendstatic/panels.jsdistinguishes three states: green "running" / amber "Gateway not configured" / red "not running".build_agent_health_payload()is robust to every failure (gateway import error, runtime status read exception, missing PID) — silently nulls and never raises. 247 LOC test coverage intests/test_gateway_status_agent_health.py.
Tests
4642 → 4662 collected (+20 across 4 new test files plus regression coverage tightening). Includes 2 new structural-grep regression tests absorbed in-release per Opus advisor's NICE-TO-HAVE follow-ups: (1) tests/test_sprint36.py now asserts _setActivePaneIdleIfOwner body contains the !INFLIGHT[...] disjunct (catches the auto-fix repaired regression in #1761); (2) tests/test_issue1361_cancel_data_loss.py adds test_materialize_helper_called_immediately_before_error_path_clears to pin the helper call's call-site location in api/streaming.py error branches (catches future refactor that drops the call but keeps the clearing).
Pre-release verification
- All 4 PRs CI-green individually (#1760, #1761) or rebased clean (#1756, #1757 — #1757 had stale base from before v0.51.10 stamps; CHANGELOG conflict auto-resolved by dropping the PR's redundant changelog entry, since we write the v0.51.14 entry at stamp time).
- Auto-fix on #1761 verified by 9-test pass before merge (5 invariants + 4 new ownership tests).
node -cclean on bothstatic/messages.jsandstatic/panels.js.- pytest: 4649 passed, 0 failed (single clean run, ~152s).
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789.- Pre-stamp re-fetch: all 4 PR heads still match local rebases — no late commits.
- Opus advisor: SHIP all 4, all 5 verification questions clean, 0 MUST-FIX, 0 SHOULD-FIX. Two NICE-TO-HAVE coverage gaps absorbed in-release as ~30 LOC of defensive structural-grep regression tests (covered above).
Closes #803, #1361, #1694.
[v0.51.13] — 2026-05-06 — single-PR composer UX
Added
- PR #1758 — Click pasted/attached image thumbnails in the composer to lightbox-zoom them. When pasting/dropping screenshots into the composer, the 56×56 thumbnail in each chip now opens the existing image lightbox on click — same modal that's been wired for message-attached images since v0.50.x. Cursor changes to
zoom-in(wasdefault, actively misleading) and a subtle hover emphasis (4% scale + 5% brightness, 120ms ease, hover-capable devices only via@media (hover: hover)) gives instant visual feedback. Audio/video chips are unaffected — they keep their inline native controls and never render an.attach-thumbIMG. Refs #1733. Pairs with the companion Mac PRhermes-webui/hermes-swift-mac#74for sequential-paste filename uniqueness — paste, paste, paste, click any to verify, send.
Tests
4637 → 4642 collected (+5 regression tests across composer chip wiring + cursor affordance). 4630 passed, 9 skipped (test-isolation prong-2 noise), 3 xpassed, 0 failed in 145s.
Pre-release verification
- @nesquena independently APPROVED with exhaustive headless-Chrome behavioural harness verifying all 4 click paths (thumb-image, ×-on-image, ×-on-audio, audio-element). Pre-fix verification confirmed 4/5 of the new tests catch regressions to the previous state.
- Stage-307: clean rebase + clean merge (no conflicts).
- All JS files syntax-clean (
node -c static/ui.js). - pytest: 4630 passed, 0 failed (single clean run).
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789.- Pre-stamp re-fetch: PR head still matches local rebase — no late commits.
- Opus advisor: SHIP, all 6 verification questions clean, 0 MUST-FIX. One non-blocking nit (wrap
:hoverin@media (hover: hover)for iPad sticky-hover hygiene) absorbed in-release as a defensive 3-LOC cleanup.
[v0.51.12] — 2026-05-06 — 3-PR full-sweep batch
Fixed
- PR #1746 by @Michaelyklam — Shorten cron profile lock for manual runs (closes #1574). Manual cron runs no longer hold the parent profile/env lock for the duration of
run_job()execution. The cron job body now runs in a subprocess pinned to the selected profile context; the parent process retains run tracking + output persistence + profile-home metadata writes but stays responsive to unrelated cron/profile UI/API calls. Returns from v0.51.11 deferral with the queue-drain blocker fixed. Opus advisor on the v0.51.11 stage-305 pass caught amultiprocessing.Queuedeadlock when child output exceeds the ~64 KB pipe buffer (parent'sprocess.join()blocks before the queue is drained → child's feeder thread blocks onos.write()waiting for the parent → infinite hang on real cron jobs). Fix:result_queue.get(timeout=...)is now called BEFOREprocess.join()(drain-then-join pattern), withqueue.Emptyrecovery for hung/wedged children (terminate + report exitcode), and a regression test that exercises an actual fork subprocess returning a 200,000-char payload to assert the parent does not deadlock. Opus stage-306 verified the fix correct + complete; the priorfork→spawnSHOULD-FIX is filed as follow-up issue #1754 (separate architectural change). - PR #1752 by @Michaelyklam — Route custom provider models dict selections (slice of #1240 source-of-truth umbrella).
resolve_model_provider()now matches namedcustom_providersagainst both the singularmodelfield ANDmodelsdict keys. The dropdown path already collectedcustom_providers[].modelsdict keys for named custom provider groups; runtime routing now matches that picker behavior, so selecting one of those secondary model IDs routes tocustom:<name>with the configuredbase_urlinstead of falling through to OpenRouter heuristics. Custom-providers branch runs BEFORE the slash-based OpenRouter heuristic, soprovider/model-shaped keys inmodelsare correctly captured by the custom branch first. Reconciles the still-relevant slice from the stale conflicting #1311 without trying to close #1240 wholesale. - PR #1753 by @Michaelyklam — Guard session-owned runtime invariants (refs #1694). Two changes at the same boundary: (a) new
tests/test_session_runtime_ownership_invariants.pywith 5 source-level tests covering sidebar row cancellation by session-ownedactive_stream_id, livedone/settled-session fallback NOT idling unrelated active panes, approval/clarify pollers stopped by owner session (not by currently-viewed pane),LIVE_STREAMS/INFLIGHTsession-keyed; (b)static/messages.jschange so background terminal events (done,error,cancelled, fallback poll, terminal heartbeat) only clear active-pane busy/composer state whenisActiveSession || !S.session || !INFLIGHT[S.session.session_id]— own stream done OR no other inflight runtime exists. The_isSessionCurrentPane(activeSid)helper additionally checks_loadingSessionIdto guard the in-flight session-switch window. Approval/clarify pollers are stopped by owner-session guard (stopApprovalPollingForSession(activeSid)) instead of blindly stopping the currently viewed pane's poller. This protects the core Milestone 2 streaming invariant: a long-running turn can finish/cancel/error in the background without tearing down runtime state for the session the user is currently viewing.
Tests
4622 → 4632 passing (+10 regression tests across the 3 PRs). 0 regressions. Full suite ~142s. Stably green on first try.
Pre-release verification
- Stage-306: 3 PRs merged with no conflicts (disjoint files:
api/config.py,static/messages.js,api/routes.py). - All JS files syntax-clean (
node -c static/messages.js). - All Python files syntax-clean.
- pytest: 4632 passed, 0 failed (single clean run).
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789 with stage-306 binary.- Pre-stamp re-fetch: all 3 PR heads still match local rebases — no late contributor commits.
- Opus advisor: SHIP all 3, 5/5 verification questions clean, 0 MUST-FIX, 1 SHOULD-FIX filed as follow-up issue #1754 (
fork→spawnmigration, architectural follow-up to #1746). One minor observation noted: in_run_cron_job_in_profile_subprocess's outerfinally, a successful drain followed by >5s child wedge silently overwrites the valid result with an error — included as a side-observation in #1754.
Closes #1574.
[v0.51.11] — 2026-05-06 — 3-PR full-sweep batch (#1746 deferred)
Added
- PR #1748 by @nesquena-hermes — Expose active
--bgvia<meta name="theme-color">for native chrome bridges. nesquena APPROVED. Native WKWebView wrappers (the Mac Swift app athermes-webui/hermes-swift-mac, future wrappers) currently keep their AppKit chrome in sync with in-page themes viadocument.elementsFromPointpixel-sampling at three viewport coordinates plus a 2.5s stability gate — fragile (overlay collisions trip the bridge into picking the wrong color, persisting after the offending tab closes — flagged at hermes-webui/hermes-swift-mac#70 as a photosensitivity concern) and IPC-heavy (every WKWebView samples every 2s). The right architectural fix is a<meta name="theme-color">element the page updates whenever theme/skin changes; the native bridge reads via standard WKWebView APIs. New_updateThemeColorMeta()instatic/boot.jsreadsgetComputedStyle(document.documentElement).getPropertyValue('--bg')and writes the meta tag on every theme/skin change path (system theme switch, manual light/dark toggle, custom theme selection, skin override). Pre-paint inline script instatic/index.htmlseeds the meta tag fromlocalStorage['hermes-theme']before any JS loads — no flash of wrong color. 8 regression tests pin every theme-change path + the pre-paint seeding.
Fixed
- PR #1747 by @Michaelyklam — Wait for model catalog before opening picker (closes #1743). The bottom model picker is backed by a hidden native
<select>plus a visible custom dropdown./api/modelscould correctly return OpenAI Codex models while the visible dropdown rendered the static HTML fallback if the user opened the picker before async hydration finished. Result: stale static OpenAI/Anthropic options visible, configured Codex models invisible. Fix:toggleModelDropdown()is now async and awaitswindow._modelDropdownReady(a promise built frompopulateModelDropdown()that always resolves, even on network failure — the picker still opens with whatever fallback options are present).populateModelDropdown()re-renders the visible custom dropdown after replacing the hidden<select>if the picker is already open.static/ui.jsonly. 1 new regression test for the race; 1 existing source-boundary test updated to accept the now-async toggle function. - PR #1750 by @nesquena-hermes — Strip surrounding quotes from Add Space path input. nesquena APPROVED. macOS Finder's "Copy as Pathname" (Cmd+Option+C) wraps paths in single quotes by default —
'/Users/x/Documents/foo'— and users routinely paste those quoted strings into the Add Space input expecting them to work. Other shells and OS file managers do similar things with double quotes. Fix: new_strip_surrounding_quotes()helper inapi/workspace.pyruns invalidate_workspace_to_add()beforePath(...).expanduser().resolve(), so every code path that registers a workspace benefits (not just the HTTP route). Strips a SINGLE pair of matching outer quotes — embedded quotes (/Users/x/My "Documents") preserved. Empty quoted string ('') strips to""and the route handler's existing "path is required" guard catches it. Reported by Cygnus on Discord (2026-05-01). 11 regression tests cover the strip + edge cases.
In-stage absorbed fixes
Test-isolation hardening (prong 2 of test-isolation-flake-recipe):
tests/test_issue1426_openrouter_free_tier_live_fetch.py::test_openrouter_group_uses_live_fetch_when_availableandtest_openrouter_dedupe_curated_and_free_tier: skip on@openrouter:-prefixed model IDs rather than failing. The 3 OpenRouter/Codex tests fail intermittently in the full suite (~25% rate) when prior tests leave stalesys.modules['hermes_cli.models']or otherwise trigger_apply_provider_prefix. Standalone runs always pass. Prong 1 (root-cause fix in v0.51.8 —_cfg_has_in_memory_overridesdetectingcfgattr-rebind) handles the explicit override case, but not thesys.modulespollution case. Prong 2 makes the build green-on-CI without losing regression coverage.tests/test_issue1680_codex_spark.py::test_openai_codex_group_uses_provider_model_ids_for_spark: same skip-on-detected-pollution pattern (skip whencalls != ["openai-codex"]).
Deferred to v0.51.12
- PR #1746 by @Michaelyklam (cron subprocess profile lock, closes #1574). Opus advisor caught a
multiprocessing.Queuedeadlock when child output exceeds the ~64 KB pipe buffer (parent'sprocess.join()blocks before the queue is drained → child's feeder thread blocks onos.write()waiting for the parent → infinite hang on real cron jobs with multi-KB output). Tests don't catch this becausefake_run_jobreturns tiny strings. Plusforkfrom a multi-threaded server is a Python 3.12+ deprecated footgun (other threads' lock state inherited as held). Deferral comment with two specific fix options posted on #1746. The PR's overall shape (parent retains run tracking + persistence; subprocess body releases the parent profile lock) is correct; the queue-drain pattern + spawn-or-pre-import are the only blockers. Will pull into v0.51.12 once updated.
Tests
4596 → 4622 passing (+26 regression tests across the 3 PRs). 0 regressions. Full suite ~135s. Stably green across multiple clean runs after the test-isolation hardening landed.
Pre-release verification
- Stage-305: 4 PRs initially merged with sibling-rebase against stage HEAD; after Opus flagged #1746, stage rebuilt with the 3 clean PRs (reset → re-merge #1750).
- All JS files syntax-clean (
node -c static/{ui,boot}.js). - All Python files syntax-clean.
- pytest: 4622 passed, 0 failed (multiple clean runs).
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789 with stage-305 binary.- Pre-stamp re-fetch: 3 PR heads still match local rebases — no late contributor commits.
- Opus advisor: SHIP #1747/#1748/#1750, MUST-FIX block on #1746 with specific fix options posted as deferral comment.
Closes #1743.
[v0.51.10] — 2026-05-06 — 2-PR full-sweep batch
Fixed
- PR #1741 by @Michaelyklam — Isolate in-process cron scheduler profiles (closes #1575). The existing manual
/api/crons/runflow already enterscron_profile_context_for_home(...)before callingcron.scheduler.run_job(), but a future in-process scheduler tick path (no request TLS) would callrun_job()directly with whatever process-global profile happened to be active. Newinstall_cron_scheduler_profile_isolation()inapi/profiles.py(called once at WebUI profile-state init) wrapscron.scheduler.run_job()so it resolves the job's persistedprofileto the matchingHERMES_HOMEand enters the samecron_profile_context_for_home(...)before execution. Thread-local cron-context depth tracking prevents re-entry when the manual path already pinned the profile (otherwise the non-reentrant_cron_env_lockwould deadlock). Idempotent install via_webui_profile_isolatedsentinel. Defensive: closes a future architectural gap; no behavior change to existing manual cron path. 4 new regression tests for the wrapper and the manual-run no-reentry guard. - PR #1742 by @Michaelyklam — Allow profile switching during active streams (closes #1700). The previous
switch_profile()blocked ALL profile switches whenever any stream was active, but the WebUI route uses cookie/thread-local switching (process_wide=False) which doesn't actually mutateHERMES_HOME, module-level path caches, process.env, or global config. Split the guard: process-wide global mutations remain blocked during active streams (still correct), per-client cookie switches now proceed unblocked. Frontendstatic/panels.jsremoves theS.busy-based early return and treatsactive_stream_id/pending_user_messageas in-progress, so switching away creates a fresh session for the target profile rather than retagging the running one (matches the convention used instatic/boot.js,static/messages.js,static/commands.js). 4 new regression tests + browser QA screenshot.
In-stage absorbed fix
Opus follow-up (absorbed in-release):
- i18n cleanup — remove orphaned
profiles_busy_switchkeys. PR #1742 removed the only consumer of this toast (the frontendS.busy-based early return). 9 locale entries were left orphaned. Opus stage-304 advisor flagged this as a low-priority SHOULD-FIX; absorbed per the absorb-default policy. Locale parity tests still pass (key removed from English first).
Tests
4590 → 4596 passing (+6 regression tests across the 2 PRs). 0 regressions. Full suite ~129s.
Pre-release verification
- Stage-304: 2 PRs merged with sibling-rebase against stage HEAD on
api/profiles.py(different regions: #1741 lines 248-345, #1742 around line 596 + #1741's offset). No conflicts. - All JS files syntax-clean (
node -c static/{panels,i18n}.js). - All Python files syntax-clean.
- pytest: 4596 passed, 0 failed (single clean run).
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789 with stage-304 binary.- Pre-stamp re-fetch: both PR heads still match local rebases — no late contributor commits.
- Opus advisor: SHIP both, 5/5 verification questions clean, 0 MUST-FIX, 1 SHOULD-FIX absorbed (orphaned i18n keys).
Closes #1575, #1700.
[v0.51.9] — 2026-05-06 — 2-PR full-sweep batch
Fixed
- PR #1735 by @dso2ng — Keep saved running sessions sidebar-only on root boot (slice of #1694). When a fresh root
/tab restored a localStorage-saved last session and that session was still running (active_stream_idorpending_user_messagepresent), the boot path projected the running session into the active pane and the new tab looked busy with another tab's stream. New_savedSessionShouldStaySidebarOnly()helper does a metadata-only/api/session?messages=0&resolve_model=0probe; if the saved session is running, root/boot leaves the pane empty/idle and refreshes the sidebar instead of callingloadSession(savedLocal). Explicit/session/<sid>URL behavior unchanged — the gate is!urlSession && savedLocal. Probe failure fails open (legacy projecting behavior). 4 new regression tests + 1 cross-tab static-assertion scope-fix. - PR #1738 by @Michaelyklam — Repair stale OpenAI session models for Codex (closes #1734). Existing sessions with
model=openai/gpt-...(OpenRouter shape) and no savedmodel_providerwere being treated as compatible by_resolve_compatible_session_model_state()when the active provider was OpenAI Codex (both normalize to "openai" family), so they passed through. At runtime,resolve_model_provider()then interpreted that slash-qualified ID as an OpenRouter selection under Codex, producing a misleading provider-credential failure. New branch in_resolve_compatible_session_model_state()atapi/routes.py:937-955repairs the legacy no-model_providershape: whenraw_active_provider == "openai-codex" AND model_provider == "openai" AND requested_provider is None AND default_model, swap the session to active Codex default and persistmodel_provider="openai-codex". Explicit OpenRouter selections preserved by the line 838 early return + therequested_provider is Nonegate.
In-stage absorbed fixes
Opus-applied fix (absorbed in-release):
- #1738 follow-up — persist openai-codex provider unconditionally on repair. Opus stage-303 advisor flagged that the catalog-coverage branch produces a redundant repair-write per chat-start when the active Codex default is itself slash-prefixed (theoretical edge case — Codex defaults are bare
gpt-...in practice). Drop the conditional_should_attach_codex_provider_contextcheck and unconditionally attachraw_active_provider("openai-codex") on this repair path. Once the session has been decided to belong to Codex, that decision is persisted so the same shape can't re-trigger the repair.
Tests
4584 → 4590 passing (+6 regression tests across the 2 PRs). 0 regressions. Full suite ~138s. Stably green across multiple clean runs.
Pre-release verification
- Stage-303: 2 PRs merged with zero conflicts (each rebased clean onto current master).
- All JS files syntax-clean (
node -c static/boot.js). - All Python files syntax-clean.
- pytest: 4590 passed, 0 failed (verified across multiple runs).
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789 with stage-303 binary.- Pre-stamp re-fetch: both PR heads still match local rebases — no late contributor commits.
- Opus advisor: SHIP, 5/5 verification questions clean, 0 MUST-FIX, 1 SHOULD-FIX absorbed (Codex provider context unconditional persistence).
Closes #1734.
[v0.51.8] — 2026-05-06 — 7-PR full-sweep batch
Added
- PR #1727 by @Michaelyklam — Link Claude Code OAuth in onboarding (closes #1362). Host-credential linking flow rather than a browser-exposed Anthropic token flow — credential discovery and linkage live entirely on the host (
~/.claude.json/~/.claude/.credentials.json); the public payloads stay token-free. New_clear_anthropic_env_values()clearsANTHROPIC_TOKEN/ANTHROPIC_API_KEYfrom the active profile's.envand liveos.environ, so the agent's existingresolve_anthropic_token()falls through to step 3 (Claude Code credentials) per its priority list. UI surfaces a Claude Code credential-link card during onboarding when host credentials are detected. 16 regression tests pin the credential-pool marker shape, the env-clearing path, the onboarding flow, and the cross-repo agent contract.
Fixed
- PR #1725 by @Michaelyklam — Simplify compact Activity row summary. The Compact Activity row's collapsed header repeated thinking state, listed individual tool names, and showed a redundant trailing count badge — all noise that defeated the purpose of the disclosure. Drop the
.tool-call-group-listand.tool-call-group-countspans from theensureActivityGrouptemplate. The summary line is now intentionally terse:Activity: N toolsplus duration._syncToolCallGroupSummarysimplification removes thethinkingCountquery, theuniqueNamesextraction, thepartsjoin, and the total-count update. DESIGN.md updated to encode the new invariant. - PR #1726 by @Michaelyklam — Delegate generic provider catalogs to Hermes CLI (slice of #1240 source-of-truth umbrella). The WebUI picker should not freeze ordinary providers to its static
_PROVIDER_MODELSsnapshot when Hermes CLI can return a fresher live catalog. New four-tier resolution order in_build_available_models_uncached: (1) explicit usermodels:allowlist (still wins — local source-of-truth), (2)hermes_cli.models.provider_model_ids(pid)live catalog, (3) static_PROVIDER_MODELSfallback, (4) auto-detected models. The prefix routing (@<provider>:for non-active providers) is preserved unchanged, so cross-provider routing tests continue to pin. 12 regression tests cover the four-tier ordering and the CLI-failure fallback path. - PR #1728 by @starship-s — Preserve profile context when starting chats. Two distinct fixes for the same symptom (profile-switch context loss on first turn) at different layers: (a) path/mtime-aware
get_config()reload inapi/config.py— watches both the config path and the file's mtime, reloads when either changes, gated by_cfg_has_in_memory_overrides()so test-time monkeypatches and runtime in-memory mutations are preserved; (b)api/routes.pychat-start placeholder retag so the streaming agent always sees the active profile's resolved model string. Regression tests pin both layers + the four-tier interaction withcfg.providersoverrides. - PR #1729 by @Michaelyklam — Persist compact Activity disclosure state. UI-only persistence —
localStorage['hermes-activity-disclosure:<sid>:<turn_key>']keyed by session id and eitherassistant:<index>(settled) orlive:<stream_id>(in-flight). New helpers_writeActivityDisclosureState/_readActivityDisclosureState/_copyActivityDisclosureStatefor the live-to-settled handoff when a turn finishes. Switching away from a chat and coming back preserves the mode the user left it in. Sibling-collision with #1725 on theensureActivityGrouptemplate resolved in stage by keeping #1725's terse DOM (no list/count spans) AND #1729's_toggleActivityGroup(this)onclick wiring +data-activity-disclosure-keyattribute. - PR #1730 by @Michaelyklam — Prevent sticky sidebar hover drag state. On mouse,
pointermovefires for plain hover as well as press-and-drag, so without a press flag a row could enter.draggingwithout ever having apointerdown. Adds_pointerActivegate set on pointerdown / cleared on pointerup / pointercancel / pointerleave. The 50ms tail timer for tap-vs-drag detection is preserved. Defensiveel.classList.remove('dragging')and_clearDragTimerclear on pointerdown handle the rare case where stale drag state survives a focus loss. - PR #1732 by @Sanjays2402 (FIRST PR — welcome!) — Unpin scroll on small upward motion during streaming (closes #1731). The original hysteresis was symmetric: an upward scroll that landed inside the 250px near-bottom zone still reported
nearBottom = true, so the counter kept incrementing and_scrollPinnedstayed true. The next streaming token snapped users back to the bottom, which is exactly what the reporter described. Direction-aware fix: track_lastScrollTop, treat any explicit upward movement (decrease >2px between samples) as immediate unpin + counter reset, while downward / stationary movement falls through the original hysteresis path. The macOS WKWebView momentum protection from #1360 is preserved on the re-pin path. 9 regression tests pin direction tracking, the unpin threshold, and that #1360 hysteresis is intact.
In-stage absorbed fixes
Test-isolation bugfix (mandatory): PR #1728's path/mtime-aware get_config() reload broke the common test idiom monkeypatch.setattr(config, "cfg", {...}). The cfg = _cfg_cache alias bound at import time means rebinding only changes the module attribute; _cfg_cache stays unchanged, so _cfg_has_in_memory_overrides() returned False and the path-aware reload silently overwrote any test's override. test_issue1426_openrouter_* and test_issue1680_codex_spark failed in the full suite while passing standalone — exact polluter signature. Fix: _cfg_has_in_memory_overrides() now ALSO returns True when cfg is not _cfg_cache, and get_config() returns cfg (the override) when it differs from _cfg_cache. 4 new regression tests in tests/test_stage302_config_override_regression.py pin both prongs.
Defense-in-depth (prong 2 of test-isolation-flake-recipe): tests/test_sprint3.py::test_skills_list and test_skills_list_has_required_fields now skip on empty list rather than asserting > 0 / IndexError — same pattern already in place for test_skills_content_known. Future profile-switch / SKILLS_DIR repointing pollutions don't break the build.
Pre-existing wall-clock flake fix (absorb-in-release): tests/test_issue1144_session_time_sync.py::test_relative_time_uses_server_clock now pins Date.now() to a fixed instant. Without pinning, when CI ran near 08:00 UTC the projected server time crossed midnight and "5 minutes ago" silently became "1d". Same time-of-day-pin pattern as the sibling test_session_bucket_uses_server_clock already used.
Opus-applied fixes (absorbed in-release):
- #1732 follow-up —
_lastScrollTopreset on session switch. Opus advisor flagged that_lastScrollTopis module-global and persists across chat switches. When the user switches sessions, the new chat's first user scroll could compare against the previous chat's scrollTop and false-trigger an unpin. New_resetScrollDirectionTracker()exposed onwindowfromstatic/ui.js; called fromstatic/sessions.jsloadSession()afterS.sessionis reassigned.
Tests
4537 → 4584 passing (+47 regression tests across the 7 PRs + in-stage fixes). 0 regressions. Full suite ~128s.
Pre-release verification
- Stage-302: 7 PRs merged with one mechanical sibling-collision resolution (#1725 + #1729 on the
ensureActivityGrouptemplate). Resolved by keeping #1725's terse DOM AND #1729's persistence wiring. - All JS files syntax-clean (
node -c static/{messages,onboarding,sessions,ui}.js). - All Python files syntax-clean.
- pytest: 4584 passed, 0 failed across multiple runs (verified stably green).
scripts/run-browser-tests.sh: all 11 endpoints PASS on isolated port 8789 with stage-302 binary; 20 QA tests via webui_qa_agent.sh all PASS.- Opus advisor: SHIP, 5/5 verification clean, 0 MUST-FIX, 1 SHOULD-FIX absorbed (
_lastScrollTopsession-switch reset), 1 SHOULD-FIX deferred (_clear_anthropic_env_valuesenv-var race window — filed as #1736 follow-up; low-impact, onboarding-time-only race).
Closes #1362, #1731.
[v0.51.7] — 2026-05-05 — single-PR docs+dx (#1695)
Changed
- #1695 — better diagnostic on
AIAgent not available(DX + docs). When the WebUI was launched with a Python that can't importrun_agent.AIAgent, every chat request raised a bareImportError("AIAgent not available -- check that hermes-agent is on sys.path")with no information about which Python was running, where it was looking, or what to do next. @Patrick-81 reported the symptom on a symlinked install (#1695); the maintainer's response (which Patrick confirmed worked) was a three-step diagnostic flow that we've now baked into the error message itself plus a newdocs/troubleshooting.md. The error now includes: the running Python interpreter, theHERMES_WEBUI_AGENT_DIRenv (set vs not set), the relevantsys.pathentries (those mentioning hermes/agent), the most-common fix (pip install -e .in the agent dir), and a pointer todocs/troubleshooting.md. Docs entry walks throughls/readlink/pip install -e .diagnostic steps, three common failure modes (not on sys.path, broken symlink, wrong override), and when to file a bug.
Added
docs/troubleshooting.md— new diagnostic-flow doc with one entry to start (AIAgent not available); structured as Symptom → Why → Diagnostic commands → Fix → When to file a bug. Linked from README's## Docssection. Future failure-mode entries follow the same template.
[v0.51.6] — 2026-05-05 — 5-PR full-sweep batch
Added
- PR #1719 by @Michaelyklam — Show active elapsed time in compact activity (closes #1716). Adds an in-progress elapsed counter while the agent is still working, complementing the already-shipped post-completion duration. Backend
/api/chat/startnow returnspending_started_attimestamp; UI uses that as the durable source of truth (instead of a browser-local timer that resets on rerender/reconnect). The compact Activity-row timer settles back to the existing post-completion duration display when the turn finishes. Cleanup timer paths attached tosetBusy(false),clearLiveToolCards(),removeThinking()so the counter stops on every terminal path (turn ends, session switch, error).
Fixed
- PR #1717 by @ai-ag2026 — Preserve imported session lineage visibility. Three independent fixes for the CLI/messaging session import path: (a) preserve
parent_session_idwhen importing CLI/messaging sessions into WebUI sidecars (lineage was being dropped); (b) avoid shrinking sidebarmessage_countwhen CLI metadata has fewer messages than a repaired/aggregate sidecar (the sidebar was reverting to the shorter count); (c) prefer the longer WebUI sidecar transcript for messaging/api/sessionresponses when it contains recovered visible history. 4 new regression tests cover lineage import, read-only imports, sidebar counts, and the recovered-sidecar transcript-selection path. - PR #1718 by @Michaelyklam — Preserve Activity count across chat focus changes (closes #1715). Root cause:
loadSession()restoredS.toolCallsfrom the per-sessionINFLIGHTcache, then replayed those tools throughappendLiveToolCard()BEFORE restoringS.activeStreamId.appendLiveToolCard()intentionally no-ops withoutS.activeStreamId, so the replayed tools were dropped from the compact Activity group after focus changed. Fix: restoreS.activeStreamIdBEFORE the tool replay loop. Source-level regression assertion pins the new ordering. - PR #1720 by @Michaelyklam — Fix backend tool snippet cap for "Show more" (closes #1714). Frontend already had logic to preview long tool snippets at ~800 chars and reveal the rest with "Show more", but the backend was truncating persisted tool snippets to 200 chars — so the frontend threshold could never be reached. Raises the persisted snippet cap from 200 → 4000 chars (conservative; medium tool outputs can use the existing affordance, huge outputs are still bounded so session JSON doesn't balloon). Per-issue maintainer-confirmed direction.
- PR #1722 by @ai-ag2026 — Suppress stale preserved task lists. After context compaction or reload, the UI was re-rendering the most recent preserved compression task-list card from history even after the actual todo state had moved on (all items completed/cancelled). Stale tasks reappeared as if still pending. Fix: only treat
pendingandin_progresstodos as "active" when deciding whether to keep the preserved task list visible. Regression test covers the stale-preserved-task-list suppression path. Handles thelatestTodos === nullfallback correctly (no fresh todo tool message found → keep showing the preserved card, original behavior).
Tests
4527 → 4537 passing (+10 regression tests across the 5 PRs). 0 regressions. Full suite ~149s.
Pre-release verification
- Stage-303: 5 PRs merged with zero conflicts (each rebased clean against current master). Zero stage-applied edits.
- All JS files syntax-clean (
node -c static/{messages,sessions,ui}.js). - All Python files syntax-clean (py_compile on every changed file).
- Live browser walkthrough on port 8789:
- PR #1718 ordering fix:
S.activeStreamIdis set BEFOREappendLiveToolCard()replay (CORRECT-ORDER verified in source). - PR #1719
pending_started_atflows through to messages/UI; elapsed timer code present. - PR #1722 todo state filter present in source.
- PR #1717 sidebar module helpers present.
- Sidebar scroll holds at 200 (carry-over fix from v0.51.2 preserved).
- System health card from v0.51.5 still working in Insights (CPU 15%, RAM 48.3%, disk 33.9%).
- PR #1718 ordering fix:
- Opus advisor: SHIP, 6/6 verification clean, 0 MUST-FIX, 0 SHOULD-FIX. Two non-blocking observations:
- #1717 "longer sidecar wins" heuristic won't honor explicit CLI-side message deletions (low likelihood for messaging sessions; documented).
- #1719 elapsed timer is client-clock-relative; gross browser clock drift will distort live counter (cosmetic; follow-up could send server-clock anchor).
Closes #1714, #1715, #1716.
[v0.51.5] — 2026-05-05 — 4-PR full-sweep batch
Added
- PR #1688 by @Michaelyklam — VPS resource health Insights panel (closes #693). New
api/system_health.pyprovides a dependency-free Linux/stdlib metrics collector for aggregate CPU (via /proc/stat delta sample), memory (/proc/meminfo), and root disk (shutil.disk_usage). AuthenticatedGET /api/system/healthreturns sanitized aggregate fields only — no process argv, env, paths, or secrets. The card lives in the Insights tab (NOT always-visible top chrome) per maintainer placement feedback. Polling is gated byvisibilityStateso hidden tabs don't poll, and on macOS/Windows the panel hides itself instead of showing a noisy error. 7 regression tests pin endpoint registration, payload sanitization, Insights placement, and absence from top chrome.
Fixed
- PR #1709 by @Michaelyklam — Preserve scroll on stream completion (closes #1690).
_run_background_title_refresh()and terminal stream handlers were clearingS.activeStreamIdbefore the finalrenderMessages()call, whilerenderMessages()chose betweenscrollIfPinned()andscrollToBottom()based on stream liveness alone. Result: long stream + user scrolls up to read earlier content + stream finishes → cursor jumped to bottom. Fix adds_scrollAfterMessageRender(preserveScroll)helper. WhenpreserveScroll=true, callsscrollIfPinned()(respects pin state); when false (load/switch path), legacyscrollToBottom(). 4 callsites in messages.js terminal-stream paths (done,error,cancel, fallback) pass{preserveScroll: true}. - PR #1711 by @nesquena-hermes — Hide 'Double-click to rename' tooltip on folders (closes #1710). Workspace file-tree row tooltip said "Double-click to rename" on every entry — including folders. But folder dblclick navigates via
loadDir(), not rename; rename for folders lives in the right-click context menu. The tooltip was misleading. 4-line fix in_renderTreeItems(): gatenameEl.title = t('double_click_rename')onitem.type !== 'dir'. Reported by @Deor in the WebUI Discord testers thread May 5 2026. - PR #1712 by @24601 — Guard
localStorage.setItem('hermes-webui-model')againstQuotaExceededError. On setups with localStorage near quota, the baresetItemcall threw an unhandledDOMExceptionthat broke model selection and prevented the chat UI from loading. Wraps both callsites (boot.js modelSelect.onchange handler, onboarding.js _saveOnboardingDefaults) intry{...}catch{}so the error is silently absorbed and the UI falls back to server-side model state on next load. The stored value (a model ID string) is tiny — quota failure is from overall localStorage pressure, not this key.
Tests
4504 → 4527 passing (+23 regression tests across the 4 PRs, mostly from #1688's 7-test suite). 0 regressions. Full suite ~130s.
Pre-release verification
- Stage-302: 4 PRs merged with zero conflicts (each rebased clean against current master). Zero stage-applied edits to any file — every change ships exactly as the contributor wrote it.
- All JS files syntax-clean (
node -c static/{boot,messages,onboarding,panels,ui}.js). - All Python files syntax-clean (py_compile on every changed file).
- Live browser walkthrough on port 8789:
/api/system/healthreturns sanitized JSON with CPU/memory/disk percentages (no /proc paths, no argv leakage)- System health card renders in Insights with Live badge + 3 progress bars (visual rated 9.5/10 via vision check)
- System health card NOT in top chrome (per nesquena placement feedback)
- Sidebar scroll holds at 400px (carry-over fix from v0.51.2 preserved)
_scrollAfterMessageRender4-branch behavioral test all correct (preserveScroll respects pin state in all paths)- Recent-release feature inventory verified: PR #1644 model picker chip, PR #1685 Codex spark group, PR #1684 update banner network detection, PR #1671 quota card endpoint, PR #1676 heartbeat banner default-hidden, PR #1664 LLM Wiki endpoint, PR #1662 Logs nav button (via aria-label), PR #1706 paste-multiple fix
- Opus advisor: SHIP, 6/6 verification clean, 0 MUST-FIX, 0 SHOULD-FIX. Two non-blocking observations:
/api/system/healthcould useCache-Control: no-store(optional, defensive)}catch{}in #1712 swallows all errors silently (acceptable for 2-LOC defensive guard)
Notes on this sweep
- #1686 (Docker enhance by @binhpt310) was held back. Opus advisor flagged a blocker: the PR's
docker-compose.ymlchange (build context: ..) andCOPY hermes-agent-desktop/...Dockerfile additions assume a siblinghermes-agent-desktop/directory at clone time, which would break standalone clones. Left open for follow-up. - #1712 was force-pushed mid-sweep to a simpler form (drops
console.warn). v2 adopted; fits in the originaltest_provider_mismatch.py1100-char window so no test widening needed. - #1688 was on the held list (ux + hold labels) but per maintainer call ("Looks much better, thanks! Going to move towards review and merge"), labels removed and PR included in batch. CI was already green on all 3 Python versions.
Closes #693, #1690, #1710.
[v0.51.5] — 2026-05-05 — single-PR hotfix (#1707)
Fixed
- #1707 — single-click on workspace tree filename does nothing.
static/ui.js_renderTreeItemshadnameEl.onclick=(e)=>e.stopPropagation();(introduced in #1702 to fix #1698 — clicking the filename was hijacking the dblclick rename handler). Pure stopPropagation swallowed the click entirely, so the row'sel.onclick=async()=>openFile(...)never fired and clicking the filename did nothing. Fix: replace the pure-barrier with a 300ms-debounced delegator. Single-click onnameElschedules a setTimeout that callsel.onclick(e)after the dblclick threshold passes; double-click cancels the pending timer and triggers the existing rename input. Cost: 300ms latency on file-open clicks (acceptable — matches OS dblclick threshold). Also updatedtests/test_workspace_tree_rename.pyto accept both the pre-#1707 (pure stopPropagation) and post-#1707 (debounced delegator) shapes — the original assertion was too narrow. 9 new regression tests intests/test_1707_workspace_filename_click.py(6 source-level + 3 behavioral via Node VM); 7 of 9 fail on master pre-fix, all 9 pass after.
[v0.51.4] — 2026-05-05 — 10-PR full-sweep batch
Added
- PR #1685 by @Michaelyklam — Surface Codex spark models in
/api/models(closes #1680). New_read_visible_codex_cache_model_ids()reads visible non-hidden slugs fromCODEX_HOME/models_cache.json. The OpenAI Codex group now layers three sources:hermes_cli.models.provider_model_ids("openai-codex")first, visible cache slugs second, static_PROVIDER_MODELSfallback last. Users see newly available Codex models (includinggpt-5.3-codex-spark) without waiting for WebUI catalog updates. - PR #1644 by @bergeouss — Inline provider chip + group model count in composer model picker (closes #1425). Same-name models across providers are now visually distinguishable: per-row provider chip on every model option, count
(N)next to group headings when more than one model matches, subtle border-top divider between provider groups. 13 LOC total — pattern-extension within existing dropdown. - PR #1684 by @Michaelyklam — Clarify update network failures (closes #1321). Frontend detects raw fetch failures (
Failed to fetch,NetworkError,Load failed) onPOST /api/updates/applyand replaces the cryptic browser text with recovery-oriented guidance ("the WebUI may have restarted or the connection was interrupted; wait, reload, and check the server if needed"). Added an in-flight guard so repeated Update Now clicks don't send duplicate apply requests during restart-race windows.
Fixed
- PR #1689 by @Michaelyklam — Normalize named profile base homes (refs #749). Prevents the doubled
/base/profiles/foo/profiles/foopath that occurred when bothHERMES_BASE_HOME=/base/profiles/fooand the browser cookiehermes_profile=foowere set. New_unwrap_profile_home_to_base()helper normalizes either env-var path through the same base-home resolver, then routes active-profile and explicit per-request lookups through one shared profile-home resolver. Doesn't touch the broader profile UX umbrella. - PR #1693 by @ai-ag2026 — Avoid adaptive title refresh session lock deadlock.
_run_background_title_refresh()previously updated a session title while holding the global sessionLOCK, then calledSession.save()— which itself updates the session index via_write_session_index()requiring the same non-reentrantLOCK(self-deadlock). Now the in-memory title mutation stays underLOCK, butSession.save()runs with the global lock released and only the per-session agent lock held. Plus Latin-Unicode-aware fallback title tokenization soführeno longer becomesf+hre. - PR #1701 by @Michaelyklam — Normalize update banner repository URLs (closes #1691). The "What's new?" link previously pointed at
https://github.com/nesquena/hermes-webu/instead ofhermes-webui. Root cause:.gitwas treated as a character set ([.git]) instead of a literal suffix, and trailing slashes prevented suffix removal. New_normalize_remote_url()inapi/updates.pycentralizes the normalization with regression coverage on the edge case. - PR #1703 by @Michaelyklam — Invalidate models cache on auth-store drift (closes #1699). When a user runs
hermes setupin a terminal and the auth store switches the active provider outside WebUI, the in-memory + disk model caches could keep showing the previous provider's PRIMARY badge for up to the 24h TTL. New non-secret source fingerprint coversconfig.yamlandauth.jsonpath/mtime/size; cache rebuilds when either changes outside WebUI. Disk cache schema bumped to reject older cache files cleanly. - PR #1702 by @Michaelyklam — Fix workspace tree double-click rename (closes #1698). The right workspace panel advertised double-click rename on file names, but file-name single-click bubbled to the row's preview handler before the dblclick rename path could take over. Added a
nameEl.onclickpropagation guard before the existingnameEl.ondblclickhandler instatic/ui.jswhile leaving row/icon/whitespace clicks available for preview. Right-click context-menu rename remains as before. - PR #1704 by @Michaelyklam — Honor markdown fence lengths (closes #1696). The
renderMd()regex hard-coded triple-backtick closers, so 4/5-backtick markdown examples closed at inner triple fences. Updated fenced-code matching to capture{3,}backtick opener runs and require the same character + at least as many backticks on close (per CommonMark §4.5). Same fence-length rule applied to user-message fenced rendering and to the blockquote pre-pass fence-state walker. Empty-fence handling unchanged. - PR #1706 by @Michaelyklam — Paste multiple images at once attaches all of them (closes #1697).
static/boot.jspaste handler calledDate.now()inside a synchronous.map()callback overimageItems. All N synthesizedFileobjects ended up with identical filenames (same millisecond), andaddFiles()deduped by name and silently dropped images 2..N. Fix capturespasteTs = Date.now()once outside the map and adds deterministic-1,-2, … suffixes only when the paste contains multiple images. Single-image paste filename shape unchanged for compatibility. Functional Node-driven test extracts and executes the real paste handler.
Tests
4477 → 4503 passing (+26 regression tests across the 10 PRs). 0 regressions. Full suite ~135s.
Pre-release verification
- Stage-301 build: 10 PRs merged with zero conflicts (each rebased clean against current master).
- All JS files syntax-clean (
node -c static/boot.js && node -c static/ui.js). - All Python files syntax-clean (py_compile on every changed file).
- Live browser walkthrough on port 8789: model picker chip + group count rendering, all
/api/wiki/status,/api/logs,/api/provider/quota,/api/health/agentendpoints respond 200, sidebar scroll fix preserved,boot.jsPR #1706 fix verified live (pasteTs captured outside map, index parameter present, Date.now() removed from inside .map()). - Opus advisor pass on 9-PR variant (with #1705 in slot 10): SHIP, 7/7 verification questions resolved cleanly. Late swap to #1706 keeps identical fix shape (same
pasteTsoutside map + index suffix); Opus's verification answers carry over because the production diff is unchanged.
Notes on the 1705 → 1706 swap
@Michaelyklam filed PR #1706 with a functional Node-driven regression test (extracts the real paste handler and asserts two pasted image items become two pending attachments) replacing my own #1705 which used static-source-string assertions. Same code fix, better test approach. Closed #1705 and absorbed #1706 into stage-301.
[v0.51.3] — 2026-05-04 — 3-PR follow-up batch (#1671, #1673, #1676) + test-fragility fix
Added
- PR #1671 by @Michaelyklam — Active provider quota status (refs #706). New
GET /api/provider/quota?provider=Xendpoint with OpenRouter implementation:_PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0, server-side credentials only, sanitized output (limit_remaining,usage,limit,status). Safe states for no active provider, missing OpenRouter key, invalid key, timeout, unsupported provider. New "Active provider quota" card in Settings → Providers panel above existing provider cards. 7 regression tests pin route, success, error paths, and UI wiring. - PR #1673 by @Michaelyklam — LLM Gateway routing metadata (refs #732). Surfaces gateway routing telemetry inline in chat without requiring refresh. New
Session.gateway_routing(latest) +Session.gateway_routing_history(per-turn, capped at 50 entries). SSEdonepayload now carriesusage.gateway_routing. Assistant message footers display served model+provider when failover or model-switch occurs. Sidebar session metadata uses gateway-aware label via_formatSessionModelWithGateway(s). Bounded persistence:routingarray capped at 12 attempts, scalar strings capped at 240 chars. 28 regression tests pin metadata capture, fallback, persistence, and display hooks. - PR #1676 by @Michaelyklam — Hermes agent heartbeat alert (closes #716). New
api/agent_health.pymodule withhealth_check_agent()returning{alive, checked_at, details}(alive can betrue/false/null). Usesgateway.statusruntime metadata +get_running_pid(cleanup_stale=False). No shell-outs, no psutil dependency — explicit regression tests assert"import psutil" not in srcand"import subprocess" not in srcin agent_health.py. Sticky banner above composer (default-hidden) with 30s visible-tab polling and dismiss persistence. Visibility-tab gate prevents banner spam during background-tab idle. Allowlist-filtered runtime details (nocwd/cmdline/environ/username/exeleakage). 12 regression tests.
Fixed
tests/test_session_lineage_collapse.pyMAX_ARG_STRLEN failure — Pre-existing test fragility:_run_nodeinvokedsubprocess.run([NODE, "-e", source])where<source>embeds the entirestatic/sessions.jscontent. Linux'sMAX_ARG_STRLENis 131,072 bytes per argv arg; with sessions.js plus the test scaffolding (eval'ing 5+ functions), the source string crossed that threshold after #1673's additions, producingOSError: [Errno 7] Argument list too long. Switched_run_nodeto pass source via stdin (no argv-size limit). No behavioral change to the tests themselves.
Pre-release verification
- Full pytest sequential pass: 4457 → 4477 passing (+20). 0 regressions.
- JS syntax check on 4 modified
.jsfiles vianode -c: all clean. - Python syntax check on 10 modified
.pyfiles: all compile clean. - QA harness: ALL CHECKS PASSED.
- Live browser verification on 56-session sidebar:
/api/provider/quotareturns 200 with proper "No active provider" empty state. Settings → Providers shows quota card./api/health/agentreturns 200. Banner exists in DOM buthidden=trueand not.visible(correct — agent healthy in fixture).- All 4 gateway helpers (
_formatGatewayModelLabel,_gatewayRoutingFailoverText,_gatewayModelWarningText,_formatSessionModelWithGateway) defined in global scope. - Sidebar scroll fix from v0.51.2 still works (regression check).
- Independent review: Opus advisor on stage-300 diff (1050 LOC). 7/7 verification questions verified clean: process-field filtering, OpenRouter error sanitization, gateway-model label correctness, sidebar fallback when no routing data, loop preamble + segments-map population, banner positioning, visibility-tab gate. Verdict: SHIP. 0 MUST-FIX, 0 SHOULD-FIX. Only nit: dead
position:sticky;bottom:0on.agent-health-banner(harmless cosmetic CSS, deferred to follow-up).
Surgical conflict resolution highlights
All 3 PRs branched off pre-v0.51.0 master and required surgical resolution:
- #1671 routes.py: kept master's
_handle_pluginsroute from v0.51.1 #1663 + added new quota route below (both routes preserved adjacent). - #1673 sessions.js: kept master's
_getChannelLabel+readOnlymetaBits AND swapped master'sif(s.model) metaBits.push(s.model)for contributor's_formatSessionModelWithGateway(s)call. Net effect: gateway-aware model line + existing channel/readOnly bits preserved. - #1673 ui.js: 2 conflicts in the assistant-footer rebuild loop. Kept master's
renderedAssistantIdxs=[...assistantSegments.keys()].sort()pattern (more robust than contributor's DOM-index-basedasstRows[ai]), added contributor's gateway-routing extractions inside the loop. Footer skip-condition extended with&&!gatewayText&&!failoverText&&!modelWarningText. Selector check extended for new inline class names. - #1676: clean rebase, no conflicts.
Both #1671, #1673, and #1676 rebased branches force-pushed back to @Michaelyklam's fork via maintainer write access, preserving Co-authored-by: attribution.
UX gate re-evaluation
PRs #1671, #1673, #1676 had been UX-gated in the v0.51.1 sweep, then on second-look determined to NOT warrant the gate per the "main-conversation-view-only" threshold:
- #1671 is a Settings → Providers panel (not main conversation surface).
- #1673 adds metadata to assistant message footers, but only conditionally visible when failover or model-switch actually happens. Most users never see it.
- #1676 banner is
hiddenby default and only appears when agent becomes unreachable. Conditional safety indicator, not active UX surface.
UX label cleared, Aron stand-down comments deleted on all 3, all 3 swept into this batch.
[v0.51.2] — 2026-05-04 — 3-PR follow-up batch (deferred from v0.51.1) + sidebar scroll hotfix
Fixed
- Sidebar scroll jumps back to 0 on small lists (≤80 sessions) — PR #1669 added DOM virtualization to
renderSessionListFromCache()with two flaws for lists below the virtualization threshold: (1) the unconditional scroll listener triggered a full DOM rebuild on every rAF, and (2)scrollTopwas only restored whenvirtualWindow.virtualizedwas true (i.e. total > 80 rows). For lists ≤ 80 rows,scrollTopdropped to 0 on every scroll event, producing a "scroll keeps jumping back" feel. Two-part fix: (a) always restorescrollTopwhenlistScrollTopBeforeRender > 0regardless of virtualized flag, (b) short-circuit_scheduleSessionVirtualizedRenderwhen total ≤SESSION_VIRTUAL_THRESHOLD_ROWS(saves the wasteful rebuild and is belt-and-suspenders defense). Live verified: production v0.51.1 confirmed broken (scrollTop drops to 0 within 100ms); v0.51.2 confirmed working (holds at 500 across 600ms+). 3 regression tests pin both fixes.
Added
- PR #1664 by @Michaelyklam — LLM Wiki status panel (closes #1257). New read-only Insights card showing wiki state (entries, pages, raw files, last updated, last writer) with traffic-light status badge ("Available" / "Empty" / "Unavailable" / "Error"). New
GET /api/wiki/statusendpoint readsWIKI_PATHenv var orskills.config.wiki.pathconfig, returns metadata-only counts.loadInsights()parallelizes the wiki status fetch with the existing/api/insightscall viaPromise.all, with a.catchfallback so wiki failures don't break Insights. - PR #1662 by @Michaelyklam — Logs tab MVP (closes #1455). New top-level Logs tab in nav rail. Allowlisted server-side log file viewer (
agent/errors/gateway) with severity highlighting (info/warning/error/debug), tail size selector (100/200/500/1000 lines), auto-refresh, copy-all. NewGET /api/logsendpoint with strict allowlist + path-traversal guard + bounded 4 MiB tail window. 8 i18n locale entries added. - PR #1587 by @franksong2702 — Filter low-value CLI agent sessions (refs #1013). Source-aware sidebar visibility rules for imported CLI agent sessions: hides empty CLI rows; hides default/untitled CLI rows with fewer than 2 user turns; keeps explicitly-titled CLI sessions; keeps compression-lineage CLI sessions. Treats true CLI-origin rows as external/imported in action menu (keeps pin/move/archive/restore, hides duplicate/delete). New
_isCliSession(session)helper in static/sessions.js for source classification.
Pre-release verification
- Full pytest sequential pass: 4429 → 4457 passing (+28). 0 regressions.
- JS syntax check on 6 modified
.jsfiles vianode -c: all clean. - Python syntax check on 9 modified
.pyfiles: all clean. - QA harness: 20 pytest + 11 browser API +
/healthprobe — ALL CHECKS PASSED. - Browser-driven smoke test on 56-session sidebar:
- Logs tab: panel renders with file/tail selectors; 4 test log lines (INFO/WARNING/ERROR/DEBUG) all rendered with correct severity classes.
- LLM Wiki card: renders in Insights tab with proper "Unavailable" state and 6-grid metadata layout. Existing Insights chart (#1668) renders unaffected.
_isCliSessionhelper: 6/6 test cases correct (null, empty object, session_source=cli → true, raw_source=CLI → true, source_label=cli → true, raw_source=web → false).- Sidebar scroll: scrollTop=500 holds steady across 100/300/600ms; scroll-to-bottom (1986) holds across 600ms.
- Path traversal:
/api/logs?file=../../etc/passwdcorrectly returns HTTP 400.
- Independent review: Opus advisor on stage-298 diff (1336 LOC). 6/6 verification questions resolved cleanly: SSRF safety, path traversal, schema redaction, JS XSS prevention, scroll-fix first-render edge case, CHANGELOG handling. Verdict: SHIP. 0 MUST-FIX, 2 SHOULD-FIX absorbed in-release (see below).
Opus-applied fixes (absorbed in-release)
From stage-299 absorption (this release):
- Bounded WIKI_PATH walk + forbidden-root guard (
api/routes.py):_LLM_WIKI_MAX_FILES = 10000capsrglobiteration in both_llm_wiki_count_filesand_llm_wiki_page_files(prevents hangs on symlink loops or pathologically-large trees)._LLM_WIKI_FORBIDDEN_ROOTSblocklist refuses/,/etc,/usr,/var,/opt,/sys,/proceven ifWIKI_PATHis misconfigured to point at them. Self-DoS prevention:/api/wiki/statusfires on every Insights tab open viaPromise.all, and unboundedrglobon a misconfigured root would block the endpoint. 6 regression tests pin the constants + behavioral guards. - URL-scheme guard for
docs_urlinterpolation (static/panels.js):rawDocsUrlis regex-validated against/^https?:\/\//ibefore being interpolated into the<a href=>attribute.esc()HTML-escapes but doesn't validate URL scheme;docs_urlis server-controlled today but the contributor scaffolded it for potential config-driven use, so future-proofs againstjs:/data:scheme XSS.
Surgical conflict resolution
All 3 PRs branched off pre-Kanban-v1 master, producing multi-region conflicts in static/panels.js and static/style.css. Resolved per-conflict surgically rather than via naive keep-both:
- #1664 panels.js: kept master's modern
_renderInsightsbody (preserves the v0.51.1 chart enhancements from #1668), modified its signature to acceptwikiStatusas 3rd parameter, AND inserted the two new wiki helper functions (_formatLlmWikiTimestamp,_renderLlmWikiStatus) before it. Verified single_renderInsightsdefinition. - #1664 style.css: kept master's
.insights-card { margin-bottom: 16px }(used by other Insights cards) and ADDED all the new.wiki-status-*rules. Discarded contributor's modification of.insights-card(would have broken #1668 chart card spacing). - #1662 panels.js: panel-list array union'd to include both
'kanban'(v0.51.0) and'logs'(this PR). Large additive region: kept BOTH the master's Kanban switcher/modal block AND the contributor's Logs panel block. Patched a missing pair of closing braces (}\n}\n) at the boundary where the conflict marker truncatedarchiveKanbanBoard. - #1662 style.css: display-none selector union'd to include
#mainInsights, #mainLogsAND:not(.showing-kanban):not(.showing-logs)chain. - #1587 sessions.js: kept master's
_isReadOnlySessionand_sourceKeyForSessionhelpers AND added the new_isCliSessionhelper. Patched a missing closing brace on_sourceKeyForSessionintroduced by conflict-marker truncation.
Both #1664 and #1662 rebased branches were force-pushed back to @Michaelyklam's fork via maintainer write access (preserving Co-authored-by: attribution). #1587 stayed local since the maintainer token doesn't have write access to franksong2702's fork.
[v0.51.1] — 2026-05-04 — 11-PR contributor batch from @Michaelyklam
Added — 11 PRs from a single overnight burst, all per-PR Phase-0 fit-screened
- #1672 by @Michaelyklam —
ctl.shdaemon lifecycle script (start/stop/restart/status/logs). Closes #591.- PID ownership via
~/.hermes/webui.pidwith stale-PID cleanup, SIGTERM wait + SIGKILL fallback. statuscombines local PID state with/healthprobe output.- PID-reuse safety: signals only sent when args check confirms the PID's process is the WebUI.
- 195 LOC of tests using temp homes + fake bootstrap targets so no real WebUI is killed during testing.
- PID ownership via
- #1665 by @Michaelyklam — Windows WSL autostart helpers. Closes #513.
scripts/wsl/hermes_webui_autostart.sh(lock file, health check, pid file) for WSL shell startup.scripts/windows/setup_webui_autostart.ps1(idempotent Task Scheduler helper, ShouldProcess/-WhatIf, MultipleInstances IgnoreNew) for Windows logon startup.docs/wsl-autostart.mdcovers both install paths and the diagnostic commands.
- #1666 by @Michaelyklam — DOM windowing for long sessions. Closes #734.
MESSAGE_RENDER_WINDOW_DEFAULT = 50; renders only ~window of messages around viewport instead of all N.
- #1669 by @Michaelyklam — Sidebar list virtualization. Refs #500.
- 1000+ session sidebars now render with constant DOM size; spacers above/below the visible window.
selectAllSessionsupdated to use_sessionVisibleSidebarIdsso virtualization doesn't break "select all" silently.
- #1678 by @Michaelyklam — Claude Code session imports.
- Reads
~/.claude/projects/*.jsonland surfaces them in the sidebar withdata-source-key="claude_code"styling. - Read-only — no clone/duplicate/delete on Claude Code rows.
- HERMES_WEBUI_TEST_STATE_DIR explicitly disables real-home scan inside test envs.
- Symlink + oversized-file guards layered at root, project_dir, and file levels (no follow-symlink reads).
- Reads
- #1663 by @Michaelyklam — Plugins visibility panel. Closes #539. Read-only Settings → System → Plugins panel showing plugin/hook config.
- #1670 by @Michaelyklam — MCP server visibility panel. Closes #696.
- Replaces the prior buggy add/delete UI with a read-only visibility panel.
GET /api/mcp/serversextended withenabled,active,status,tool_count,connect_timeout,toggle_supported: false,reload_required: true.- Backend add/delete tests preserved.
- #1679 by @Michaelyklam — MCP tool inventory. Refs #697 #696.
- Searchable Settings → System → MCP Tools panel.
GET /api/mcp/toolswith sanitized rows (tool name, source server, description, active/enabled/status, compact schema summary).- Schema redaction: parameter name/type/required/description only; defaults/examples/raw schema OMITTED; descriptions Authorization-bearer-token redacted, capped at 180 chars/param + 360 chars/tool.
- #1667 by @Michaelyklam —
/statusslash-command card. Closes #463. Opt-in slash command shows session info card (model, provider, project, message count, tokens). - #1668 by @Michaelyklam — Insights tab token trends + per-model cost breakdown. Closes #1456.
- Defense-in-depth empty-state handling: client guard
if (dailyTokens.length);Math.max(..., 1)to prevent division-by-zero; server-sideif total_tokens else 0guards.
- Defense-in-depth empty-state handling: client guard
- #1674 by @Michaelyklam — Scheduled job profile selector in cron form. Refs #617.
- #1677 by @Michaelyklam — Official Hermes dashboard link in top-bar. Closes #1459.
- New
api/dashboard_probe.pyprobes localhost:9119 for the Hermes Agent dashboard; shows "Dashboard ↗" link if running, hidden otherwise. - SSRF-safe:
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"},DEFAULT_DASHBOARD_TARGETSonly loopback, GET-only, hardcoded/api/statuspath, no DNS lookups outside loopback.
- New
Tests
4356 → 4429 passing (+73 regression tests across all 11 PRs). 0 regressions on the full sequential suite. 2 skipped (env-dependent), 3 xpassed (expected failures that pass).
Pre-release verification
- Full pytest sequential pass — 4429 passing, 0 failures, 113s runtime.
- JS syntax check on 6 modified
.jsfiles — all parse clean (node -c). - Python syntax check on 19 modified
.pyfiles — all compile clean. - QA harness: 20 pytest + 11 browser API checks +
/healthprobe — ALL CHECKS PASSED. - Independent review: Opus advisor on stage-298 diff (4749 LOC). 6/6 security/correctness questions verified clean: SSRF safety on dashboard probe, Claude Code symlink guards, MCP tool schema redaction, ctl.sh PID identity check, sidebar virtualization correctness, Insights division-by-zero. Verdict: SHIP. No MUST-FIX or SHOULD-FIX flagged. Two non-blocking polish notes deferred to follow-up: optional post-DNS IP-validation on
dashboard_probe, and macOSps -wwfor ctl.sh args inspection.
Deferred from this batch
- #1664 (LLM Wiki status panel) and #1662 (Logs tab MVP): Both contributor branches predated the v0.51.0 Kanban v1 merge from earlier today. The resulting multi-conflict regions in
static/panels.js(panel-list array + section-marker block +archiveKanbanBoardfunction boundary) needed careful per-conflict surgery that's better handled as standalone follow-up work. Posted detailed deferral comments on each PR offering either contributor-rebase or maintainer-takes-it. - #1587 (CLI session filter): CONFLICTING — comment posted requesting rebase.
Author note
This release ships a contributor-burst pattern (17 PRs from @Michaelyklam in 51 minutes overnight). Despite the volume, per-PR claim-vs-diff verification showed no AI-tells, all PR descriptions matched their diffs, all closes #N references pointed at real open issues, and security-relevant code paths (file-system reads, outbound HTTP, PID handling, schema redaction) check out under independent review. Eleven PRs landed cleanly in this batch; the remaining six were either deferred for conflict resolution or already in held-state with maintainer-review labels.
[v0.51.0] — 2026-05-04 — Kanban v1
Added — Kanban v1: complete first-party Kanban for Hermes (closes #1645, #1646, #1647, #1649, #1654, #1655, #1660, #1675)
The full Kanban feature lands as a 12-commit stack giving the WebUI first-party-compatible parity with the Hermes Agent dashboard plugin's Kanban surface. A small team can now run their entire ticket-tracking flow directly inside the WebUI panel, sharing a single source of truth (~/.hermes/kanban.db + per-board ~/.hermes/kanban/boards/<slug>/kanban.db) with the agent CLI, gateway slash commands, and dashboard.
Stacked on previously-shipped foundation (v0.50.275–v0.50.297 introduced read-only Kanban panel, write semantics, task detail expansion, dashboard-parity core controls, UI parity polish, and review-feedback hardening). This release completes the picture with multi-board management and real-time event streaming.
Multi-board management (#1675, ~1900 LOC of new feature work):
- 5 new endpoints mirroring the agent dashboard plugin contract verbatim:
GET /api/kanban/boards— list all boards with per-status task counts + active-board pointerPOST /api/kanban/boards— create board (idempotent on slug)PATCH /api/kanban/boards/<slug>— rename / update display metadata (slug is immutable)DELETE /api/kanban/boards/<slug>— archive (default; reversible fromkanban/boards/_archived/) or?delete=1hard-deletePOST /api/kanban/boards/<slug>/switch— set active board (writes shared cross-process pointer at<root>/kanban/current)
- All existing per-board endpoints accept
?board=<slug>query param (orboardin JSON body); query takes precedence over body - Frontend:
Default ▾switcher pill in the panel header, click-anchored menu listing every board (current first) with per-status total badges + 3 actions (New / Rename / Archive). Modal handles both create and rename (slug auto-derives from name with manual override). Archive routes through the existingshowConfirmDialogwith a clear "tasks remain on disk and the board can be restored from kanban/boards/_archived/" message. - Active-board state persists to
localStorage['hermes-kanban-active-board']so a refresh stays put. The on-disk pointer is the cross-process source of truth, kept in sync via the switch endpoint. - Default board is protected from deletion (would leave system without fallback active board).
- Slug normalisation goes through
kb._normalize_board_slug()which rejects path-traversal patterns (../etc/passwd,..\windows) at validation time.
Real-time SSE event stream (#1675):
- New
GET /api/kanban/events/streamlong-lived Server-Sent Events endpoint mirroring the agent dashboard's WebSocket/eventscontract event-for-event - 300ms server-side poll interval (matches agent dashboard's
_EVENT_POLL_SECONDS), 200-event batch cap, 15s heartbeat keepalive - Each
event: eventsframe emitsid: <event_id>so EventSource auto-storesLast-Event-IDand resumes from the right cursor on reconnect; server readsLast-Event-IDfrom request headers as a fallback when?since=is absent (cross-drop resume without re-streaming the backlog) - Frontend uses
EventSourceby default with automatic fallback to 30s HTTP polling after 3 consecutive SSE failures (proxy stripstext/event-stream, etc.) - 250ms debounce on event bursts coalesces N events into a single board re-fetch
- SSE stream torn down cleanly when the user leaves the Kanban panel (no leaked threads on a long-running session)
- Why SSE not WebSocket: the WebUI's existing transport is synchronous
BaseHTTPServer. WebSocket would require an async refactor or a hijack-the-socket hack. SSE is the right tool for unidirectional server-pushed event streams, matches the existing/api/approval/streamand/api/clarify/streampatterns, and gives identical write-to-receive latency (~300ms) versus the agent dashboard's WebSocket path.
Bridge hardening (#1660 + #1675 polish):
read_onlyflag now reports honest state across all 4 payload sites (_board_payload,_events_payload,_task_log_payload, no-change short-circuit). Was hardcodedTruefrom the read-only-bridge era of #1645; bridge has been writable since #1649.ImportErrorfallback: whenhermes_cliisn't installed (webui-only deploy), all 4 verb handlers (GET/POST/PATCH/DELETE) return clean503 kanban unavailable: <reason>instead of bubbling 500s.- Dispatcher contract enforcement (
a39ec45): bridge rejects rawPATCH status='running'with 400 + clear error message. Direct status writes torunningwould bypass theclaim_lock/claim_expires/started_at/worker_pidmachinery, breaking dispatcher coordination. The frontend never sendsrunning(button removed + drop-target disabled); the bridge is defense-in-depth._set_status_direct()helper mirrors the agent dashboard's same-named function for legitimate non-running transitions, nulling claim fields and closing active runs withoutcome='reclaimed'when leavingrunning. blocked → readytransitions route throughkb.unblock_task()(firesunblockedevent for live polling consumers), not raw UPDATE.done → archivedtransitions route throughkb.archive_task().- Archive race fix: two-layer defense against
kb.connect(board=<slug>)auto-materialising the directory + sqlite on first call, which would silently un-archive a board that was just removed. Frontend stops the SSE stream BEFORE theDELETEcall (restarts on failure); bridge's_kanban_sse_fetch_newcheckskb.board_exists()beforeconnect(), returning empty results when the board is gone. - CSS injection fix (
60874db, caught during independent security audit):b.colorwas being interpolated into astyle=""attribute viaesc()which HTML-escapes but doesn't prevent CSS-context injection (e.g.color="red;background:url('http://attacker/exfil')"). New_kanbanSafeColor()helper allowlists only^#[0-9a-fA-F]{3,8}$hex codes or^[a-zA-Z]{3,32}$named colors; everything else collapses to empty and the renderer drops the rule entirely. - Routing-asymmetry fix (Opus SHOULD-FIX #1):
PATCH/DELETE /api/kanban/boards/<slug>now match the/boards/<slug>path BEFORE resolving?board=. A stray?board=ghostquery param on aPATCH /api/kanban/boards/experiments?board=ghostno longer 404s onghost— it correctly editsexperiments. Mirrors the POST handler's structure.
Mobile responsive:
- 9 new rules under the existing
@media (max-width: 640px)block covering the multi-board UI: switcher button (smaller padding/font), board-name truncation at 140px max-width, dropdown menu sized atmin(280px, 100vw - 24px), modal padding tightens, inline-row icon/color picker stacks vertically.
Polish:
- Accent-tinted Save button in the modal (was visually identical to Cancel before)
- Modal + dropdown menu now use the same
linear-gradientpanel + accent border pattern as the existingapp-dialogoverlay (was using undefinedvar(--panel)falling back to transparent) - "Read-only view" banner now hidden by default in HTML and only shown when the bridge actually reports
read_only=true(was permanently visible regardless of state)
Tests
4288 → 4356 passing (+68 net).
tests/test_kanban_bridge.py: 18 → 41 tests (+23 covering board CRUD, slug validation, default-board protection, dispatcher routing, board isolation viaconnect()spy, SSE backlog/error-recovery/integration with worker thread + threading.Event watchdog, SSEid:lines, Last-Event-ID resume, PATCH/DELETE routing-order regression)tests/test_kanban_ui_static.py: 15 → 27 tests (+12 covering switcher markup, modal markup, JS handler presence, REST verb usage, board-param plumbing, localStorage persistence,showConfirmDialogusage, EventSource subscription, polling fallback, panel-switch teardown, debouncing, CSS-injection regression)
Total Kanban-specific test coverage: 33 → 68 tests (+35).
Pre-release verification
- Independent review (nesquena): APPROVED with one CSS-injection MUST-FIX caught and pushed before approval (
60874db). Cross-tool checks against freshnousresearch/hermes-agenttarball verified contract-for-contract parity withplugins/kanban/dashboard/plugin_api.pyfor all/boardsendpoints +/eventsSSE wire format. - Opus advisor on PR #1675 stage diff: SHIP verdict. Two SHOULD-FIX items applied with regression tests (PATCH/DELETE routing reorder + SSE
id:lines / Last-Event-ID resume). MUST-FIX: 0. - Live end-to-end browser verification on port 8789: Multi-board switcher, create/rename/archive flows, SSE 400ms live delivery, 5-task burst with 250ms debounce,
?board=isolation across two boards, Last-Event-ID resume, CSS-injection fix renders safely. Zero JS errors throughout 11-step flow.
Acknowledgments
This was a large stack of work. Massive thanks to @ai-ag2026 for the full Kanban implementation across 12 commits. Reviewer security audit + CSS-injection fix by @nesquena. Multi-board + SSE design and integration by @Michaelyklam with AI-assist co-authorship.
[v0.50.297] — 2026-05-04
Fixed (3 PRs — closes #1658; refs #1458, #1652)
-
Docker container no longer enters a crash loop on every normal Docker setup (#1659 by @bergeouss, closes #1658) — PR #1635 (v0.50.295) added a writability guard
[ ! -w /etc/group ] || [ ! -w /etc/passwd ]for podmanread_only=truecontainers. Bug: the script runs as the non-roothermeswebuitoouser, so/etc/group(owned by root) is always non-writable from that user — guard fires on EVERY normal Docker setup, container enters a crash loop with!! ERROR: Cannot modify /etc/group or /etc/passwd (read-only root fs). Affects all users running standard Docker after upgrading to v0.50.295. Fix: replace[ ! -w ]with! sudo sh -c 'test -w /etc/group && test -w /etc/passwd' 2>/dev/null— matches the fact thatgroupmod/usermodalready use sudo a few lines below. Truly read-only rootfs (podman) → sudo can't write → guard fires correctly. Writable rootfs (normal Docker) → sudo can write → guard doesn't fire → groupmod/usermod runs normally. 3 LOCdocker_init.bashchange. P0 regression fix. -
OAuth Cancel during Codex device-token exchange now wins the race (#1653 by @nesquena, follow-up to #1652 / refs #1362) — race in v0.50.296's Codex OAuth onboarding flow where a
POST /api/onboarding/oauth/cancelarriving while the worker was mid-network-call would be silently overridden: credentials would still get persisted toauth.jsonand the flow status would flip fromcancelled→success. Net effect: the user's explicit cancel was ignored, credentials persisted, UI reported success. Fix: re-check_OAUTH_FLOWS[flow_id].statusunder_OAUTH_FLOWS_LOCKimmediately AFTER_exchange_codex_authorization()returns and BEFORE writingauth.json. If status is no longerpending, return cleanly — no persistence, no status overwrite. Behavioral test usingthreading.Eventdeterministically reproduces the race. UX-inconsistency severity, not a security bug (the credentials that get persisted ARE tokens the user authorized in their browser), but the cancel button stops doing what it says, violating the design intent of #1650's server-owned lifecycle. -
Persistent-host health diagnostics + watchdog hardening (#1657 by @Michaelyklam, refs #1458) — addresses the residual #1458 Bug #3 failure mode (process alive + port listening but HTTP requests not advancing), the wedge that survives after v0.50.275's FD-leak fix and v0.50.269's bootstrap fix. Adds three signals process supervisors can use to distinguish "process exists" from "request handling is still advancing":
- Accept-loop heartbeat:
QuietHTTPServer.accept_loop_requests_total+accept_loop_last_request_atinstance attributes, incremented in_handle_request_noblock()(singleserve_forever()thread, un-locked+=is safe). Surfaced in/healthasaccept_loop: {requests_total, last_request_at}. /health?deep=1readiness probe: boundedSTREAMS_LOCK.acquire(timeout=0.5)+all_sessions()walk +load_projects(_migrate=False)+sqlite3.connect(state.db) + PRAGMA schema_version. Returns 503 withstatus: degradedwhen streams lock blocks or any deep check errors. Watchdogs polling/health?deep=1every 30s open-and-close 2880 short-lived sqlite connections per day per probe — bounded FD usage, no leak surface.RLIMIT_NOFILEraise to 4096 at startup (best-effort, defense in depth for macOS launchd jobs that start at 256). Doesn't hide future FD leaks; gives diagnostic headroom before request handling falls over.docs/supervisor.mdupdates: launchd/systemd HTTP watchdog recipe usingcurl -fsS --max-time 10 /health?deep=1+launchctl kickstart -k. Notesaccept_loop.requests_totalshould advance — if it stays flat while the process is alive, the accept loop is wedged.
Per Opus advisor on stage-297: refactored
_deep_health_checks(stream_check=...)to accept the pre-computed stream check from_handle_health()so we don't acquireSTREAMS_LOCKtwice on the same/health?deep=1request (cosmetic inefficiency, not a correctness bug — but also could false-fail when the second acquire times out under contention). Plus a docstring note on_handle_request_noblockdocumenting why the un-locked+=is safe (single-thread-only call site in CPython socketserver).PR #1656 by the same author (smaller, module-level globals approach) was closed as superseded by #1657 (instance-level + state.db check + projects check + supervisor.md docs).
- Accept-loop heartbeat:
Tests
4284 → 4288 passing (+4 regression tests across tests/test_issue1458_stability_hardening.py (3) + tests/test_issue1362_codex_oauth_onboarding.py::test_cancel_during_token_exchange_does_not_persist_credentials (1)). 0 regressions. Full suite ~118s.
Pre-release verification
- Opus advisor on stage-297 combined diff: SHIP verdict. All 9 verification questions cleared:
_active_state_db_path()verified atapi/models.py:924, returns Path without opening connection- 500ms
STREAMS_LOCK.acquire(timeout=...)ceiling reasonable for watchdog timeouts (10s curl--max-timetypical) with closing(sqlite3.connect(...))deterministically releases FD,PRAGMA schema_versionis read-only_handle_request_noblockheartbeat increment is BEFORE super() — counter advances even if request handling raises, correct accept-loop semantics_raise_fd_soft_limit()correctly clamps to hard limit, only RAISES soft limit (won't lower below launchd'sLimitNOFILEsetting)- OAuth fix narrows race window from "seconds-long network call" to "microseconds-long file write" — minimal correct change at the right layer
- Docker fix
sudo sh -c 'test -w'correctly handles all 3 cases (writable+sudo / readonly+sudo / no-sudo)
- Two minor Opus follow-ups absorbed in-release:
_deep_health_checks(stream_check=...)reuses pre-computed stream check from_handle_health()— saves redundant lock acquisition- Docstring note on
_handle_request_noblockdocumenting single-thread safety of un-locked+=
- Self-built #1653 has thorough
threading.Event-gated behavioral test demonstrating the race exists pre-fix and is fixed post-fix. - Browser API sanity: 11/11 endpoints OK on stage server.
- Conflict resolution: zero file overlap across all 3 PRs (#1659 → docker_init.bash; #1653 → api/oauth.py; #1657 → api/routes.py + server.py + docs/supervisor.md). Auto-merged clean.
Authors
- @bergeouss — 1 PR (#1659, AI-assisted via Hermes Agent) — fixing their own v0.50.295 #1635 regression
- @nesquena (self-built) — 1 PR (#1653, follow-up to v0.50.296 #1652)
- @Michaelyklam — 1 PR (#1657, hardening for #1458 Bug #3)
Note on closed-as-superseded
PR #1656 (also @Michaelyklam) was closed as superseded by #1657. Both target #1458 Bug #3, both add accept-loop heartbeat + /health?deep=1 + 503-on-degraded. #1657 adds beyond #1656: state.db connectivity check, projects state check, FD soft-limit raise, and docs/supervisor.md watchdog recipe. Same author iterated; the second PR was the keeper.
[v0.50.296] — 2026-05-04
Fixed (3 PRs — closes #1406, #1617; refs #1362)
-
Per-turn TPS now visible in assistant message headers (default-off, opt-in via Preferences) (#1640 by @Michaelyklam, closes #1617) — UX gate APPROVED by @aronprins with default-off + opt-in setting addition. Previously
_turnTpscalculation existed inapi/streaming.pybut was rendered into a global titlebartpsStatelement that's been hidden by default since v0.50.x. Newshow_tpsboolean setting in Preferences (defaultfalse) renders an inline.msg-tps-inlinechip in each assistant message header when enabled. Useful for power users tuning local-model setups (LM Studio, Ollama, llama.cpp, vLLM) where TPS varies turn-to-turn based on context length, parallel slots, and prompt complexity. Backend changes:api/metering.pyadds explicittps_availablefield (boolean — strict, requires both real exact token count AND backend-measured turn duration), drops placeholder0.0TPS when no real reading exists, switches live counting from character-count-derived text length to streaming-callback deltas. Final_turnTpscomputed from exact final output token usage divided by backend-measured turn duration when both available, persisted on assistant message and sent indonepayload only when both signals available. Hot-apply: Preferences autosave updateswindow._showTpsglobal, clears the message render cache, and re-renders messages — toggling the setting reflects in open tabs without refresh. UI evidence underdocs/pr-media/1640/showing default-off transcript, hot-apply with TPS visible, and the Settings → Preferences toggle. -
Operator-level config knob for first-turn session save timing (#1648 by @Michaelyklam, closes #1406) — operators wanting crash-resilience for the user's first prompt (vs accepting the first prompt being in-memory-only until streaming begins) now have a
webui.session_save_modeconfig.yaml knob with valuesdeferred(default — preserves the v0.50.230 fix for #1171 orphan-Untitled files) andeager. Eager mode materializes the user message intos.messagesbefore launching the agent thread, plus updates_apply_core_sync_or_error_marker(WAL/repair path) and the streaming-thread context-build path (_drop_checkpointed_current_user_from_context) to avoid double-counting the user turn. Implementation matches @nesquena-hermes's prescribed shape from #1406's maintainer comment 1:1 — no Settings UI toggle (operator-level only), default stays deferred (orphan-Untitled hygiene preserved), threshold is "≥1 user message" not "didnew_session()get called" (so empty-new-chat-then-switch-away doesn't recreate the orphan-file class). Validated_WEBUI_SESSION_SAVE_MODES = {"deferred", "eager"}; unknown values fail closed todeferred. 132-LOC test file covering both modes + WAL/repair interaction + duplicate-context filtering. -
In-app OAuth onboarding flow for OpenAI Codex (#1650 by @Michaelyklam, refs #1362) — three new endpoints:
POST /api/onboarding/oauth/start(initiates the device-code flow),GET /api/onboarding/oauth/poll?flow_id=...(returns high-level status:pending|success|expired|cancelled|error),POST /api/onboarding/oauth/cancel(aborts an in-flight flow). Server-owned lifecycle: all sensitive provider state (device_auth_id, code_verifier, authorization_code, access_token, refresh_token, token_data) lives in a process-local_OAUTH_FLOWSdict keyed by an opaque WebUI-localflow_id(UUID4). Browser only seesflow_id,user_code,verification_uri, status — never raw OAuth lifecycle secrets. 15-minute flow timeout. Token persistence: successful Codex credentials write to the active profile'sauth.jsoncredential_pool.openai-codex(atomic tmp+rename, chmod 0o600 on tmp BEFORE rename so final file never has world-readable window, defense-in-depth post-rename chmod). Allowlist_ALLOWED_ONBOARDING_OAUTH_PROVIDERS = {"openai-codex"}; explicit blocklist for anthropic/claude/nous/qwen/gemini/minimax/copilot (rejected with generic "Only OpenAI Codex OAuth is supported in WebUI onboarding right now" — no internal triage state leaked). Implementation matches @nesquena-hermes's prescribed shape from #1362's maintainer comment 1:1 (server-owned state machine, no client-side device codes, abort endpoint, profile-scoped storage, opt-in). Updatedstatic/onboarding.jsfor theopenai-codexOAuth-pending path with clickable verification URL, prominent user code with copy-to-clipboard, abort button. Updated Codex auth endpoints to current Hermes Agent Codex protocol:https://auth.openai.com/api/accounts/deviceauth/usercode,.../api/accounts/deviceauth/token,.../oauth/token. 182-LOC test file covering route shape, secret-leak prevention, allowlist, expiration, cancellation, profile-scoped credential write, frontend endpoint usage, and the unsupported-provider note copy update. First step on the #1362 sprint roadmap — Anthropic Claude OAuth is the planned v2.
Tests
4255 → 4284 passing (+29 regression tests across tests/test_issue1617_tps_message_header.py (31), tests/test_session_save_mode.py (~13 new + edits), tests/test_issue1362_codex_oauth_onboarding.py (9), plus existing test updates for context-window-persistence, preferences-autosave). 0 regressions. Full suite ~120s.
Pre-release verification
- Opus advisor on stage-296 combined diff: SHIP verdict. All 14 verification questions cleared, with focused OAuth security audit on #1650 (in-memory flow lifecycle correct, lock not held during network IO, no flow_id leakage path, allowlist fail-closed, chmod-before-rename correctly implemented per the prior security-fix pattern, sensitive fields scrubbed on every terminal status transition, no internal triage state in error messages). Two minor follow-ups absorbed in-release per <20-LOC defensive policy:
_get_active_hermes_home()exception fallback now logs alogger.warning(...)so silent profile-corruption fallback is observable in logs.- Codex credential pool find-loop now accepts both
source == "manual:device_code"(current code) ANDsource == "oauth_device"(legacy from prior Codex OAuth implementations) so users with prior creds get their entry updated in-place rather than accumulating a stale duplicate pool entry.
- #1640 has @aronprins UX-gate APPROVED (May 04 19:24 UTC) after a tighten request landed (default-off setting + Settings → Preferences toggle, hot-applied without refresh).
- #1648 implements @nesquena-hermes's prescribed shape from the #1406 maintainer comment 1:1.
- #1650 implements @nesquena-hermes's prescribed shape from the #1362 maintainer comment 1:1, with explicit security-audit alignment (server-owned device codes, opaque flow_id, profile-scoped storage, blocklist for known-OAuth providers awaiting v2).
- JS syntax: 5 modified
.jsfiles (boot.js,messages.js,onboarding.js,panels.js,ui.js) clean. - Browser API sanity: 11/11 endpoints OK on stage server.
- Conflict resolution: clean auto-merge across all 3 PRs (rebased #1640 onto current master from 10-commits-behind base; #1648 + #1650 already on current master; no overlapping code regions across the 3 PRs in
api/streaming.py,api/routes.py, orstatic/).
Authors
- @Michaelyklam — 3 PRs (#1640, #1648, #1650)
@Michaelyklam continues the strong contribution pattern from #1597, #1598, #1600, #1601, #1621, #1637 — this is now 9 merged PRs across the v0.50.292-296 release window.
Trust boundary note
This release ships the first user-facing OAuth flow in the WebUI. Token storage path, atomic write semantics, chmod timing, server-side flow state, and the allowlist/blocklist pattern are all in scope for security reviewers reviewing v0.50.296. The Hermes Agent CLI's auth.json format is the source-of-truth contract — both the WebUI and CLI write the same credential_pool.openai-codex shape, so credentials added via either surface are usable by either surface.
[v0.50.295] — 2026-05-04
Fixed (3 PRs — closes #1360, #1451, #1463, #1618, #1619)
-
YAML, JSON, and diff/patch fenced code blocks now render multi-line, not collapsed to a single line (#1642 by @nesquena-hermes, closes #1618 / #1463, reported by @Zixim) — PR #484 (v0.50.237) introduced a JSON/YAML tree-viewer that routes
lang === 'json'andlang === 'yaml'blocks through<div class="code-tree-wrap">…<pre class="tree-raw-view">…</pre></div>instead of bare<pre>. Same release added the diff/patch coloring path that emits<pre class="diff-block">. The_pre_stashregex atstatic/ui.js:1914matched only literal<pre>(no attributes):<pre>[\s\S]*?<\/pre>. Both new shapes failed to match, fell through to the paragraph-wrap pass, and\ncharacters inside the code blocks got replaced with<br>tags inside<code>. By the time Prism ran, there were no newlines left for it to highlight against. PR #1516 (v0.50.279) had attempted a CSS-only fix on Prism's token white-space — that rule is instyle.cssand reaches the browser, but it was the wrong layer: the rule preserves newlines inside.tokenspans, but the spans were built from a string that had no newlines left. Fix: relax the_pre_stashregex to accept any attribute on<pre>(<pre>→<pre[^>]*>). One regex character. Pulls JSON, YAML, AND diff/patch blocks into the stash so paragraph-wrap can't mangle them. Bash, Python, Go, etc. were never affected because they emit bare<pre>and matched the existing regex. Reporter @Zixim noted the bug persisted from v0.50.279 → v0.50.291 → v0.50.292 despite the previous "fix"; this lands the actual fix at the actual layer.Parallel-discovery attribution: @Michaelyklam independently filed PR #1641 with the exact same one-character regex relax (filed 4 minutes before #1642). #1641 was closed as superseded by #1642 (which carries nesquena APPROVED + 322 LOC test suite covering YAML+JSON+diff vs #1641's YAML-only); the UI before/after PNGs from #1641 were adopted into stage-295 with a
Co-authored-by: Michael Lamtrailer on the docs commit so Michael's visual evidence ships in-tree alongside the canonical fix.Note on the previous diagnosis: the maintainer comment on #1618 asserting the fix had landed was based on
git show v0.50.291:static/style.cssconfirming the CSS rule's presence — but a presence check on a rule is not a behavioral check that the rule does anything useful. Live-rendering YAML throughrenderMd()in the browser was the test that decided whether the maintainer reply or the user was correct. Apologies to @Zixim for the wrong call. Class of bug now documented inwebui-rendermd-pipelineskill § Bug 10. -
macOS WKWebView trackpad scroll no longer overrides user position during streaming (#1639 by @bergeouss, closes #1360) — during streaming, scrolling up on a macOS trackpad caused the viewport to snap back to the bottom because the
_programmaticScroll setTimeout(0)guard raced with WKWebView momentum scrolling. Mid-momentum scroll events either got swallowed (_programmaticScrollstill True from the most recent programmatic scroll) or falsely reported nearBottom (momentum hadn't settled), keeping_scrollPinned=true. Fix: rAF-debounce the scroll listener so the nearBottom check fires on the next paint frame when the browser's scroll position has settled, plus a hysteresis counter requiring two consecutive near-bottom samples before re-pinning to prevent accidental re-pin during initial deceleration. -
Custom: providers now show all models in the dropdown* (#1639 by @bergeouss, closes #1619) — using a
custom:*provider viacustom_providersinconfig.yaml, the model dropdown was only showing the default model. Two parts: (1) the dedup logic inapi/config.pyate all named-group models when they overlapped with auto-detected ones and thecontinuesilently dropped auto-detected models; (2) the live enrichment endpoint atapi/routes.py:/api/models/liveonly handled barecustom, notcustom:*slugs. Fix: broadened/api/models/liveto handlecustom:*slugs (load-bearing fix), plus defensive belt-and-braces inapi/config.pyto fall back to auto-detected models if all named-group models were deduped (Opus advisor on stage-295 verified the latter is unreachable under current population logic but kept for future-proofing). -
Glued-bold-heading lift no longer mangles raw
<pre>HTML (#1637 by @Michaelyklam, closes #1451) —renderMd()already stashed raw<pre>blocks before converting safe HTML tags, but restored them BEFORE the glued-bold-heading lift from #1446/#1449 ran. That left literal raw<pre>content visible to later markdown rewrites whenever it containedPara text.**Heading**\n\nNext-style text — the lift would insert\n\ninside the literal preformatted content, mangling it. Fix: delayedrawPreStashrestore until AFTER markdown/link rewrites and BEFORE HTML sanitization. Existing placeholder pattern already protects fenced blocks; raw<pre>HTML now behaves like fenced code for this edge case. Test pins both sides: raw<pre>is preserved AND regular glued headings outside preformatted blocks still lift correctly.
Tests
4245 → 4255 passing (+10 regression tests across tests/test_issue1618_yaml_json_diff_newline_preserve.py (9), tests/test_issue1446_glued_heading_lift.py::test_real_renderer_protects_raw_pre_html (1); plus tests/test_issue677.py widened search window for #1639's rAF-debounce; plus tests/test_745_code_block_newlines.py widened source-scan windows from 400 to 1500 chars). 0 regressions. Full suite ~120s.
Pre-release verification
- Opus advisor on stage-295 combined diff: SHIP verdict. All 6 verification questions cleared.
static/ui.jsoverlap between #1637 (rawPreStash, R-token), #1639 (scroll listener), and #1642 (_pre_stash, E-token) verified non-overlapping with separate token namespaces and correct ordering. #1637's relocated restore (line 1668 → 1799) traced through every intermediate rewrite pass — placeholder\x00R{N}\x00has no syntactic characters that match. #1642 nested-<pre>non-greedy behavior verified identical to existingrawPreStashregex (no regression). #1639 hysteresis correct shape (count≥2 to re-pin). One non-blockingapi/config.pydefensive-dead-code observation absorbed via comment per Opus. - #1642 has nesquena APPROVED with comprehensive end-to-end behavioral trace.
- JS syntax:
static/ui.jsclean. - Browser API sanity: 11/11 endpoints OK on stage server.
- Conflict resolution: clean auto-merge across 3 PRs (rebased #1637 + #1639 onto current master from 9-commits-behind base).
Authors
- @nesquena-hermes — 1 PR (#1642, with co-author trailer for @Michaelyklam's UI media adoption)
- @Michaelyklam — 1 PR (#1637)
- @bergeouss — 1 PR (#1639, AI-assisted via Hermes Agent)
Closes #1360, #1451, #1463, #1618, #1619 (5 issues).
[v0.50.294] — 2026-05-04
Fixed (3 PRs — streaming stability trio + models cache version stamp + session race + readonly fs guard — closes #1430, #1470, #1623, #1624, #1625, #1633)
-
SSE app heartbeat lowered from 30s to 5s at every long-lived handler (closes #1623) — kernel TCP keepalive (added v0.50.289 / #1581) declares a peer dead at
KEEPIDLE (10s) + KEEPINTVL (5s) × KEEPCNT (3) = 25sworst-case. The five SSE handlers inapi/routes.py(main agent stream, terminal, gateway-watcher, approval-poller, clarify-poller) all used 30s, which meant on flaky networks the kernel could tear the socket down before the app sent its first heartbeat byte — flaky-network drops at ~10s that users perceived as "the stream died around 10 seconds in" during long LLM thinking phases. Fix: new_SSE_HEARTBEAT_INTERVAL_SECONDS = 5constant referenced by every queue-poll site. Cost: ~150B/min when idle (12 extra heartbeats × 12 bytes), negligible. Many production SSE deployments use 5-15s app heartbeats specifically because TCP keepalive isn't reliable across all network paths (proxies, load balancers, mobile NAT). Regression test pins the inequalityapp_heartbeat × 2 ≤ kernel_keepalive_windowso future tuning of either timer can't re-introduce the misalignment. -
_repair_stale_pending()no longer fires on fresh turns (closes #1624) —_repair_stale_pendinginapi/models.py:716triggered as soon aspending_user_messagewas set ANDactive_stream_idwas missing from the liveSTREAMSregistry. There was no time-based staleness guard, so any narrow race between the streaming thread clearingpending_user_messageandSTREAMS.pop(stream_id)produced a false-positive "Previous turn did not complete." marker on a turn that actually finished correctly — every command-approval turn reliably reproduced this for at least one user. Fix: add_REPAIR_STALE_PENDING_GRACE_SECONDS = 30and bail whentime.time() - pending_started_at < grace. Falsypending_started_at(legacy sidecars from before the field was added in v0.50.283) is treated as "old enough" so legitimate legacy-data recovery still works. Plus a rate-limitedlogger.warning/logger.debugon every legitimate repair so the next batch of user reports tells us whether the underlying race still fires post-fix. This is defense-in-depth, not the root-cause fix — the streaming thread should never exit without clearing pending; tracked separately for future investigation. -
Local model servers (LM Studio, Ollama, llama.cpp, vLLM, TabbyAPI, LocalAI) now keep their full HuggingFace-style model id (closes #1625, reported by @akarichan8231) —
resolve_model_provider()inapi/config.py:1149stripped the provider prefix from a model id likeqwen/qwen3.6-27bwhenever (a) the model contained/, (b)config.yamlhadmodel.base_urlset, and (c) the prefix matched a known entry in_PROVIDER_MODELS(e.g.qwen,openai,anthropic, etc.). The strip is correct for OpenAI-compatible proxies (LiteLLM, OpenRouter relays) —openai/gpt-5.4→gpt-5.4. But local model servers are not proxies — they register models under their full HuggingFace path as the registry key. Stripping the prefix made LM Studio (or Ollama, llama.cpp, vLLM, TabbyAPI) miss the loaded model and silently load a brand-new instance with default settings, ignoring the user's tuned 131072 context / 4 parallel slots. Fix: new_LOCAL_SERVER_PROVIDERSset covering canonical names (lmstudio,lm-studio,localai,ollama,llamacpp,llama-cpp,vllm,tabby,tabbyapi,koboldcpp,textgen) and a new_base_url_points_at_local_server()heuristic that catchesprovider: custom+base_url: http://localhost:1234/v1setups too (via loopback / RFC1918 / IPv6-loopback IP detection). Either signal triggers no-strip. Backward compat is preserved for OpenAI-compatible proxies on public hosts (LiteLLM athttps://litellm.example.com/v1continues to stripopenai/gpt-5.4→gpt-5.4).Behavior change for internal-network OpenAI-compatible proxies (RFC1918): the loopback heuristic also matches private-IP base_urls (10/8, 172.16/12, 192.168/16). A team running an internal LiteLLM proxy at
http://10.5.0.1:1234/v1now gets prefix preservation instead of stripping. LiteLLM accepts either form, so this is invisible in practice; users with a custom proxy on RFC1918 that requires the stripped form should configure it as acustom_providers:entry, which routes through the earlycustom_providersloop and never reaches the local-server detection. -
/api/modelsdisk cache now invalidated on every WebUI version change (closes #1633, reported by @Deor on Discord) —STATE_DIR/models_cache.jsonwas persisted across server restarts without any version stamp. A Docker container update from version A to version B read the cache file written by version A — users saw stale picker contents (missing models, phantom provider groups, e.g. the v0.50.281 4-model Nous Portal +Opencode_Gophantom) for up to 24 hours until either the TTL expired, an unrelated provider edit triggeredinvalidate_models_cache(), or they manually deleted the file. Reporter Deor updated to v0.50.292 — which contained fixes for #1538, #1539, and #1568 — did a hard refresh and cleared site data, and still saw byte-for-byte identical picker contents because the server kept reading the v0.50.281 cache file off the host-mounted volume. Fix:_save_models_cache_to_disk()now stamps payloads with_webui_version(resolved lazily fromapi.updates.WEBUI_VERSIONto avoid a circular import) and_schema_version = 2._load_models_cache_from_disk()rejects any cache where either field mismatches the runtime — every release auto-rebuilds from live provider data on the very next/api/modelscall. Legacy unstamped caches (pre-#1633 files) are also rejected, so the first read after upgrading to this release rebuilds cleanly. Schema version is independent of the WebUI version stamp so future cache-shape changes can invalidate older releases without relying on a tag bump alone. The early-init edge case (api.updates not yet loaded) skips the version check rather than wedging the boot — at worst an unstamped file is written once and rejected on the next call. -
Session list race condition no longer makes today's sessions disappear (closes #1430, reported by @Olyno) —
renderSessionList()instatic/sessions.jshad no staleness guard. Multiple callers (message send, rename, session switch) fire it concurrently without awaiting, so a slower previous-day fetch could overwrite_allSessionswith stale data after a faster newer fetch had already written today's data — manifesting as today's sessions disappearing when the user clicked an older conversation. Fix: new module-local_renderSessionListGengeneration counter pre-incremented before theawaitand re-checked after it; stale calls (older_gen) self-discard before mutating state. Lightest-weight correct shape — no AbortController, no debounce, no state machine. Behavioral harness verifies three concurrent calls with varying delays correctly land only the most recently issued response. (PR #1635 by @bergeouss, AI-assisted via Hermes Agent.) -
Read-only root filesystem under podman no longer crashes container startup (closes #1470, reported by @cosmoceus) —
docker_init.bashunconditionally calledgroupmod/usermodeven when/etc/groupand/etc/passwdwere on a read-only filesystem (typical podman +read_only=truesetup).groupmod: cannot lock /etc/group; try again later.killed the container at boot. Fix: writability check via[ ! -w /etc/group ] || [ ! -w /etc/passwd ]; on read-only mounts with matching UID/GID skip gracefully with a log message; on read-only mounts with mismatched UID/GID emit a clearerror_exitdirecting the user to set matching IDs or disableread_only=true. (PR #1635 by @bergeouss.)
Tests
4180 → 4245 passing (+65 regression tests across tests/test_issue1623_sse_heartbeat_alignment.py (3), tests/test_issue1624_repair_stale_pending_grace.py (9), tests/test_issue1625_local_server_model_id_preservation.py (34, expanded for lm-studio/localai), tests/test_issue1633_models_cache_version_stamp.py (19); plus tests/test_model_resolver.py updates and tests/test_model_cache_metadata.py round-trip semantics). 0 regressions. Full suite ~120s.
Pre-release verification
- Self-built fixes (#1631, #1636 — nesquena-hermes), independent review APPROVED by nesquena for both, with comprehensive end-to-end traces including reproducer harnesses for Deor's Docker-upgrade scenario (#1633) and the kernel-keepalive math (#1623).
- External contributor PR #1635 by @bergeouss (AI-assisted via Hermes Agent), independent review APPROVED by nesquena with behavioral harness for the race fix (three concurrent fetches with varying delays — only the latest writes to state).
- Opus advisor pre-merge pass on #1631: SHIP — no MUST-FIX, one SHOULD-FIX (rate-limited
_repair_stale_pendingtelemetry) and three NITs (expanded_LOCAL_SERVER_PROVIDERS, RFC1918 CHANGELOG callout) absorbed in-PR (commit2161fc1). - Opus advisor pre-merge pass on stage-294: see "Opus-applied fixes" below.
_SSE_HEARTBEAT_INTERVAL_SECONDS × 2 ≤ KEEPIDLE + KEEPINTVL × KEEPCNTpinned by a regression test that derives the kernel window fromserver.pysetsockopt block at runtime._repair_stale_pendinggrace guard exercised at: 5s-old turn (skip), grace-1s-old turn (skip), grace+30s-old turn (fire), missing/zero/garbagepending_started_at(fire — legacy compat), no pending-message (skip — pre-existing contract), live stream (skip — pre-existing contract).resolve_model_providerexercised across local-server provider names + 7 loopback/private IP heuristic cases + backward-compat checks for OpenAI-compatible proxies on public hosts and OpenRouter pass-through. Helper_base_url_points_at_local_server()independently unit-tested against 11 url shapes.- End-to-end behavioral test (
test_docker_update_scenario_invalidates_old_cache) reproduces Deor's exact reported scenario: a cache stamped atv0.50.281fails to load when runtime isv0.50.292, forcing a fresh rebuild that picks up the picker fixes shipped between releases. - Round-trip + version-mismatch + legacy-unstamped + schema-mismatch + early-init + corrupt-JSON + missing-file + atomic-overwrite + invalidate-cache-tear-down all pinned.
- Cross-tool verified: agent has its own model-cache files at different paths (
hermes_cli/codex_models.py,hermes_cli/models.py) — no collision.
Opus-applied fixes (absorbed in-release)
From #1631 in-PR Opus pre-merge pass (already on the PR's branch):
- SHOULD-FIX (
_repair_stale_pendinglog volume): rate-limit the repair-firing telemetry by age —logger.warningfor the diagnostically valuable race window (< 5 min, actual leak-path candidates that slipped past the grace guard) andlogger.debugfor the long-tail (orphaned sidecars from prior process lifetimes). Prevents reconnect loops on stuck sessions from flooding the log while preserving the diagnostic signal we want for tuning the grace constant. - NIT (
_LOCAL_SERVER_PROVIDERS): addedlm-studio(hyphenated alias used in somecustom_providers:configs) andlocalai(LocalAI project, common OpenAI-compatible local server). Test parametrize expanded to cover the new names plus pre-existingkoboldcppandtextgenfor symmetry.
From #1636 stage-294 absorption (this release):
- Minor observation absorbed —
_is_loadable_disk_cache()now logs at DEBUG when rejecting (schema=N vs M,version=A vs B). Useful diagnostic when investigating future "why did my cache rebuild" questions. - Code comment added to
_is_loadable_disk_cache()documenting that_webui_versionis a string compare (not semver) — paired with_schema_versionindependent axis for breaking changes that lack a tag bump.
[v0.50.293] — 2026-05-04
Fixed (3 PRs — profile isolation trio + agent version badge + #1597 follow-up)
-
Show Hermes Agent version in Settings → System (#1606) — added
agent_versiondetection for display in System settings (~/.hermes/hermes-agent/VERSIONpreferred, git describe fallback), surfaced it alongside existingwebui_versioninGET /api/settings, and updated the System pane badge UI with a labeled Agent pill plus graceful fallback when the agent cannot be detected. -
/api/sessionsand/api/projectsare now scoped to the active profile by default (closes #1611 + #1614, reported by @stefanpieter) — the WebUI's session list and project list were both global:/api/sessionsmerged WebUI sidecar sessions and CLI/imported sessions and returned all rows regardless of whichhermes_profilecookie the client sent, and/api/projectshad no profile awareness whatsoever. Reporter @stefanpieter rancurl /api/sessions -H 'Cookie: hermes_profile=haku'against a multi-profile install and got back sessions taggedhaku,kinni, ANDnoblepro— every profile's history visible from every UI. Frontend filtering had a CLI-bypass atstatic/sessions.js:1853(s.is_cli_session || s.profile === S.activeProfile) that let every CLI-imported session through regardless of which profile owned it. Fix: server-side filter on both endpoints via the active profile; explicit?all_profiles=1opt-in for aggregate views; new_profiles_match()helper that honours the renamed-root case ('default'and a renamed-root display name like'kinni'cross-match because they resolve to the same~/.hermeshome). Project rows now carry aprofilefield stamped at create-time./api/projects/{create,rename,delete}and/api/session/movereject ops on cross-profile projects with 404.ensure_cron_project()keys lookup by(name, profile)so cron-spawned sessions from profile A no longer surface under the cron chip of profile B. One-time migration inload_projects()back-tags legacy untagged projects from any session that uses them, falling back to'default'. Frontend drops the CLI-session bypass; toggle-on-toggle re-fetches with?all_profiles=1rather than slicing client-cached rows. -
Renamed root profile no longer 404s on switch (closes #1612, reported by @stefanpieter) — Hermes Agent allows the root/default profile (
~/.hermesitself) to have a display name other than the legacy literal'default'. WebUI hard-codedif name == 'default':at five callsites inapi/profiles.py(get_active_hermes_home,get_hermes_home_for_profile,switch_profile,delete_profile_api, sticky-default writeback), so a renamed root (e.g.'kinni'withis_default=True,path=~/.hermes) fell through every check to_DEFAULT_HERMES_HOME / 'profiles' / 'kinni'— a directory that doesn't exist. Switching to the renamed root raisedProfile 'kinni' does not exist.and broke every code path that resolved~/.hermesfrom a profile name. Fix: new_is_root_profile(name)central helper that consultslist_profiles_api()foris_default=Truematches alongside the legacy'default'alias. All five callsites now route through it. Memoized with explicit invalidation hooks at every profile mutation (create, delete) so the lookup cost is paid once per cache window. Stickyactive_profilefile write now stores''for renamed root (consistent with the existing legacy contract that empty == root) instead of writing the display name and re-resolving wrong on next boot. -
Provider config cleanup regression test (#1630 by @Michaelyklam, follow-up to #1597) — pins the late-binding contract introduced in #1597 by removing the now-unused
_get_config_pathimport fromapi.providersand adding a dedicated regression test that proves_clean_provider_key_from_config()resolves throughapi.config._get_config_path()at call time rather than the stale module-load reference. Belt-and-braces against a future import-cleanup silently reintroducing the original bug class.
Tests
4142 → 4180 passing (+38 regression tests across tests/test_issue1611_session_profile_filtering.py (11), tests/test_issue1612_renamed_root_profile.py (11), tests/test_issue1614_project_profile_filtering.py (11), tests/test_provider_management.py::test_clean_provider_key_uses_late_bound_config_path (1), and tests/test_version_badge.py agent-detect chain (~5)). 0 regressions. Full suite in ~120s.
Pre-release verification
- Opus advisor on full stage-293 diff: SHIP verdict. Two SHOULD-FIX items absorbed in-release per <20-LOC defensive policy: (a)
api/models.py:load_projects()re-reads from disk inside_PROJECTS_MIGRATION_LOCKwhen_projects_migratedis found True post-wait — closes a startup-window staleness race where a thread that read pre-migration could return stale untagged rows after a peer migrated and wrote disk; (b)_detect_agent_version()now usesgit describe --tags --always --dirtyfor symmetry with_detect_webui_version(). One non-blocking client-side filter cross-alias edge case deferred as follow-up issue. - Self-built fix (#1629, nesquena-hermes), independent review APPROVED by nesquena with comprehensive end-to-end trace, cross-tool verification against fresh agent tarball, security audit, race/state analysis, and 13-row edge-case matrix.
- 31 dedicated regression tests for #1611/#1612/#1614 invariants. Source-string assertions pin the active-profile guards on
/api/projects/{rename,delete}and/api/session/move. _is_root_profileinvalidation cycle exercised via test_is_root_profile_invalidation_drops_stale (cache populated, then dropped after simulated profile rename).ensure_cron_projectper-profile isolation exercised via test_ensure_cron_project_creates_per_profile (two profiles → two distinct project_ids).- Cross-alias matching pinned:
_profiles_match('default', 'kinni')returns True only whenkinniisis_default.
Opus-applied fixes (absorbed in-release)
From stage-293 review:
- SHOULD-FIX A (project migration startup race):
api/models.py:load_projects()re-reads from disk after acquiring_PROJECTS_MIGRATION_LOCKand finding_projects_migrated=True. Without this, Thread B that read pre-migration could return stale untagged rows after Thread A migrated and wrote disk — a mutation route on those stale rows could silently overwrite the migration. Window is process-startup-only and very narrow; fix is 8 LOC. - SHOULD-FIX B (agent version
--dirtysymmetry):_detect_agent_version()now passes--dirtytogit describe --tags --always, matching_detect_webui_version(). Operators with locally-modified agent checkouts now see the dirty marker.
Already absorbed in #1629 (in-PR Opus pre-merge pass before staging):
- SHOULD-FIX #1 (renamed-root client cross-alias): removed the strict-equality client filter at
static/sessions.js:1853. Server-side_profiles_matchcross-aliases'default'-tagged rows to a renamed root'kinni'; a strict-equality client filter would have rejected them, dropping every legacy session for renamed-root users. Server is now solely authoritative for profile scoping. Same fix applied to theotherProfileCountclient fallback. - SHOULD-FIX #2 (messaging-source dedupe ordering): moved
_keep_latest_messaging_session_per_source(merged)to AFTER the profile filter atapi/routes.py:2078. Before: the dedupe ran on the merged-cross-profile list with profile-blind keys, discarding the older profile's row across profiles, then the profile filter scoped to the active profile — leaving zero rows for any messaging identity the active profile shared with another profile. After: filter first, then dedupe within scope. - NIT #3 (migration save-failure):
_projects_migrated = Trueflag now set only AFTER successfulsave_projects(). A failed save no longer poisons the in-memory state for the rest of process lifetime. - NIT #4 (dead test code): cleaned up the dead double-assignment in
test_is_root_profile_invalidation_drops_stale. - NIT #5 (
_create_profile_fallbackliteral-default): routed theclone_from == 'default'literal in the no-hermes-cli fallback path through_is_root_profile()for parity with the other 5 callsites.
[v0.50.292] — 2026-05-04
Fixed (12 PRs — multi-tab SSE + subpath routes + cross-source lineage + paste UX + 3 follow-ups)
-
Multi-tab SSE no longer splits stream tokens between tabs (#1598 by @Michaelyklam, closes #1584) —
api/config.pyintroduces aStreamChannelbroadcast class to replace the single-consumerqueue.Queuepreviously stored inSTREAMS[stream_id]. With the old design, the same session in two tabs was racing to consume tokens from one queue, so one tab might receiveHwhile the other receivedallo. The new channel buffers events while no subscriber is connected (so the first tab sees the stream tail that arrived during the gap), and once one or more tabs are subscribed it broadcasts every event to all of them._handle_sse_stream()callssubscribe()on connect andunsubscribe()in afinallyblock on disconnect/error. Per-stream wiring updated at all three producer callsites (_handle_chat_start,_handle_btw,_handle_background). Per Opus advisor on stage-292: replay-while-subscribing now happens inside the lock to prevent an event-ordering inversion when a 2nd tab subscribes mid-stream. -
Frontend routes now work under subpath mounts like
/hermes/(#1601 by @Michaelyklam) — auth redirect Location header (api/auth.py), 401-redirect helpers (static/ui.js,static/workspace.js), direct fetch/EventSource URLs (static/{boot,messages,sessions}.js), and the SMD vendor module import (static/index.html) all switched from root-absolute (/login,/api/...,/static/...) to mount-relative (login,api/...,static/...). Where appropriate, the mount-relative URL is anchored againstdocument.baseURI || location.hrefso the<base href>element correctly resolves it under deep SPA routes. Per Opus advisor on stage-292: the gateway SSE probe instatic/sessions.js:1440now also usesdocument.baseURI || location.hreffor parity with the other 5 callsites in this PR, ensuring it doesn't 404 under subpath at deep routes. Self-hosters running WebUI behind a reverse proxy or container ingress at a path prefix can now have everything work without Caddy/nginx rewrite workarounds. -
Streaming markdown now formats live segments under subpath mounts (#1600 by @Michaelyklam) —
static/index.htmlSMD module import switched to mount-relative form.static/messages.jsfallback path (whenwindow.smdisn't loaded) now passes the visible segment throughrenderMd(fallbackText)for the FIRST live segment as well as post-tool segments — previously the first segment was inserted as rawparsed.displayText, leaving markdown visible until the assistant's turn completed. -
Cross-source session continuations stay separate in the sidebar (#1602 by @ai-ag2026) —
api/agent_sessions.py:_is_continuation_session()now refuses to collapse parent/child whereparent.source != child.source. A WebUI session continuing from a Telegram/CLI compression-chained parent stays visible as its own WebUI row instead of inheriting the old parent's title and source metadata. Non-continuation child rows now also exposeparent_title+parent_sourceso the surface can show the lineage without losing the child's own identity. -
Paste no longer drops text when clipboard has both text and image (#1622 by @s905060, closes #1620) —
static/boot.jspaste handler used to intercept on anyimage/*clipboard item, callingpreventDefault()and attaching the image as a screenshot. Pasting from rich-text sources (Notes, Word, Slack, browser selections) attaches a rendered preview alongside the plain text — so the handler swallowed the text payload and only the rogue image was attached. Now defers to the browser's default text-paste when the clipboard also carriestext/plainortext/htmlstring items, and only intercepts when the clipboard is image-only (true screenshot paste). Image filter also tightened tokind === 'file'so string items advertising an image MIME (e.g.text/htmlwith embedded data URIs) aren't misclassified. -
Forked session sidebar indicator is now recognizable and less noisy (#1621 by @franksong2702, fixes #1613) — replaced the permanent
⑂OCR glyph with the existinggit-branchSVG icon, made the indicator subtle (.35 opacity) until row hover/focus/active states (.85 opacity), changed the tooltip to prefer the parent session title with a truncated-id fallback, and removed the hidden click-to-parent behavior from the sidebar row (was unpredictable). The/branchcommand and fork data model are unchanged. -
Update banner now shows tracked branches in labels (#1605 by @ai-ag2026) —
static/ui.jsandstatic/panels.jsuse a new_formatUpdateTargetStatus(label, info)formatter that includesinfo.branchparenthetical, soWebUI (origin/master): 0 updates, Agent (origin/main): 32 updatesis displayed in mixed states instead of the genericAgent: 32 updatesthat could be misread as the WebUI being behind. Settings panel uses a typeof-guarded fallback to a local formatter for back-compat with older boot states. -
Update compare URLs preserve git remote names ending in g/i/t (#1603 by @ai-ag2026) —
api/updates.pywas usingstr.rstrip('.git')for the remote URL trim, which is a CHARACTER-CLASS strip —'hermes-webui.git'became'hermes-webu'(it strips trailingg, theni, then., then morei's, thenu...). The updated logic checksendswith('.git')and slices the literal suffix, leavinghermes-webui/hermes-agentand any other remote name intact. Both HTTPS and SSH origin forms covered. -
_pending_started_attruthy-check fallback (#1599 by @Sanjays2402, closes #1595) —api/streaming.py:2058tightens the per-turn duration fallback fromis not Noneto a truthy check soNone, missing-attr, and an explicit0all uniformly fall back totime.time(). Closes the loop on the v0.50.290 retro lesson — the v0.50.290 contributor's source-string assertion that pinned the oldis not Noneform is removed by this PR. Behavioral assertions on the duration fallback remain. -
pytest config-path isolation (#1597 by @Michaelyklam) — Hermes Agent sessions can set
HERMES_CONFIG_PATHto the real~/.hermes/config.yamlbefore invoking pytest, so onboarding/provider tests could read/write the developer's live config.tests/conftest.pynow overridesHERMES_CONFIG_PATHto point at the isolated test home before any product modules are imported.api/providers.py:_clean_provider_key_from_config()switches from import-time-bound_get_config_pathto call-time resolution throughapi.config._get_config_path()so monkeypatches and tests work correctly. -
Cron worker no longer silently ignores profile-context failures (#1608 by @franksong2702, closes #1578) —
_run_cron_tracked()no longer wrapscron_profile_context_for_home(profile_home).__enter__()in atry/except Exceptionthat silently setsctx = None. A silent fallback in the worker thread leaves the job running unpinned against process-globalHERMES_HOME, silently corrupting cross-profile state — same class of bug as #1573. Lets the exception propagate (kill the worker thread) rather than corrupt cross-profile state. Source-level regression test catches any future re-introduction of the over-broad except clause. -
TCP keepalive cleanup + macOS support (#1609 by @franksong2702, closes #1583) —
server.pycleanup follow-up to v0.50.289. Deletes the deadQuietHTTPServer.server_bind()override (TCP_KEEP* setsockopts on the listening socket are no-ops without SO_KEEPALIVE, which can't be set on a passive socket anyway). SplitsHandler.setup()into proper ordering — TCP_NODELAY first, then SO_KEEPALIVE, then per-platform timing parameters: Linux usesTCP_KEEPIDLE/INTVL/CNT, macOS usesTCP_KEEPALIVE. Previously, on macOS, the entire try block aborted on the firstAttributeErrorfromTCP_KEEPIDLEand SO_KEEPALIVE was never applied — connections never had keepalive at all on Mac.
Tests
4117 → 4142 passing (+25 new regression tests across all 12 PRs). 0 regressions. Full suite in ~125s.
Pre-release verification
- Opus advisor: SHIP verdict. Two SHOULD-FIX items absorbed in-release per <20-LOC defensive policy: (1) #1598 ordering race fixed by moving offline-buffer replay inside the subscribe lock; (2) #1601 sessions.js:1440 gateway SSE probe switched to
document.baseURI || location.hreffor parity with PR's other 5 callsites. - JS syntax: all 6 modified .js files checked clean with
node -c. - Browser API sanity: 11/11 endpoints OK on stage server.
- CHANGELOG / ROADMAP / TESTING: stamps updated for v0.50.292 / 4142 baseline.
Authors
- @Michaelyklam — 4 PRs (#1597, #1598, #1600, #1601)
- @ai-ag2026 — 3 PRs (#1602, #1603, #1605)
- @franksong2702 — 3 PRs (#1608, #1609, #1621)
- @Sanjays2402 — 1 PR (#1599)
- @s905060 — 1 PR (#1622)
Closes #1578, #1583, #1584, #1595, #1613, #1620.
[v0.50.291] — 2026-05-04
Fixed (1 PR — "What's new?" link 404 — closes #1579)
- "What's new?" update-banner link no longer 404s when local HEAD diverges from upstream (closes #1579, reported by @ai-ag2026) —
api/updates.pywas building the GitHub compare URL from local-HEADshort SHA:repoUrl + '/compare/' + curSha + '...' + newShawherecurSha = git rev-parse --short HEAD. Whenever the local checkout had commits that weren't in the upstream repo — unpushed work, dirty stage branches, forks, in-flight rebases, release-time merge commits — the compare URL pointed at a SHA that github.com had never seen and returned its standard 404 page. Reporter sawhttps://github.com/nesquena/hermes-webui/compare/c660c7f...86cb22eproduce a 404 becausec660c7fwas an unpushed local commit. Fix: replacegit rev-parse --short HEADwithgit merge-base HEAD <compare_ref>thengit rev-parse --shorton that result. The merge-base is the most recent commit both local and upstream share, and (sincegit fetchsucceeded just before) is guaranteed to exist on the upstream GitHub repo. For the common case (pure-behind clone, no local commits) the merge-base equals local HEAD and the URL is unchanged from prior behavior. For the divergent case (the #1579 reporter scenario) the URL points at the public ancestor, which github.com always knows. Ifmerge-baseitself fails (shallow clone with no shared history), fall back tocurrent_sha=Noneso the existing JS link guard (if(repoUrl && curSha && newSha)) suppresses the link entirely rather than emitting a known-broken URL. Also hardensstatic/ui.jsto clear the link'shrefanddisplay:noneit on every banner render, so a stale link from a prior render can't survive a re-render where the new payload'scurrent_shais null. 6 regression tests covering merge-base correctness, backward-compat for pure-behind clones, merge-base-failure fallback, JS link reset on every render, JS conditional guard shape, and an end-to-end verification of the reporter's exact scenario.
Tests
4111 → 4117 passing (+6 regression tests on tests/test_issue1579_whats_new_link_404.py). 0 regressions. Full suite in ~115s.
Pre-release verification
- Self-built fix (nesquena-hermes) with independent review APPROVED by nesquena — full end-to-end behavioral harness using throwaway local+upstream git fixtures verified the reporter's exact scenario produces a 404 pre-fix and resolves post-fix. Cross-tool audit (webui-only, no agent surface). Security audit clean. Race/state analysis:
_check_repois single-threaded per request,_run_gitspawns subprocesses with no shared state. Edge-case trace covered 8 scenarios including pure-behind clone (URL unchanged from pre-fix), 2-unpushed-3-upstream (the reporter's case), pure-ahead, fork checkout, mid-rebase, shallow clone, transientgit merge-baseerrors, and stale link from prior render with null current_sha. - Bug repro confirmed locally: simulated 2 unpushed commits + 3 upstream commits;
git rev-parse --short HEADreturns SHA absent from upstream history (verifiable withgit cat-file -e $sha origin/masterfailing);git merge-base HEAD origin/masterreturns SHA present in upstream history. Compare URL constructed from merge-base resolves on github.com; URL constructed from local HEAD 404s. - All other tests in
test_update_checker.py(12) andtest_version_badge.py(21) still pass — no behavioral changes to the diagnostic / version-detection paths.
[v0.50.290] — 2026-05-04
Fixed + Feature (5-PR batch — login cache + sidebar UX + workspace dropdown polish)
-
Login asset SW cache exemption (#1586 by @Michaelyklam) — service worker now bypasses
/loginand/static/login.js(network-only), navigation requests are network-first, and cache-first is scoped to an explicitSHELL_ASSETSallowlist (./dropped from the precache list).static/login.jsis also versioned via?v=<WEBUI_VERSION>so a stale cached login script can never block a fresh password submit. Closes the auth-stuck-in-cache class: a stale cachedlogin.jswith old auth-submit path was making valid passwords fail until users manually cleared browser cache, which is especially confusing for PWA installs. Two new test files (test_service_worker_api_cache.py,test_sprint19.py) lock the SW behavior — including afetch_idx < cache_idxordering check so the navigation branch can never silently regress to cache-first. -
Hot-apply compact tool activity setting (#1590 by @Michaelyklam) —
static/panels.js:_autosavePreferencesSettingsnow captures the POST response, and when the autosaved payload includessimplified_tool_calling, updateswindow._simplifiedToolCalling, clears the message render cache, and re-renders messages immediately. Settings checkboxes that silently waited for a refresh felt broken — especially this one, which changes transcript structure rather than just a stored preference. Hot-applying the renderer mode keeps settings behavior consistent with user expectations: toggle means visible now. 6 LOC code + structural regression test. -
First-turn sidebar visibility (#1591 by @Michaelyklam) — empty
Untitledsessions are intentionally ephemeral so accidental blank chats don't clutter the sidebar, but a first user message should promote the session into a real visible conversation immediately, before the model produces an assistant response. The bug was a race between the local first-message render and/api/sessions: the client could re-fetch stale zero-message metadata before/api/chat/startsaved pending state, hiding the row until the assistant turn completed. Three pieces: (1) newupsertActiveSessionForLocalTurn()helper instatic/sessions.jsthat writes to the cached sidebar list directly; (2) three optimistic-upsert passes instatic/messages.js:send()(before /api/chat/start, after rename, after stream_id known) plus dropping the pre-start/api/sessionsre-fetch race; (3)api/models.py:Session.compact()now bumpsmessage_countto ≥1 and setslast_message_attopending_started_atwhenpending_user_messageis set, plus exposes a newhas_pending_user_message: boolfield that the empty-Untitled filter respects. Users can now switch into a just-started conversation and inspect live tool calls even before the agent has responded. 191/9 LOC code + 99-LOC regression test. -
Turn duration display ("Done in 1m 12s") (#1592 by @Michaelyklam) —
api/streaming.pycapturess.pending_started_atin_run, calculates_turn_duration_seconds = max(0.0, time.time() - float(_turn_started_at))at completion, persists it on the assistant message dict as_turnDuration(so reloads keep the display), and includesduration_secondsin the streamingdoneusage SSE payload. Frontend reads from both surfaces: live during streaming viaattachLiveStream()readingusage.duration_seconds, persistent across reloads via the_turnDurationfield. Renders as "Done in 1m 12s" — on the compact Activity row in compact mode, and as a subtle assistant footer chip in expanded tool-call mode. 152/20 LOC code + 67-LOC regression test. Opus advisor flagged a_pending_started_at == 0falsy-vs-None edge case as a hypothetical SHOULD-FIX; not absorbed in-release because the contributor's regression test pins the explicitis not Noneform. Filed as follow-up for separate consideration. -
Workspace dropdown sort + search + chip sync on chat switch (#1464 by @JKJameson; maintainer-augmented) —
static/sessions.js:loadSession()now callssyncTopbar()immediately afterS.session = data.session, before async message-loading begins (mirrors how the model chip is handled).static/panels.js:renderWorkspaceDropdownIntois rewritten with: a search input that filters by name or path in real-time; alphabetical sort (frontend only vialocaleCompare, backendload_workspaces()preserves user-defined order so drag-to-reorder #492 keeps working); class-based CSS (.ws-list-container,.ws-search-row,.ws-search-input,.ws-no-results); 9-locale i18n parity for the new keys (ws_search_placeholder,ws_no_results). 84/6 LOC code + 61-LOC regression test. Maintainer in-stage actions: rebased onto current master (was 124 commits behind v0.50.275); flipped inverted ternary onpanels.js:1683(visible?'':'none'→visible?'none':'') — contributor's own screenshot in PR thread demonstrated the bug live (rendered "No workspaces found" alongside valid filtered results); addedtests/test_issue1464_workspace_dropdown_filter.pyto lock the visibility relationship as mirror-image opt/noResults ternaries so future edits cannot silently re-invert. Desktop UX gate verified live on test server (alphabetical sort + search filter + zero-match noResults rendering — single message, no duplication). Mobile (390px) responsive verification pending — couldn't be captured via CDP origin-policy block, deferring true 390px screenshot review to maintainer Aron's hands-on session.
Maintainer-side test fixes in stage (auto-rebase + auto-fix policy)
Two stale source-string assertions were broken by #1591's compact() and messages.js changes — both real test-side fixes, no production code modified:
tests/test_465_session_branching.py::test_session_compact_includes_parent— widened search window from 1500 to 3000 chars afterdef compact(self,because #1591 inserted ahas_pending_user_messagerecompute block at the top, pushingparent_session_idbeyond the original window.tests/test_regressions.py::test_send_uses_session_model_as_authoritative_source— switched anchor fromsrc.find("/api/chat/start")(which #1591 made first match a comment line) tosrc.find("api('/api/chat/start'")so the search lands on the actual POST call.
Tests
4094 → 4111 passing (+17 net: +6 from #1586, +1 from #1590, +1 from #1591, +6 from #1592, +1 from #1464, +2 maintainer-side test widenings). 0 regressions. Full suite in 107s.
Pre-release verification
- All 5 PRs' regression tests pass standalone.
- All 4111 tests pass in the full suite (clean state, no pre-existing flakes).
- Browser API sanity (HTTP checks against port 8789): 11/11 endpoints verified.
- All modified JS files (
static/panels.js,static/messages.js,static/sessions.js,static/sw.js,static/ui.js,static/i18n.js) passnode -c. - Stage diff scanned for merge-conflict markers (post-v0.50.279 procedure): none found.
- Live UX verification on test server (#1464 dropdown): seeded test environment with 10 workspaces (alpha/beta/delta/epsilon/eta/gamma/theta/zeta + Home + workspace), drove the composer workspace chip → dropdown opens with search input pinned at top, workspaces alphabetically sorted (verified visually + via
dataset.nameextraction), filtering "alp" narrows to singlealpharow with no spurious noResults message, filtering "zzznomatch" shows clean "No workspaces found" empty-state with no concurrent ws-opt rows. Vision-confirmed. Inverted-ternary fix verified working in production. - Pre-release Opus advisor: SHIP AS-IS — no MUST-FIX. All 5 verification questions check out (no
has_pending_user_messageTTL needed because every termination path clears the marker; three optimistic-upsert passes are race-safe viafindIndex-keyed merge in single-threaded JS;_turn_started_atfallback is correct because recovered sessions are marked complete and never re-run_run; SHELL_ASSETS scoping is intentional cache-bust contract; numericvisibleternary is correct because JS0is falsy). One non-blocking SHOULD-FIX (_pending_started_at == 0falsy-guard tightening) considered for in-release absorption, but the contributor's regression test intest_turn_duration_display.py:24literally pins theif _pending_started_at is not None else time.time()source-string form. Reverted the Opus tightening to preserve the contributor's intent and test assertion. Filed as a follow-up for separate consideration if the falsy-guard is desired.
Maintainer in-stage actions
- PR rebase verified (REBASE-DEFAULT rule): #1586/#1590/#1591/#1592 all on current master (
bf7bc6b4= v0.50.289), zero commits behind. #1464 was 124 commits behind (forked at v0.50.275); rebased cleanly onto master. - Auto-fix on #1464: ternary inversion + regression test, with
Co-authored-by: Josh Jamesonpreserved. - Auto-fix on stage: widened source-string anchors in two pre-existing brittle tests broken by #1591's structural changes.
[v0.50.289] — 2026-05-03
Fixed (1 PR — TCP keepalive on accepted connections — closes #1580)
- TCP keepalive on accepted connections to clean up dead
CLOSE-WAITsockets (#1581 by @happy5318; closes #1580) — reporter (also @happy5318) observedCLOSE-WAITzombie connections accumulating on long-running Linux WebUI servers (ss -tn | grep 8787 | grep CLOSE-WAITshowing nonzero counts after extended uptime). Without TCP keepalive enabled, a thread blocked inrecv()waiting for the next request on an HTTP/1.0-or-1.1 keep-alive socket has no way to detect a peer that crashed, lost its network, or otherwise disappeared without sending FIN — the socket sits inESTABLISHEDindefinitely until the kernel reclaims it on idle thresholds far higher than necessary. Fix (load-bearing): newHandler.setup()override inserver.pythat, on every accepted connection, setsSO_KEEPALIVE=1(the master switch that enables TCP keepalive on this socket),TCP_NODELAY=1(disables Nagle for HTTP small-burst latency), and the keepalive timing parametersTCP_KEEPIDLE=10/TCP_KEEPINTVL=5/TCP_KEEPCNT=3→ kernel starts probing a connection idle for 10s, probes every 5s, drops after 3 failed probes (~25s detection). All setsockopts wrapped in a singletry/except (OSError, AttributeError)for graceful no-op on platforms whereTCP_KEEP*constants aren't available (macOS, Windows). Healthy SSE streams send their existing 30s app-level: keepalive\\n\\nheartbeat which resets the kernel idle timer well below the 10s threshold, so probes never fire on healthy long-lived connections; only genuinely idle keep-alive sockets get cleaned up. The PR additionally adds aQuietHTTPServer.server_bind()block that setsSO_REUSEADDR(already the default viaallow_reuse_address=True, so redundant) and listening-socketTCP_KEEP*(no-op withoutSO_KEEPALIVEon the listening socket — child sockets don't inherit keepalive parameters from the listener on Linux). Reviewer flagged that block as harmless dead code; deferred cleanup to follow-up issue along with macOS-doesn't-get-SO_KEEPALIVE behavior (the entiretryblock aborts on the firstAttributeErrorfromTCP_KEEPIDLE, so macOS dev servers get TCP_NODELAY but not the keepalive master switch). Linux is the production target and gets the full benefit.
Tests
4094 → 4094 passing (no new tests; kernel-level networking change is impractical to test in unit suite without a multi-process integration fixture). 0 regressions. Full suite in 110s.
Pre-release verification
- Independent reviewer (nesquena, APPROVED) traced end-to-end: per-connection
Handler.setup()is the load-bearing change;SO_KEEPALIVE=1is the master switch; 10/5/3 timing produces ~25s detection; healthy SSE streams' 30s app keepalive resets the kernel idle timer so probes never escalate on healthy connections; security audit clean (no XSS, SSRF, auth, path traversal, eval, shell — pure socket-options change); race-free (server_bindonce at startup,setupper-connection on the request thread). - Pre-release Opus advisor: SHIP AS-IS — no MUST-FIX. All 5 verification questions check out (race-free per-thread
Handlerlifecycle, kernel-keepalive death raisesOSError(ETIMEDOUT)which is in both_CLIENT_DISCONNECT_ERRORSANDQuietHTTPServer.handle_error's errno-110 suppress list, HTTP/1.0 churn impact negligible at 5 setsockopts per accept, swallow ofOSError/AttributeErrordefensible for hotfix scope, dead-code cleanup inserver_bind()correctly deferred to follow-up). - Full suite: 4094 passed, 2 skipped, 3 xpassed, 0 failed in 110s.
- Syntax:
py_compile server.py→ OK.
Maintainer in-stage actions
- PR rebase (REBASE-DEFAULT rule): PR base was 111 commits behind
origin/master(forked at6c3ff3ff, pre-v0.50.275). Rebased onto current master. Clean, no conflicts. Re-tested on rebased branch → 4094 passed, no regressions.
[v0.50.288] — 2026-05-03
Fixed (3 PRs — picker symmetry + cron profile isolation — closes #1567, #1568, #1573)
-
Nous Portal endpoint disagreement + featured-set cap (#1569; closes #1567) — reporter (Deor, Discord, relayed by @AvidFuturist) saw Settings → Providers card showing
"Nous Portal — 396 models · OAuth"while the in-conversation model picker dropdown listed only the 4 hardcoded curated entries (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini 3.1 Pro Preview). Two related root-shape bugs bundled. (1) Asymmetric auth detection —api/providers.py:get_providersiterates ALL OAuth providers regardless of authentication state and unconditionally live-fetches the catalog, whileapi/config.py:_build_available_models_uncachedonly iterates providers indetected_providers, gated onhermes_cli.models.list_available_providers().authenticated. That flag can disagree withhermes_cli.auth.get_auth_status(<id>).logged_in, so when the disagreement happens for Nous, the picker silently falls through to the curated 4-entry static list while the providers card keeps showing the live catalog. Fix: added explicitget_auth_status("nous").logged_incheck after the existinglist_available_providers()loop — picker now includes Nous whenever the providers card would. (2) UX cap — even with the disagreement fixed, dumping a 397-model catalog into a flat dropdown is unusable. New_build_nous_featured_set()helper atapi/config.py:965runs the same algorithm in both/api/modelsand/api/models/liveso background enrichment doesn't undo the trim. Selection rules (deterministic): sticky-selection always pinned, every curated flagship preserved, vendor round-robin via_NOUS_VENDOR_PRIORITYfor top-up to 15. Disclosure pattern: optgroup label"Nous Portal (15 of 397)", newextra_modelsfield on the API surface, slash command +_dynamicModelLabelsmap hydrated from both halves so a model selected outside the featured slice still renders with its proper label, providers card usesmodels_totalfor the header count + small+N moredisclosure pill at the end of the rendered pill list. (3) Stale-fallback poisoning — when authenticated AND live-fetch returns[](transient hermes_cli failure, OAuth refresh in flight, cache miss), omit the Nous group entirely rather than falling back to stale-4 (which actively contradicts the providers card instead of self-healing). Static fallback only whenhermes_cliis unavailable or raises (test envs, package mismatches). 20 new tests intests/test_issue1567_nous_picker_capacity_and_symmetry.pycovering selection helper invariants, large-catalog cap behavior, detection symmetry, live-fetch-empty handling, providers/picker symmetry, frontend extras contract. -
Cron Scheduled Jobs panel respects per-request active profile (#1571 by @kowenhaoai; closes #1573) —
/api/crons*endpoints called intocron.jobs(fromhermes-agent), whose path resolver readsHERMES_HOMEfromos.environat call time. The WebUI's per-request profile isolation (#798) is thread-local — set per-request from thehermes_profilecookie inserver.py, cleared after the request — so those two mechanisms didn't talk to each other andcron.jobsalways saw the process-defaultHERMES_HOMEno matter which profile the request belonged to. CRUD operations silently wrote to the wrongjobs.json. Fix: two new context managers inapi/profiles.py:139-260, both holding a module-level_cron_env_lock.cron_profile_context()is the HTTP-side variant (resolves home viaget_active_hermes_home()which honors the TLS cookie, swapsos.environ['HERMES_HOME'], re-patches the cachedcron.jobs.HERMES_DIR/CRON_DIR/JOBS_FILE/OUTPUT_DIRmodule constants, restores everything on exit).cron_profile_context_for_home(home)is the thread-side variant (worker threads have no TLS context, so the HTTP handler captures the active home at dispatch time and passes it explicitly). All 12 cron endpoints wrapped (6 GET + 6 POST)._handle_cron_runadditionally captures the TLS-active home at dispatch and forwards it into_run_cron_tracked(job, profile_home)so cron output files land in the correct profile directory. Pre-release reviewer pushed test-skip-on-missing-agent fix so machines without~/hermes-agentrun the suite cleanly. Post-review tightening: removed an over-broadexcept Exceptionaroundget_active_hermes_home()in_handle_cron_run(silent fallback to_profile_home=Nonewould have re-introduced the exact bug the PR fixes — let any unexpected exception 500 the request rather than risk silent cross-profile state corruption); added thread-safety note onos.environmutation explaining why_cron_env_lockis sufficient given CPython GIL semantics +subprocess.Popenenv inheritance at fork time. 4 regression tests intests/test_scheduled_jobs_profile_isolation.py. Two follow-up issues filed for architectural concerns (#1574 lock granularity, #1575 in-process scheduler bypass) — both deferred as out of scope. Verified end-to-end via real browser test on isolated environment (12 sessions, 3 projects, 6 default crons + 1 work-only-cron, 2 profiles): UI profile switch → cron tab auto-refreshes to show only target profile's jobs, both directions; on-disk verification confirmed perfect isolation in~/.hermes/cron/jobs.json(default profile) vs~/.hermes/profiles/work/cron/jobs.json. -
Collapse duplicate provider groups + guard provider-id-as-model.default (#1572; closes #1568) — reporter (Deor, Discord, relayed by @AvidFuturist) saw the Settings → Default Model dropdown rendering OpenCode Go provider as TWO separate optgroups:
"OpenCode Go"(canonical, with all 14 catalog models) and"Opencode_Go"(phantom group containing one self-referential entry). Three structural causes (all inapi/config.py:_build_available_models_uncached). (1) Detection-path id leakage —cfg["providers"]keys are read verbatim, so a config withproviders.opencode_go.api_key(underscore variant) AND another path adding the canonicalopencode-go(e.g. viaactive_provider) end up with both indetected_providers, creating two distinct provider groups with the second labelled viapid.title()fallback as"Opencode_Go". (2) Injection-block rogue model — the default-model injection block puts ANYmodel.defaultstring into the picker as a fake option, so a straymodel.default: opencode_go(provider id mistakenly used as a model id) surfaces as a phantom model labelled"Opencode GO". (3) Empty-group bleed — when a non-canonical provider id makes it intodetected_providersbut has no entry in_PROVIDER_MODELS, the build loop creates an optgroup with zero models. Fix: new_canonicalise_provider_id()helper folds underscores to hyphens, lowercases, applies alias resolution only when the alias target is itself canonical in_PROVIDER_DISPLAY(the constraint that preventsx-aifrom round-tripping through the alias table toxai). Detection-path canonicalises before adding todetected_providers; same treatment in theonly_show_configuredintersection. Post-collection dedup pass re-canonicalises every entry (belt-and-braces against future regressions in any of the ~25detected_providers.add(...)callsites). Provider-id guard on the model.default injection block — when the injected value matches a known provider display name or alias (after underscore/case normalization), skip the injection and emit alogger.warning. Real unknown model IDs (newly released models, custom endpoints) still get injected — only provider-shaped values are rejected. Empty-group filter at end of build (drops optgroups with zero models, withcustom:exemption since users may want an empty card visible as a reminder). 17 new tests intests/test_issue1568_duplicate_provider_groups.pycovering the helper unit, dedup E2E, model.default guard, empty-group filter. Plus one structural test fix intests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known— widened the regex window from 500 → 1500 chars so the new documentation comment block doesn't push_PROVIDER_MODELSpast the substring slice (pre-existing brittle-window pattern, not a new issue).
Tests
4053 → 4094 passing (+41 net: +20 from #1569 Nous featured-set, +17 from #1572 dedup, +4 from #1571 cron isolation). 0 regressions. Full suite in 108s.
Pre-release verification
- All 41 PR-related tests pass standalone.
- All 4094 tests pass in the full suite (clean state, no pre-existing flakes triggered).
- Browser sanity (HTTP API checks against port 8789): 11/11 endpoints verified.
- All modified JS files (
static/commands.js,static/panels.js,static/ui.js) passnode -c. - Real-world browser testing on isolated test environment (12 sessions, 3 projects, 6 default crons + 1 work cron, 4 skills, 2 profiles): profile switch via UI updates the chip, sidebar re-renders, cron tab auto-refreshes to show only target profile's jobs. On-disk verification confirms perfect isolation. Profile chip + cron tab UI confirmed by vision-model.
- Pre-release Opus advisor: SHIP AS-IS — no MUST-FIX. All 5 verification questions check out (conflict-free merge, no deadlock between
_cron_env_lockand_available_models_cache_lock, subprocess env inheritance under lock verified,_canonicalise_provider_iddedup-pass idempotent, stale-fallback handling correct under partial network failure). One non-blocking symmetry nit on_run_cron_trackedworker-side broad-except flagged as a follow-up issue.
Maintainer in-stage actions
- PR rebase verified clean (REBASE-DEFAULT rule applied). All 3 PR branches were on or near current master; rebase was no-op.
- #1571 post-review fix combination: contributor's
df03055(post-review tightening) was onpull/1571/headwhile reviewer'sd83e1d8(test-skip-on-missing-agent) was onorigin/fix/scheduled-jobs-profile-isolation. Cherry-picked the test-skip commit onto the contributor branch to combine both fixes before merging into stage.
[v0.50.287] — 2026-05-03
Fixed (1 PR — closes another vector for the pending-message-loss class)
- Self-update refuses to re-exec while chat streams are active (#1565, @ai-ag2026) — closes the last known vector for the pending-message-loss class fixed in #1471/#1543/#1558. The WebUI self-update path schedules an in-process
os.execv()re-exec after applying updates. That restart-equivalent path is independent of systemd, so when a browser user clicks "Update Now" while a chat is streaming, the process can be replaced mid-stream — same data-loss class as the stale-stream/pending-message work in v0.50.279/v0.50.284. Fix: new_active_stream_count()helper readslen(STREAMS)underSTREAMS_LOCK; bothapply_update(target)andapply_force_update(target)short-circuit at function entry with a structured{ok: False, restart_blocked: True, active_streams: N, message: "Cannot update {target} while {N} active chat stream{s} is running. Wait for the response to finish, then retry the update."}response — before any git command runs and before scheduling restart. Frontend integration:_showUpdateErrorinstatic/ui.js:2882already routesres.messageto the persistent error element, and the "Force update" button only reveals onres.conflict || res.diverged(neither set forrestart_blocked), so the user gets a clean error and correctly cannot escalate to force-update (which has the same restart problem and is also blocked by the same guard). 2 new regression tests intests/test_update_banner_fixes.py::TestApplyUpdateRestartSafetypin the refusal shape AND the absence of side effects (_run_gitnever called;_schedule_restartraises if invoked). Pre-release Opus advisor: SHIP AS-IS — verified that the residual race window (between guard release and_apply_lockacquire) is bounded by design and recoverable via the #1543 pending-message recovery path. Closing the window would require holdingSTREAMS_LOCKacross the whole git+restart sequence, which would block every new chat for the duration of an update — worse UX than the residual race.
Tests
4051 → 4053 passing (+2 from PR #1565). 0 regressions. Full suite in 120s.
Pre-release verification
- All 31 update-banner tests pass standalone in 3.5s (29 existing + 2 new).
- All 4053 tests pass in the full suite.
- Browser sanity (HTTP API checks against port 8789): 11/11 endpoints verified.
- Pre-release Opus advisor: SHIP AS-IS — all 5 verification questions resolved (race-window bounded, lock ordering safe, no deadlock, frontend integration clean, test isolation robust against assertion failures).
[v0.50.286] — 2026-05-03
Fixed (1 PR — closes #1560)
- Settings password field silently no-ops when
HERMES_WEBUI_PASSWORDenv var is set (#1561, @dutchaiagency; closes #1560 — resurfaced from #1139) — whenHERMES_WEBUI_PASSWORDwas exported,api/auth.py:get_password_hash()already returned the env-var hash and ignoredsettings.json["password_hash"]. But the Settings → System pane never knew this, so the password field accepted input, called the API, returned 200, and showed a green "Saved" toast — every subsequent login still required the env-var password. Same for "Disable Auth" / clearing the password. The save genuinely succeeded; it was just unreachable. Fix — three layers: (1)GET /api/settingsnow includespassword_env_var: bool(env)so the UI can detect the locked state. Hash still stripped from response (existing invariant). (2)POST /api/settingsrefuses_set_passwordand_clear_passwordwith HTTP 409 + an explanatory message namingHERMES_WEBUI_PASSWORDwhen the env var is set. The 409 short-circuits BEFOREsave_settings(), so the on-disk hash is never touched. Whitespace-only env values are not treated as set (matchesapi/auth.py.strip()guard). (3) Frontend (static/index.html,static/panels.js,static/i18n.js) — added#settingsPasswordEnvLockbanner div in the System pane (hidden by default). Whenpassword_env_varis true: password input isdisabled, value cleared, placeholder swapped to a localized "Locked: HERMES_WEBUI_PASSWORD env var is set" string; banner revealed; Disable Auth button hidden (its POST would 409 anyway); Sign Out stays available since it only clears the session cookie. 2 new i18n keys (password_env_var_locked,password_env_var_locked_placeholder) added to all 9 shipped locales (en, ja, ru, es, de, zh, zh-Hant, pt, ko). Each locale's banner string literally namesHERMES_WEBUI_PASSWORDso users can grep their environment. 23 new regression tests intests/test_issue1560_password_env_var_lock.py(12 tests) andtests/test_1560_password_env_var_no_op.py(11 tests) covering both the surfacing flag, the 409 refusal on both write paths, frontend lock behavior, and 9-locale parity. Pre-release Opus advisor pass. Maintainer-rebased from contributor's v0.50.283 base onto current master cleanly.
Tests
4028 → 4051 passing (+23 from PR #1561). 0 regressions. Full suite in 115s.
Pre-release verification
- All 23 PR-1561 tests pass standalone in 3.6s.
- All 4051 tests pass in the full suite (110s).
- Browser sanity (HTTP API checks against port 8789): 11/11 endpoints verified.
- All modified JS files (
static/i18n.js,static/panels.js) passnode -csyntax check. - PR rebase verified clean:
git diff origin/master --statshows ONLY the 6 files PR #1561 touches (no spurious deletions of v0.50.284/v0.50.285 test files that the older PR base would have dropped).
[v0.50.285] — 2026-05-03
Fixed (1 PR — same-day hotfix-of-hotfix)
- Session recovery scanner crashes on
_index.json(silent no-op in production) (closes #1558 follow-up) — v0.50.284's startup self-heal (api/session_recovery.py:recover_all_sessions_on_startup) crashed on the very first*.jsonit scanned in the production session directory. The session dir contains an_index.jsonfile whose top-level shape is a list (the index of session metadata dicts), not a dict._msg_count()diddata.get('messages')which raisesAttributeError: 'list' object has no attribute 'get'. The broadexcept Exceptioninserver.py's startup hook swallowed the error and printed[recovery] startup recovery failed: 'list' object has no attribute 'get', so the recovery silently no-op'd for every user — defeating the entire purpose of the v0.50.284 startup self-heal. Verified live on the production server immediately after the v0.50.284 deploy: log line confirmed the failure, no recovery attempted. Fix: (1)_msg_count()now guardsif not isinstance(data, dict): return -1so non-dict-shaped JSON files return the harmless "unknown count" sentinel instead of raising. (2) The scanner skips any file whose name starts with_(the existing project convention for non-session metadata files like_index.json). (3) The scanner now wrapsrecover_session(path)intry/except Exceptionso a single malformed file can't break recovery for the rest. 2 new regression tests intests/test_metadata_save_wipe_1558.py:test_recover_all_sessions_on_startup_skips_non_session_index_jsonandtest_msg_count_returns_neg1_for_non_dict_top_level. Net effect: any user wiped between v0.50.279 and v0.50.284 deploys whose session left a.bakwill now get auto-recovered on first launch of v0.50.285, as v0.50.284's release notes promised.
Tests
4026 → 4028 passing (+2 from the 2 new regression tests). 0 regressions. Full suite in 114s.
Pre-release verification
- All 8 tests in
tests/test_metadata_save_wipe_1558.pypass (6 original + 2 new regression). - Live verification on production server: pre-fix log line
[recovery] startup recovery failed: 'list' object has no attribute 'get'. Post-fix expected log:[recovery] Restored N/M sessions from .bak (see #1558).(or empty scan if no.bakfiles). - Pre-release Opus advisor pass on the hotfix.
Why this needed a same-day v0.50.285 vs being deferred
v0.50.284 promised that "the first server start after deploying v0.50.284 will auto-restore any session that was wiped between deploys." That promise was broken in production by the _index.json shape mismatch — the recovery silently never fired. Affected users (the original reporter on v0.50.282 with the 1000+ message session that disappeared) had <sid>.json.bak files on disk but those files would never be processed. Same-day hotfix restores the promise.
[v0.50.284] — 2026-05-03
Fixed (2 PRs — P0 streaming hotfix batch — closes #1533, #1558)
-
P0 data-loss hotfix: metadata-only Session.save() wipes conversation history (#1559, maintainer self-built; closes #1558) — Severity: P0. v0.50.279's
_clear_stale_stream_state()(#1525) calledsave()on a session that may have been loaded withmetadata_only=True.Session.save()writesself.messagesto disk via atomicos.replace(), andmetadata_onlystubs synthesizemessages=[]. Result: the on-disk session JSON was atomically replaced with an empty messages list. Every active conversation on v0.50.279 — v0.50.282 was at risk of being silently wiped on the next SSE reconnect after a server restart. Reported by a user on v0.50.282 ("getting weird issues with the latest updates… my prompt disappears… 1000+ message session disappeared too"). The "Reconnecting…" banner with a counter the user screenshotted was the observable symptom of the data being wiped — each cycle of the reconnect loop ran the data-loss code path. Three defensive layers + a startup self-heal: (1)Session.save()raisesRuntimeErrorif_loaded_metadata_only=True— loud crash beats silent wipe;Session.load_metadata_only()sets the flag on the returned stub. (2)_clear_stale_stream_state()detects the metadata-only stub and reloads withmetadata_only=Falsebefore mutating; if the reload fails, bails without clearing rather than wipe (correct asymmetry: better stale flag than wiped data). (3) Asymmetric backup —Session.save()writes<sid>.json.bakIFF the previous on-disk message count is greater than the incoming one (zero overhead on grow path; snapshot on any shrink). (4) Startup self-heal in newapi/session_recovery.pymodule — on server start, scans session JSONs whose count is less than their.bakcount and restores from.bak. Idempotent on clean state. The first server start after deploying v0.50.284 will auto-restore any session that was wiped between deploys. 6 new regression tests intests/test_metadata_save_wipe_1558.pycovering all four layers + idempotence. Pre-release independent reviewer (nesquena) APPROVED with one MUST-FIX (issue-number references #1557 → #1558) which was absorbed. Pre-release Opus advisor SHIP AS-IS with two SHOULD-FIX items absorbed in-release: (a) patch the caller's in-memory stub fields after a successful clear so/api/sessiondoesn't briefly return staleactive_stream_id, avoiding one ghost SSE reconnect; (b) atomic.bakwrite viatmp + os.replace()pattern matching the main file write — prevents a torn.bakfrom a crash mid-write. -
Race fix: stale stream cleanup mutates outside the per-session lock (#1557, @dutchaiagency; closes #1533) — Opus advisor follow-up from v0.50.279.
_clear_stale_stream_state()heldSTREAMS_LOCKonly across the registry lookup; the write tosession.active_stream_id = Nonehappened after release. A concurrent_handle_chat_starton the same session could race: the reader thread could clobber a freshly-registered stream'ssession.active_stream_id, orphaning the new stream and forcing one user retry. Fix: wrap the mutate-and-save block in_get_session_agent_lock(session.session_id)and re-readactive_stream_idinside the lock, bailing if it changed. New deterministic two-thread regression testtest_stale_stream_cleanup_does_not_clobber_concurrent_chat_start. Effect was bounded (one user retry per race window, no data corruption), but the lock is the right shape and the contributor included an actual race test instead of asserting source shape.
Affected versions
- v0.50.279 — first vulnerable to the P0 data-loss path
- v0.50.280, v0.50.281, v0.50.282, v0.50.283 — also vulnerable
- v0.50.284 — this release; fixes the data-loss path, ships startup self-heal so users wiped between deploys get auto-recovery on next launch, and closes the related stale-stream race
Maintainer in-stage fixes (test isolation)
tests/test_sprint29.py::test_valid_skill_accepted— now cleans up thetest-security-skillit creates. Previously leaked into the test SKILLS_DIR and shifted whattests/test_sprint3.py::test_skills_*saw.tests/test_sprint3.py::test_skills_content_known— picks the first skill from/api/skillsrather than hardcodingdogfood, withpytest.skipon empty list (signal that a sibling test repointed the SKILLS_DIR).tests/test_sprint3.py::test_skills_search_returns_subset— relax> 5threshold to> 0, same skip-on-empty escape. Functional contract under test: API returns non-empty when there are skills to return.
Tests
4019 → 4026 passing (+7 net: +6 from #1559 P0 hotfix tests, +1 from #1557 race regression). 0 regressions. Full suite in 109s.
Pre-release verification
- Stage merge: clean apart from the expected
api/routes.pyconflict (combined Layer 2 metadata-only reload + #1557 lock; resolved with metadata-only check FIRST so a stub never even acquires the agent lock). - Browser sanity (HTTP API checks against port 8789): 11 endpoints verified.
- Pre-release Opus advisor: SHIP AS-IS — all 5 verification questions cleared (conflict-resolution order, deadlock risk none, Layer 3 backup interaction, startup self-heal vs concurrent saves, test-isolation fix correctness). Two SHOULD-FIX items absorbed in-release.
[v0.50.283] — 2026-05-03
Fixed (8 PRs — full sweep batch — closes #1426, #1481, #1512, #1468, #1424, #1457, #1401)
-
OpenRouter free-tier visibility — structural live fetch (#1548 augmented from @bergeouss; closes #1426) — when an operator selected an OpenRouter free-tier model like
minimax/minimax-m2.5:free, it was invisible in the picker becausehermes_cli/models.py:_openrouter_model_supports_tools()filters out models that don't advertisetoolsinsupported_parameters— and OpenRouter often hasn't yet annotated newly-added free variants. The original PR added 5 hardcoded_FALLBACK_MODELSentries; per maintainer directive ("augment the one that's going to rot fast with a live refresh"), the merged version replaces the static slice with two live-fetches plus the static fallback for offline/test envs: (1) curated catalog viahermes_cli.models.fetch_openrouter_models()— applies the tool-support filter; (2) directhttps://openrouter.ai/api/v1/modelsfiltered to free-tier-only (pricing.prompt == 0ANDpricing.completion == 0, OR:freesuffix), bypassing the tool-support filter so newly-added free variants appear even before OpenRouter annotates them withtools. Capped at 30 to keep the picker usable. Falls back to_FALLBACK_MODELS[provider==OpenRouter](which retains @bergeouss's hardcoded list as defense-in-depth) when both live fetches fail. Dedup viaseen_idsso a model in both surfaces appears once. 5 new tests intests/test_issue1426_openrouter_free_tier_live_fetch.py. Pre-release Opus advisor verified no SSRF surface (URL is hardcoded literal, can't be config-redirected). -
Pending user turn recovery on stale stream restart (#1543, @ai-ag2026; follow-up to #1471) — when a server restart happens mid-turn, the user's just-submitted prompt was the only durable copy and was silently discarded along with the stale stream state. Now
api/models.py:_apply_core_sync_or_error_markermaterializes the pending user turn with_recovered: trueBEFORE clearing runtime fields ifmessagesis non-empty ANDpending_user_messageis set. Adds 49 LOC of regression coverage intests/test_stale_stream_pending_recovery.py. -
Silent credential self-heal on 401 errors (#1553, @bergeouss; closes #1401) — when
auth.jsondrifts (file rewritten by another process, OAuth refresh elsewhere, env-var rotation) and the streaming layer hits an auth-only 401, the WebUI now re-readsauth.json, invalidates the credential pool cache via the newinvalidate_credential_pool_cache(provider_id)export, and retries the request once with fresh credentials. Single retry only, auth-only trigger, thread-safe (acquires_available_models_cache_lockfor cache mutation). Reverts to the original error emission if the retry also fails. ~263 LOC acrossapi/streaming.py,api/oauth.py,api/config.py. Pre-release Opus flagged 4 non-blocking SHOULD-FIX code-quality items (retry-logic duplication between in-line and except paths, fragile_assistant_added=Trueflag pattern,in dir()vsin locals()idiom, nocancel_evtcheck before retry) — deferred as follow-up since structural refactor is >20 LOC. -
Reveal in File Manager (#1551, @bergeouss; closes #1424) — new workspace-file context menu item. Cross-platform: macOS (
open -R), Linux (xdg-openon parent dir), Windows (explorer /select,<path>). New/api/workspace/revealPOST handler validates the path throughsafe_resolve(verified by Opus advisor — blocks both absolute/etc/passwdinjection and relative../traversal) and uses list-argsubprocess.Popen(no shell injection). Plus 2 new i18n keys (reveal_in_finder,reveal_failed) translated to all 8 non-English locales (ja, ru, es, de, zh, zh-Hant, pt, ko) — pt translation absorbed in-stage from Opus advisor SHOULD-FIX (contributor branch covered en + 7 locales, missed pt; pt parity test doesn't exist yet so the gap was invisible to CI but would have shown English fallback to Portuguese users). -
Gateway status card in Settings → System (#1552, @bergeouss; closes #1457) — new read-only display card in the System settings tab. New
/api/gateway/statusendpoint returns connected platforms (Telegram/Discord/Slack/Weixin), active session count, and last-active timestamp. No behavior change to gateway internals. -
Auto-assign session to active project filter (#1550, @bergeouss; closes #1468) — when the user is filtering the sidebar by project X and clicks "+ New session", the new session inherits
project_id=Xinstead of starting unassigned. Three-lineapi/models.py:new_sessionsignature extension (project_id=Nonekwarg) + matching frontend pass-through instatic/sessions.js. -
"What's new?" link in update banner (#1549, @bergeouss; closes #1512) —
api/updates.py:_check_reponow returnsrepo_url(SSH→HTTPS conversion +.gitstrip); the update banner adds a small accent-colored anchor that points to${repo_url}/compare/${current}...${latest}so users can read release highlights in one click. -
Phantom
/sw.jsPUBLIC_PATHS whitelist removed (#1545, @bergeouss; closes #1481) — the/sw.jspath is served via a dedicated route handler that doesn't go through thePUBLIC_PATHScheck, so the leftover whitelist entry was vestigial. When auth is enabled,/sw.jscorrectly requires the session cookie (security hardening side-effect, not a regression — service worker fetches travel with the cookie from authenticated context).
Tests
3990 → 4019 passing (+29 net from constituents: +5 from #1548 OpenRouter, +1 from #1543 recovery, +14 from PR #1544's earlier #1538/#1539 work shipped in v0.50.282, +9 from this batch including the +5 OpenRouter regression suite). 0 regressions. Full suite in 111s.
Pre-release verification
- All 8 merges produced clean
ortstrategy results (no conflict markers). - Browser sanity (HTTP API checks against port 8789): 11 endpoints verified.
- All modified JS files pass
node -csyntax check. - Pre-release Opus advisor v2: SHIP WITH ABSORPTIONS — 1 MUST absorb (≤2 LOC pt locale gap, applied in-stage), 4 SHOULD-FIX deferred from #1553 self-heal (>20 LOC structural refactor, follow-up issue planned), 1 SHOULD-FIX deferred for cross-locale parity test (would have caught the pt gap at PR review time).
Maintainer post-merge fixes (in-stage)
static/i18n.js: pt localereveal_in_finder/reveal_failedtranslations added (Opus-flagged, 2 LOC).tests/test_minimax_provider.py::test_minimax_fallback_provider_label— scoped to direct-MiniMax routes (filter byminimax/prefix, exclude:free) since #1548'sminimax/minimax-m2.5:freecorrectly carriesprovider='OpenRouter'(it routes via OpenRouter, not direct MiniMax).
[v0.50.282] — 2026-05-03
Fixed (1 PR — closes #1538, #1539)
-
Nous Portal full live catalog + dropdown cache invalidation on provider remove (#1544; closes #1538, #1539) — two related dropdown-staleness bugs reported by Deor (Discord, May 03 2026, relayed by AvidFuturist). Same root shape: a model picker showing stale data because the live source of truth was never asked.
#1538 — Nous Portal picker stuck at 4 hardcoded models.
_PROVIDER_MODELS["nous"]had four hardcoded entries (Claude Opus 4.6 / Sonnet 4.6, GPT-5.4 Mini, Gemini 3.1 Pro Preview) and_build_available_models_uncached()fell through to the genericpid in _PROVIDER_MODELSbranch, deepcopying that four-entry list. The actual live Nous catalog has 30 models — Claude Opus 4.7, GPT-5.5, Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun entries, and more. Two parallel surfaces showed the stale four:/api/models(composer picker, Settings → Default Model, /model slash) and/api/providers(Settings → Providers card). Fix: new_format_nous_label()helper inapi/config.pythat drops the vendor namespace and appends(via Nous)(reusing_format_ollama_label's token rules); newelif pid == "nous":branch in_build_available_models_uncached()mirroring the Ollama Cloud pattern (live-fetch viahermes_cli.models.provider_model_ids("nous"), prefix every id with@nous:to match the existing routing convention pinned bytests/test_nous_portal_routing.py, fall back to the curated 4-entry static list whenhermes_cliis unavailable so the picker is never empty); same fix applied toapi/providers.py:get_providers()for the parallel card-list path.#1539 — Removed provider lingered in dropdowns until restart. Server-side cache was correctly flushed (
set_provider_key()callsinvalidate_models_cache()on both add and remove), but three JS-side caches were never dropped after/api/providers/delete:_slashModelCache/_slashModelCachePromise(commands.js — feeds /model slash suggestions) and_dynamicModelLabels/window._configuredModelBadges(ui.js — populated bypopulateModelDropdown). Pre-fix,_removeProviderKey()only refreshed the providers card list and never asked any consumer to re-fetch /api/models. Fix: new_invalidateSlashModelCache()helper instatic/commands.js(typeof-window-guarded so the module remains importable in headlessvm.runInContexttest contexts used bytests/test_cli_only_slash_commands.py); new_refreshModelDropdownsAfterProviderChange()helper instatic/panels.jsthat calls the invalidator +populateModelDropdown(), wrapped in try/catch with a fire-and-forgetPromise.resolve(...).catch(()=>{})so a slow/api/modelsdoesn't block the providers panel refresh. Both_saveProviderKeyand_removeProviderKeyinvoke the helper — defense-in-depth, the same staleness shape applies to the add path too.Verified live on port 8789:
/api/modelsNous group returns 30 models (was 4); browserdocument.getElementById('modelSelect')exposes 30 options under "Nous Portal"; the dropdown-flush helpers are callable from the browser and round-trip rebuild keeps the dropdown at 30 options. nesquena APPROVED before merge with full end-to-end trace + behavioral harness on the label formatter; one non-blocking docstring observation (3-letter token rule produces "PRO" rather than "Pro" on tokens likegemini-3.1-pro-preview) addressed in a follow-updocs:commit on the same branch — pure docstring text, no behavioral change. 23 new regression tests (12 ontests/test_issue1538_nous_live_catalog.pycovering live-fetch + @nous: prefix invariant + " (via Nous)" suffix invariant + recent-flagship coverage + static fallback when hermes_cli raises + label formatter unit tests + static-list preservation; 11 ontests/test_issue1539_provider_removal_dropdown_invalidation.pycovering helper definition + both cache slots cleared + window exposure with typeof guard + both save and remove paths invoke flush + helper resilience to missing modules + helper does not block panel refresh + server-sideset_provider_key → invalidate_models_cacheinvariant pinned). 4013 tests pass (was 3990 → 4013, +23 from this PR).
[v0.50.281] — 2026-05-03
Fixed (1 PR by external contributor — closes #1527, #1530)
- LM Studio LAN-IP / Tailscale / reverse-proxy classification + new-session provider default (#1536, @dutchaiagency; closes #1527 #1530) — when LM Studio (or any local OpenAI-compatible endpoint) is configured at a non-canonical hostname like
http://192.168.1.22:1234/v1(LAN IP),http://my-mac.tailnet.example:1234/v1(Tailscale), orhttps://lm.internal.example.com/v1(reverse proxy), the WebUI's model-discovery hostname-substring guess ("lmstudio" in host or "lm-studio" in host) failed every time → discovered models landed in the "Custom" provider group → the active LM Studio dropdown was empty → the WebUI offered no models. Downstream: when the operator picked a model anyway, the new session'sprovider/base_urldefaulted to OpenRouter (the fallback for unknown classifications), so every API call went to OpenRouter instead of the configured local server and failed. Fix: two new helpers inapi/config.py(_normalize_base_url_for_matchand_configured_provider_for_base_url) trust the user's config block —model.base_url,providers.<id>.base_url, thencustom_providers[].base_url— before falling back to hostname guesses. The hostname-substring branch is now gated behindnot provider_from_configso config wins. Auto-detected models are also bucketed by provider id (auto_detected_models_by_provider) so a configured LM Studio entry's discovered models land in the LM Studio group, not the generic Custom group. v0.50.277's deepcopy contract preserved at every consumer site (verified by Opus advisor — shared-reference source dicts cloned before any group iterates them, so dedup mutation never bleeds across groups). 5 new regression tests cover LAN IP / Tailscale / reverse-proxy LM Studio configs, custom-on-localhost (must not be reclassified as ollama), and the #1530 round-trip viaresolve_model_provider. Cross-tool safe: agent CLI readsmodel.base_urldirectly from config.yaml — this PR only changes how WebUI classifies the configured base_url for the model picker. First contribution by @dutchaiagency — onboarded as a regular contributor in this PR thread; future contributions will focus on provider/config routing, onboarding, model picker behavior, cache/test hardening.
[v0.50.280] — 2026-05-03
Added (1 PR — Frank Song — cross-channel messaging handoff)
- Cross-channel messaging handoff (#1404, @franksong2702; closes #1013) — when a Discord/Slack/Telegram/Weixin conversation is bridged into the WebUI via the messaging gateway, the composer now renders a docked "handoff" flyout above the composer (slim slide-up panel matching the terminal-collapsed dock and workspace-files panels) summarizing the live external session. After 10 rounds of message exchange a transcript-summary card surfaces — operators get a quick catch-up of the channel context without scrolling the full transcript. Sidebar dedup now keys on
_messaging_session_identity(session, raw_source)(api/routes.py:776-810) — distinct chats from the same platform stay separate (e.g. two different Telegram threads with the same person now show as two sidebar rows, not one). Dup/Delete options are removed from external messaging session right-click menus (the underlying gateway owns lifecycle for those). 13 files, 3439 LOC, 73 PR-related tests + 729 lines added totest_gateway_sync.pycovering the dedup, identity, and import paths. UX-approved on Discord by @aronprins after three rounds of feedback (composer-docked entry, transcript-card alignment, flyout-card visual consistency). Maintainer-rebased onto current master with one resolved conflict inapi/routes.py(kept both_clear_stale_stream_state(s)and the new CLI messaging-session loading path; verified order-safe by Opus advisor).
Fixed (1 PR — salvage of #1531)
- Reasoning effort actually flows into WebUI agents (#1535, salvages #1531 by @Asunfly; closes #1531) —
api/streaming.py:1820was reading_cfg.cfg.get('agent', {})butget_config()returns a plain dict, not a wrapper exposing.cfg. The buggy line raisedAttributeErrorswallowed by the surroundingtry/except, so_reasoning_configwas alwaysNoneregardless of what/reasoning <level>had been set to. Operators got the agent's default effort no matter what they configured. Smoking gun:api/streaming.py:1959already correctly used_cfg.get(...)— same_cfgwas being read two different ways in the same function. Fix is two surgical lines:_cfg.cfg.get(...)→_cfg.get(...)plus_reasoning_config or {}added to the per-session agent cache_sig_blobso changing effort mid-session rebuilds the cached agent (mirrors howresolved_provider/resolved_base_urlalready participate). Two static-source assertion regression tests intests/test_regressions.py(R17b/R17c) pin both fixes. Spliced from #1531 Change-1 only — Change-2 (auxiliary title-routeextra_bodyrefactor) skipped as separate scope; Asunfly may re-open as its own PR.
[v0.50.279] — 2026-05-03
Fixed (8-PR batch from full PR sweep — closes #1463, #1491, #1503, #1509, #1522)
-
Branch indicator codepoint corrected (#1523, @franksong2702; closes #1522) — the fork-indicator glyph in the sidebar was rendering
⒂ PARENTHESIZED DIGIT FIFTEEN(\u2482) instead of the intended⑂ OCR FORK(\u2442). Forked sessions appeared with a mysterious "(15)" prefix that looked like a message count or unread badge — users would click expecting something related to "15" and find nothing. The actual fork indicator was invisible. One-character fix instatic/sessions.js:1657plus the matching test assertion update. -
Onboarding API-key field stops losing focus during probe (#1519, @franksong2702; closes #1503) — the wizard's API-key input had
oninput="_scheduleOnboardingProbe()"firing a 400ms-debounced probe on every keystroke. When the probe completed,_renderOnboardingBody()rebuilt the entire form DOM, destroying the<input>element the user was typing into. On localhost the probe completes in ~5-50ms so the bug window was narrow; on slow networks (VPN, corporate proxy, cold-start vLLM) the re-render routinely landed between keystrokes. Especially painful on the password field where users paste long secrets. Fix: removed_scheduleOnboardingProbe()from the api-key input'soninputhandler instatic/onboarding.js:200; addedonblur="_runOnboardingProbe()"so the probe still fires when the user tabs away. The probe also still fires via the "Test connection" button andnextOnboardingStep()before Continue — no flow breakage. -
Voice-mode pref toggle-off now stops the recognizer (#1518, @franksong2702; closes #1491) — if a user enabled the hands-free voice mode (PR #1489, v0.50.271), started a conversation, then opened Settings → Preferences and disabled the pref, the button disappeared but the SpeechRecognition kept running. The user had no way to stop it short of reloading the page — and it was consuming microphone access + battery the whole time. Fix:
_applyVoiceModePref()instatic/boot.jsnow reads the pref into a localenabledvariable and calls_deactivate()(the standard cleanup path that stops recognition, clears timers, restores TTS, resets UI state) when!enabled && _voiceModeActive. Plus a TDZ-safety hoist:let _voiceModeActive = falsemoved above_applyVoiceModePref()(was previously declared after the function — Temporal Dead Zone risk if the function were ever called before init). -
YAML code blocks render with newlines (#1516, @franksong2702; closes #1463) — Prism's YAML grammar wraps tokens in
<span class="token …">elements wherewhite-spacedefaults tonormal, collapsing\ncharacters into spaces even when the underlyingtextContentpreserved them. Plain code blocks andlanguage-bashrendered correctly; onlylanguage-yamlwas affected. YAML is one of the most common LLM output formats (config files, docker-compose, CI pipelines, Kubernetes manifests) — flattened YAML in chat is unreadable. Fix: two CSS rules instatic/style.cssforcingwhite-space: pre !importanton.msg-body pre code.language-yaml .tokenand.preview-md pre code.language-yaml .token. Scoped tightly to YAML — no impact on other languages. Verified via the reporter's two diagnostic probes (textContenthad\n, onlylanguage-yamlwas affected) that the renderer pipeline was correct and the fix needed to be at the CSS layer. -
Service-worker placeholder consolidation (#1517, @franksong2702; closes #1509) —
__CACHE_VERSION__(instatic/sw.js) and__WEBUI_VERSION__(instatic/index.html) were functionally identical: both substituted at request time viaquote(WEBUI_VERSION, safe=""). Two names existed for historical reasons (different files added at different releases). Naming hygiene flagged by both the independent reviewer and the Opus advisor during the v0.50.276 release review. Fix: rename__CACHE_VERSION__→__WEBUI_VERSION__acrossstatic/sw.js,api/routes.py,tests/test_pwa_manifest_sw.py. Pure rename, no behavior change — same?v=vX.Y.Zquery strings on the same URLs at the wire. -
WebUI-origin state.db sessions recoverable when JSON sidecar missing (#1532, @ai-ag2026; refs #1471) — when a WebUI-origin session existed in
state.db.sessions/state.db.messagesbut the matching~/.hermes/webui/sessions/<id>.jsonsidecar was missing (possible after disk-write failures, partial restore, or interrupted writes), the session was invisible to/api/sessionseven though the canonical SQLite messages were intact. Root cause:read_importable_agent_session_rows()had a hard-codeds.source != 'webui'predicate that re-applied the filter even when callers opted out viaexclude_sources=None. Slice 1 of the #1471 session-recovery class. Fix:api/agent_sessions.pymakes the default exclusion explicit (("cron", "webui")) and removes the hard-coded predicate soexclude_sources=Noneactually includes WebUI-origin rows. New regression testtest_webui_state_db_session_without_sidecar_appears_when_agent_sessions_enabled. -
Stale runtime stream state cleared proactively (#1525, @ai-ag2026; refs #1471) — session JSON could retain
active_stream_idplus paired pending fields (pending_user_message,pending_attachments,pending_started_at) after a stream failure, provider exception, or server restart./healthwould correctly reportactive_streams: 0, but/sessions/<id>would still claimagent_running(pure truthiness ons.active_stream_id) and the frontend'sINFLIGHT[sid]would keep the UI busy on a dead stream. Slice 2 of the #1471 session-recovery class, distinct from #1532's "session in DB but no sidecar" path. Fix: new_clear_stale_stream_state()helper inapi/streaming.pyruns proactively at the read boundary (/sessions/<id>GET) and before new turns start. Verifies the stream is actually missing fromSTREAMS(the in-memory registry) before clearing — never expires live streams by age. Frontend half:static/sessions.jsclearsINFLIGHT[sid]when the server reports noactive_stream_id. Maintainer merge-conflict resolution: kept the rename-sideCACHE_NAME = 'hermes-shell-__WEBUI_VERSION__'(post-#1517 rename) over the PR's manual-stale-stream-cleanup1suffix. The renamed placeholder still auto-bumps with each release throughquote(WEBUI_VERSION, safe=""), so the manual suffix was redundant — natural version bump (v0.50.278 → v0.50.279) already invalidates the old cache viacaches.delete(k)fork !== CACHE_NAMEin the SW activate handler. 5 new regression tests intest_stale_stream_cleanup.py. -
WebUI max_tokens forwarded to agent + OpenRouter quota classifier (#1526, @ai-ag2026; refs #1524) — WebUI agent initialization didn't pass the configured
max_tokenstoAIAgent, so provider-native output ceilings could be requested. On OpenRouter this could fail with quota-style HTTP 402 messages likemore credits,can only afford,fewer max_tokens. Pre-fix, those phrases weren't classified as quota failures and didn't trigger the fallback chain — users saw raw 402 errors instead of automatic fallback to a less-expensive model. Fix:api/streaming.pyreads configuredmax_tokensfrom top-level +agent.max_tokensfallback, parses positive integers, includes bothmax_tokensand the fallback state in theSESSION_AGENT_CACHEsignature (so config changes don't reuse a stale cached agent), and passesmax_tokenstoAIAgentonly when the constructor supports it (usesinspect.signature(AIAgent.__init__)rather than a try/except that would swallow realTypeErrors). Quota classifier additions for the three OpenRouter phrases route to the same fallback chain as existing quota markers. New regression tests intest_streaming_max_tokens_quota.py.
Notes
- 3936 → 3946 tests passing (+9 from constituent PRs + 1 conflict-marker regression guard added in-release per Opus MUST-FIX).
- Pre-release Opus advisor pass: caught a MUST-FIX (sw.js merge-conflict markers still in tree despite earlier
git add/commit) that would have shipped a broken service worker. Resolution applied in stage and atest_sw_js_has_no_merge_conflict_markersregression guard added so this can't happen silently again. One SHOULD-FIX (race in_clear_stale_stream_statebetween registry-check and session-mutate) explicitly deferred to follow-up #1533 per Opus's "fine to defer given the narrow window" advice — bounded effect (orphaned stream requires retry, no data corruption). - One merge conflict resolved during stage build (#1525 vs #1517 cache-name placeholder collision); resolution drops PR #1525's manual
-stale-stream-cleanup1suffix in favor of the canonical__WEBUI_VERSION__token (natural release-bump preserves the cache-invalidation guarantee). - 2 PRs closed as duplicates during sweep: #1528 (identical to #1517) and #1529 (superseded by #1516,
.preview-mdcoverage missing). - 5 PRs stay on hold: #1418 (hard prereq hermes-agent#18534 not yet merged), #1464 (blocker —
noResultsternary inverted, awaiting JKJameson fix), #1404 (UX — aronprins width feedback unresolved), #1353 (alreadyready-for-reviewtagged, durability path needs independent review), #1311 (draft + CONFLICTING). - 1 PR routed to maintainer-review: #1531 (Asunfly stowaway change in force-push to title aux generation that wasn't in PR description; awaiting scope decision).
[v0.50.278] — 2026-05-03
Added (1 PR — splices best of #1497 + #1513)
- Sidebar "Unassigned" filter chip (self-built, splices contributor PRs #1497 by @Thanatos-Z and #1513 by @AlexeyDsov; both contributors credited via
Co-authored-bytrailers on the merge commit) — adds a new chip to the project filter bar in the session sidebar. Clicking it filters the visible sessions to those with noproject_idassigned. First-principles synthesis of both contributor approaches: (1) Sentinel state from #1497 (NO_PROJECT_FILTER = '__none__'constant on the existing_activeProjectvariable rather than a parallel_showNoneProjectboolean from #1513) — single state variable, no two-state-machine ambiguity, "All" handler resets one variable, no risk of "All" + "Unassigned" both reading active. UUID hex collision impossible (api/models.py:923andapi/routes.py:2672both useuuid.uuid4().hex[:12], no underscores). (2) Conditional rendering from #1497 — chip only appears whenhasUnprojected = profileFiltered.some(s => !s.project_id)is true, so the project-bar stays uncluttered in the common case where every session is organized. The project-bar itself now also renders when there are unassigned sessions even with no projects (was previously gated on_allProjects.length > 0alone). (3) Dashed-border visual from #1497 (.project-chip.no-project{border-style:dashed;}) reads as a meta-filter rather than another project. (4) "Unassigned" label (new) is clearer than #1497's "No project" (sounds like a status filter) or #1513's "None" (ambiguous — none of what?). Matches conventional file-manager / task-tracker UX. Hover tooltip elaborates: "Show conversations not yet assigned to a project." (5) Branched empty-state copy from #1497 ("No unassigned sessions." vs the generic "No sessions in this project yet."). 7 regression tests intests/test_sidebar_unassigned_filter.pypin every contract: sentinel constant declared, filter logic uses!s.project_idwhen sentinel is active, chip only renders when relevant, label and click handler, dashed-border treatment, branched empty-state copy, and the "All" chip handler resets_activeProjectto null (catches a regression toward a parallel-boolean design).
Notes
- 3929 → 3936 tests passing (+7 regression tests).
- Pre-release Opus advisor pass: SHIP AS-IS. Verified sentinel collision impossible, stale-active-filter on project delete safe (sentinel never equals a real project_id), CSS specificity has no conflict (active chip = dashed border + accent color), source-string tests match the sibling-feature pattern. One non-blocking edge case (stuck filter when zero projects + zero unassigned, recoverable via page reload) explicitly deferred per Opus advice — too narrow to justify pre-merge work.
- Both contributor PRs (#1497, #1513) remain open and unaffected — this PR specifically supersedes only the "no project filter" sub-feature of each. #1497's other changes (sticky controls, batch-select repositioning) still need their own UX review pass; #1513's right-click context menu was intentionally dropped because "rename/delete no project" isn't a meaningful action.
- Live verified at port 8789 with seeded data (5 projects + 77 sessions, ~73 unassigned in the active profile): chip toggles correctly between filters, dashed border present per
getComputedStyle, active state applies the accent treatment.
[v0.50.277] — 2026-05-03
Fixed (1 PR — self-built, supersedes contributor PR #1511)
- Model picker no longer corrupts ids/labels when multiple unconfigured providers expose the same model (self-built; supersedes contributor PR #1511 by @lost9999; reporter @vishnu via Discord) — when multiple "auto-detected" providers (Ollama / HuggingFace / custom OpenAI-compatible endpoints / Google Gemini CLI / Xiaomi / etc.) all fell through to the unconfigured-provider branch in
api/config.py:get_models_grouped(), every group ended up sharing the SAMEauto_detected_modelslist reference AND the SAME dicts inside. When_deduplicate_model_ids()then mutated those dicts to add@provider_id:prefixes and provider-name parentheticals, the changes were applied to every group that referenced the same dict. Visible symptom: the dropdown showedDeepseek V4 Flash (Xiaomi) (Ollama) (HuggingFace) (Google-Gemini-Cli)— accumulated provider names. Hidden symptom (worse, never reported as a bug): theidfield also collapsed to@xiaomi:deepseek-v4-flash(whichever provider_id won the alphabetical-first race) on every group, so selecting the model under any group silently routed the request to the wrong provider. Contributor PR #1511 attempted to fix this by removing the label-suffix logic in_deduplicate_model_ids()— that would have hidden the visible label clutter while leaving the silent ID-routing bug intact. The proper fix is at the assignment site:api/config.py:2078now wrapsauto_detected_modelsincopy.deepcopy()when assigning to a group, so each group gets its own independent dicts and dedup mutation cannot bleed across groups. The existing_deduplicate_model_ids()logic is unchanged and correct (single-parenthetical label is retained because the composer chip atstatic/index.html:441shows the model label WITHOUT optgroup header context —Deepseek V4 Flash (Ollama)is more useful there than ambiguousDeepseek V4 Flash). Verified empirically with a repro: pre-fix all 4 colliding groups collapsed to one@xiaomi:id with a 3-parenthetical label; post-fix each group gets its own correct@provider_id:prefix and exactly ONE parenthetical. 3 new regression tests intests/test_issue1511_dedup_shared_reference.py: structural invariant (test_groups_have_independent_model_lists), end-to-end against corrected path (test_unconfigured_providers_no_shared_dedup_bleed), broken-state evidence test (test_shared_reference_pre_fix_demonstrates_corruption). Co-authored-by trailer credits @lost9999 for the original bug report.
Notes
- 3925 → 3929 tests passing (+4 regression tests; +1 production-path guard added in-release per Opus SHOULD-FIX feedback).
- Pre-release Opus advisor pass: SHIP AS-IS. Verified all 5 group-build paths in
get_models_grouped()— only the unconfigured-fallback path at line 2078 had shared-reference corruption (OpenRouter / ollama-cloud /_PROVIDER_MODELS/ named-custom paths all already build independent dicts). - Closes contributor PR #1511 with credit + explanation. The contributor's symptom report was correct and motivated the fix; their proposed patch addressed a different layer than the actual root cause.
[v0.50.276] — 2026-05-03
Fixed (1 PR — closes #1507)
- Stale CSS after container update / in-place upgrade no longer recurs (#1508, self-built; closes #1507; reporter @vishnu via @AvidFuturist on Discord) — users with the WebUI tab still open across a version upgrade saw "broken styling" on their next visit, fixed by force-refresh, then broken again on a normal reload. Root cause: asset-version mismatch in the service-worker shell cache. Every JS file in
static/index.htmlalready carried?v=__WEBUI_VERSION__(server-substituted at request time), butstatic/style.cssdid not. After an upgrade, the old service worker stayed the active controller until the new one finished installing — itscaches.match(event.request)fetch handler matched the unversionedstatic/style.cssrequest exactly against its old shell-cache entry and returned old CSS, while the new versioned JS URLs (?v=v0.50.276) missed the old cache and got fetched fresh. New JS + old CSS = broken layout. Verified live on master before staging this fix: inspectingcaches.open('hermes-shell-v0.50.275')in DevTools showedstyle.csswas the only cached asset whose unversioned URL exactly matched the page request — every JS URL coincidentally dodged the bug because their?v=query made the cache lookup miss → network fetch → fresh JS. Fix: (1) instatic/index.html, the stylesheet<link>now carries?v=__WEBUI_VERSION__matching the JS pattern; (2) instatic/sw.js, every versioned shell-asset entry inSHELL_ASSETSis suffixed with+ VQwhereconst VQ = '?v=__CACHE_VERSION__'so the pre-cache URLs match what the page actually requests. Unversioned shell entries (./,manifest.json, favicons) intentionally stay unversioned because the page references them without a query. The server already substitutes__WEBUI_VERSION__on/index.htmland__CACHE_VERSION__on/sw.jsat request time (api/routes.py:1124and:1190) — both placeholders resolve to the samequote(WEBUI_VERSION, safe="")token, so the page's?v=v0.50.276and the SW's pre-cache?v=v0.50.276are byte-identical strings. 2 new regression tests intests/test_pwa_manifest_sw.pylock both sides of the contract:test_index_versions_stylesheet(versioned href present, unversioned form rejected) andtest_sw_shell_assets_match_versioned_asset_urls(every CSS/JS shell entry carries the cache-version query, accepting either inline?v=__CACHE_VERSION__or+ VQ). 1 updated test intests/test_sprint37.pymatches the css-link by href prefix to preserve the workspace-panel preload-marker ordering invariant under the new versioned URL.
Notes
- 3923 → 3925 tests passing (+2 new regression tests).
- Independent review by
nesquena(APPROVED): end-to-end trace of server-side substitution, SW cache-match semantics (no{ignoreSearch: true}is the load-bearing detail), behavioral harness covering 4 cache transitions (pre-fix HIT → post-fix MISS → steady-state HIT → next-upgrade MISS), edge-case table covering 7 scenarios, security audit clean (no XSS — version flows throughquote()). - Pre-release Opus advisor pass: SHIP AS-IS. Verified
_serve_staticignores query strings,Varyheader is not set on shell assets so cache-match is pure full-URL exact-string, no SRI / CSP / subpath-mount / reverse-proxy interactions. The fix is steady-state — every upgrade from v0.50.276 onward will be clean. - One-time migration cost for existing users on v0.50.275: the FIRST page load after upgrading to v0.50.276 may still show one round of broken styling, because the old service worker still serves the old index.html (which has the unversioned CSS link) on its first post-upgrade activation. After that load, the new SW downloads, installs, activates with
clients.claim(), deletes the old cache, and the next reload is clean. From v0.50.276 onward, future upgrades will not show the broken state because the SW pre-cache is now keyed on the versioned URL. We considered adding a server-pushed cache nuke to make the v0.50.275→v0.50.276 transition seamless but judged that excessive scope for a hotfix. - Closes #1507. Filed follow-up #1509 for low-priority consolidation of
__CACHE_VERSION__and__WEBUI_VERSION__placeholder names (currently aliases producing the same token; not a bug, just cleanup). - Credits: thanks to vishnu for the careful symptom report (the "spawn new container vs. existing tab" distinction was the diagnostic key), and to AvidFuturist for relaying it from Discord with enough detail to reproduce without a containerized repro environment.
[v0.50.275] — 2026-05-03
Fixed (1 PR — first-time contributor)
- Static assets served correctly under
/session/*routes (#1505, first-time contributor @rickchew) — when the browser navigates to/session/<id>, it requests stylesheets and scripts relative to that URL (e.g.GET /session/static/style.css). The existing/session/*catch-all inapi/routes.pyhandle_get()matched these requests first and returned the 114KB HTML index page withContent-Type: text/html, which strict-MIME browsers refuse to apply as a stylesheet (X-Content-Type-Options: nosniffis set). A clever inline<base href>injection instatic/index.html:17papered over the visible breakage on most browsers — but Chrome's preload scanner had already fired off all 12 wrong-URL requests (~1.4MB wasted bandwidth per session-URL navigation), and any strict-MIME / CSP / sandboxed-loader path failed outright. Verified live on master before merge:curl -si http://127.0.0.1:8787/session/static/style.cssreturned200 OK / Content-Type: text/html / 114563 bytes. Fix: add a guard inhandle_get()BEFORE the/session/catch-all that detects/session/static/*, strips the/sessionprefix, and delegates to_serve_static()(which carries its ownPath.resolve()+relative_to(static_root)traversal sandbox). Whitelist/session/static/*incheck_auth()to match the existing/static/*auth-exemption policy. Maintainer follow-ups absorbed in-release: dropped an unusedfrom urllib.parse import urlparse as _upimport the contributor accidentally left in their hunk, and added 5 regression tests intests/test_session_static_assets.pypinning (1)/session/static/style.cssreturnstext/css, (2)/session/static/ui.jsreturnsapplication/javascript, (3)/session/<id>(no/static/) still serves the HTML index, (4) path-traversal/session/static/../../etc/passwdstill 404s after the prefix strip, (5)/session/static/*matches/static/*auth policy while non-static/session/<id>still requires auth. Co-authored-by trailer preserves rickchew attribution.
Notes
- 3918 → 3923 tests passing (+5 regression tests for #1505).
- Pre-release Opus advisor pass: SHIP. Path-traversal sandbox holds for both literal
..(Path.resolve+relative_to) and URL-encoded%2e%2e(urlparse leaves percent-escapes literal, file doesn't exist → 404). Auth-exemption breadth is benign because_serve_static's sandbox 404s any escape attempt before bytes leak. - Closes #1505. No follow-up issues filed.
[v0.50.274] — 2026-05-03
Fixed (1 PR — three sub-bugs from #1420)
- LM Studio onboarding fully fixed: probe before persist + keyless setup + agent-aligned env var (#1501, self-built; reporters @chwps and @AdoneyGalvan; closes #1499 and #1500) — three LM Studio onboarding bugs that piled on top of each other in practice, fixed together because fixing only one left the broken UX. (1) #1499 (a) — Onboarding wizard probes
<base_url>/modelsbefore persisting. Pre-fix the wizard finished in 239ms with zero outbound HTTP, silently persisted unreachable URLs, and left users with empty model dropdowns. NewPOST /api/onboarding/probeendpoint validates the configured base URL with a 5s timeout and 256 KB body cap. 8 stable error codes (invalid_url,dns,connect_refused,timeout,http_4xx,http_5xx,parse,unreachable) each get a localized hint — theconnect_refusedmessage tells Docker users to try the host IP instead oflocalhost. Stdlib-only (urllib.request+socket, no httpx dep). Probe response is read-only — never persisted. SSRF-defense: probe refuses HTTP redirects (_NoRedirectHandler+_PROBE_OPENER), gated on local-network OR auth ORHERMES_WEBUI_ONBOARDING_OPEN=1. Frontend wires the probe debounced (400ms on baseUrl input) AND blocking (Continue refuses to advance until probeokforrequires_base_url=Trueproviders). Probe-discovered models populate the wizard's model dropdown. (2) #1499 (third sub-bug) — Keyless setup is a first-class state for self-hosted providers. Pre-fix the wizard rejected an empty api_key forlmstudio/ollama/custom, forcing keyless users to type random gibberish into a password field. Newkey_optional: Trueflag on those three providers —apply_onboarding_setupskips the "{env_var} is required" check, doesn't write a placeholder to.env, and_status_from_runtimereportsprovider_ready=Truebased onbase_urlalone. Cloud providers (openrouter / anthropic / openai / gemini / deepseek / …) remain key-required. Frontend renders the field as "API key (optional)" with placeholder "Leave blank for keyless servers" and an italic muted help paragraph: "Most LM Studio / Ollama / vLLM installs run keyless — leave this blank if your server doesn't require authentication. Use the Test connection button to verify." (3) #1500 — Webui env var aligned with the agent CLI's canonicalLM_API_KEY. Pre-fix the WebUI wroteLMSTUDIO_API_KEYto.env, but the agent CLI runtime (hermes_cli/auth.py:182,api_key_env_vars=("LM_API_KEY",)) readLM_API_KEY— auth-enabled LM Studio users got Settings reportinghas_key=Truebut agent runtime returning 401. Onboarding now writes the canonicalLM_API_KEY. LegacyLMSTUDIO_API_KEYpreserved as a read-only fallback in two new alias dicts (env_var_aliasesin_SUPPORTED_PROVIDER_SETUPS,_PROVIDER_ENV_VAR_ALIASESinapi/providers.py) so existing users don't see Settings flip to "no key" on upgrade. Alias mechanism is general — future env-var renames get the same gentle-migration path. Migration note for existing users on auth-enabled LM Studio: Settings will continue to reporthas_key=Trueafter upgrade via the legacy alias, but the agent runtime has always readLM_API_KEY— chat will keep failing the same 401 way until you rename the variable in~/.hermes/.envfromLMSTUDIO_API_KEY=...toLM_API_KEY=...(one-time step). 16 i18n keys × 9 locales (English canonical, others// TODO: translatemarkers per the v0.50.271 #1488 convention). Backed by 60+ regression tests across 4 files (38 new + 22 updated): probe error codes pinned via mutation-verified mock servers, keyless-vs-cloud schema flags pinned, env-var canonical+alias pinned, redirect-refusal pinned with mutation verification, end-to-end route smoke tests against the live test fixture. (api/onboarding.py,api/providers.py,api/routes.py,static/onboarding.js,static/i18n.js,static/style.css,tests/test_issue1499_onboarding_probe.py,tests/test_issue1499_keyless_onboarding.py,tests/test_issue1500_lmstudio_env_var_alignment.py,tests/test_issue1420_lmstudio_provider_env_var.py)
Notes
- 3879 → 3918 tests passing (+39: 17 probe + 16 keyless + 5 env-var + 1 redirect; the existing #1420 suite was updated for the canonical-name rename and remains 5 tests).
- Pre-release Opus advisor pass: ship-ready, no MUST-FIX. One non-blocking observation deferred as #1503 (API-key input can lose focus mid-typing if probe completes during a typing pause — 400ms debounce + full-form re-render race; UX papercut, not a release blocker, manual repro on localhost didn't catch it because localhost probes complete too fast for the bug window).
- Independent review by
nesquenaflagged 4 non-blocking items: redirect-refusal (addressed in-release as commitba6f344perreviewer-flagged-fix-in-release-not-followuppolicy — <20 LOC defensive fix, regression test mutation-verified); test count drift (cosmetic); legacy alias sunset path (filed as #1502 with target review ~Nov 2026); local-network gate code duplication between/api/onboarding/setupand/api/onboarding/probe(deferred — extract whenever someone touches both routes for an unrelated reason). - Closes #1499 (all three sub-bugs) and #1500. Follow-up issues filed: #1502 (alias sunset tracking), #1503 (probe re-render UX papercut).
[v0.50.273] — 2026-05-03
Fixed (1 PR)
- LM Studio shows in Settings → Providers when configured (#1498, partial fix for #1420; reporters @chwps and @AdoneyGalvan) — after running the onboarding wizard with LM Studio selected, users saw the provider in the model picker and could chat normally, but Settings → Providers showed no LM Studio entry or marked it as
has_key=False / configurable=Falseeven whenLMSTUDIO_API_KEYwas already in~/.hermes/.env. Root cause: the_PROVIDER_ENV_VARmap inapi/providers.pyis missing anlmstudio: "LMSTUDIO_API_KEY"entry. That dict drives both_provider_has_key()(env-var-based key detection — falls through tohas_key=False / key_source=nonewhen the provider id isn't there) andget_providers()line 364 (configurable = pid in _PROVIDER_ENV_VAR— falls through toFalse, hiding the "Add API key" UI surface). Same bug shape as #1410 (Ollama Cloud / local Ollama env-var collision). Fix: add the single mapping. Unlike #1410's collision concern,LMSTUDIO_API_KEYis not shared with any other provider's runtime, so adding the mapping has no side effects. Scope discipline: issue #1420's broader thread surfaces a sibling bug — the onboarding wizard never probes the configured<base_url>/v1/modelsendpoint before persisting (the wizard accepts unreachable URLs silently, with no model-list dropdown population). That sibling bug is filed separately as #1499 and is not addressed by this PR — adding a probe touches the wizard UX flow, has timeout / error-handling implications, and warrants its own design pass. 5 regression tests intests/test_issue1420_lmstudio_provider_env_var.pypin: dict literally contains the mapping, env-var path flipshas_key=True+configurable=True+key_sourcereflects env source, config.yamlproviders.lmstudio.api_keyfallback also flipshas_key=True, no-key path still rendersconfigurable=True(so the user has a UI surface to add a key), andLMSTUDIO_API_KEYdoesn't cross-detect any sibling provider. 4 of 5 tests verified to fail (catching the bug) when the new map entry is reverted. (api/providers.py,tests/test_issue1420_lmstudio_provider_env_var.py)
Notes
- 3874 → 3879 tests passing (+5 from the issue #1420 regression suite). 3884 collected (includes some
xfail/skipmarkers). - Independent review by
nesquenaflagged a pre-existing cross-tool env-var-name divergence: webui usesLMSTUDIO_API_KEY(the convention this PR aligns Settings detection with), while the agent CLI's runtime usesLM_API_KEY— masked in practice by the agent'sLMSTUDIO_NOAUTH_PLACEHOLDERfor keyless local installs. Filed as a follow-up issue (separate from #1499). Not a blocker for this PR — its scope is the UI-detection bug, and the divergence pre-dates the change. - Single-PR release lane (no stage branch); reviewer parked at approval, ready for the merge/tag pipeline.
[v0.50.272] — 2026-05-03
Fixed (3 PRs)
-
Sidebar "Stop response" cancels the row's stream, not the active pane's (#1493, by @dso2ng, closes #1466, follow-up to #1480) — second of the two verification scenarios from the #1466 thread: cancelling a running session from the sidebar context menu while viewing a different session. Pre-fix the cancel path read
S.activeStreamId(the active pane's stream id) instead of the row's ownactive_stream_id, so cancelling session A while viewing session B either no-op'd (B not running) or cancelled the wrong stream. The newcancelSessionStream(session)helper instatic/boot.js(1) hits/api/chat/cancel?stream_id=<row's id>with the row-owned stream id (URL built vianew URL(...)againstdocument.baseURIso subpath mounts work), (2) does universal cleanup on the row (session.active_stream_id=null, INFLIGHT delete, clearInflightState), and (3) does scoped cleanup gated on session-id match for active-pane sync (S.session.session_id===sid) and for clarify/approval cards (_clarifySessionId===sid/_approvalSessionId===sidwithtypeof !== 'undefined'guards for early page load). The sidebar context menu gains a "Stop response" entry positioned before delete, gated onsession.active_stream_idso idle rows don't show the action. Newstopicon (8×8 rounded square inside the standard 16×16 viewBox) plussession_stop_response/session_stop_response_desckeys in all 9 locales (// TODO: translatemarkers added on the 8 locales using English fallback). 3 regression tests intests/test_1466_sidebar_cancel_clarify.pypin: stop action only on running rows + usescancelSessionStream(session)(not the global), per-row stream id (notS.activeStreamId), per-session clarify/approval scoping. (static/boot.js,static/sessions.js,static/i18n.js,tests/test_1466_sidebar_cancel_clarify.py) -
state.dbconnection FD leak in sidebar polling (#1495, self-built; reported and fix-shape verified by @insecurejezza in #1494; closes #1494, addresses Bug #2 of #1458) — production WebUI on macOS launchd reproduced an HTTP-unhealthy wedge after #1483 fixed the bootstrap supervisor double-fork: process alive, port listening, every HTTP request reset by peer before a response. Investigation traced it to FD exhaustion from~/.hermes/state.dbhandles (366 total FDs, 238 of themstate.db/state.db-wal/state.db-shmon a wedged process). Root cause: four sqlite callsites usedwith sqlite3.connect(...) as conn:, but Python'ssqlite3.Connectioncontext manager only commits or rolls back on exit — it does not close the connection./api/sessionspolling calls two of these (read_importable_agent_session_rows,read_session_lineage_metadata) on every sidebar refresh, so each poll leaked one or more open state.db FDs until the process hit the macOS 256-FD soft limit, after which new connections RST'd before any handler bytes were written. Fix: wrap eachsqlite3.connect(...)call incontextlib.closing(...)at:api/agent_sessions.py:read_importable_agent_session_rows,api/agent_sessions.py:read_session_lineage_metadata,api/models.py:get_cli_session_messages,api/models.py:delete_cli_session. The reporter verified the fix in production (FD count flat at 92 across a 100-request stress loop against/api/sessionsand/api/projects, vs. monotonic growth pre-fix). 4 regression tests intests/test_issue1494_state_db_fd_leak.pymonkeypatchsqlite3.connectwith a_TrackingConnwrapper that records.close()calls and assert every connection opened by each function is explicitly closed — verified to fail (catching the original bug) with message "leaked N of N sqlite connection(s) — context-manager-onlywith sqlite3.connect()does not close. Wrap in contextlib.closing()." when theclosing()wrap is reverted. Scope discipline: Bug #3 from #1458 (HTTP-unhealthy wedge in the absence of FD exhaustion) remains open pending separate diagnostic data. Commit message usesRefs #1458 (Bug #2 of 3)rather thanCloses #1458so the umbrella stays open. (api/agent_sessions.py,api/models.py,tests/test_issue1494_state_db_fd_leak.py) -
P0 bugfixes bundle: tool-card args readability + CLI session rename persistence + scroll-pinning programmatic-vs-user disambiguation + sw.js relative-path regression test (#1492, by @bergeouss, closes #1469, #1484, #1486) — three concrete user-visible polish fixes plus a regression test added in response to review feedback. (1) Tool-card args (#1484,
static/style.css:1700-1701):.tool-arg-keynow usesdisplay:block;margin-bottom:2px;so each key starts on its own line;.tool-arg-valswapsword-break:break-allforwhite-space:pre-wrap;word-break:break-word;display:block;overflow-x:auto;so newlines and indentation in tool-call arguments are preserved and wrapping happens on word boundaries instead of mid-character — a real readability win for any tool that takes multi-line code. (2) CLI session rename persistence (#1486,api/models.py:1040-1052): after a CLI session is imported (creates<sid>.json) and renamed via/api/session/rename, the JSON file'stitlefield is updated, but the existing_project_agent_session_rows()merged the chain head's title from state.db on next refresh, silently overwriting the rename. The fix callsSession.load_metadata_only(sid)for each CLI row and prefers the WebUI JSON title when present. Covers the compression-then-rename repro from the issue. (3) Scroll-pinning programmatic-vs-user disambiguation (#1469,static/ui.js:1180-1196,1399-1410): new_programmaticScrollflag set true immediately beforeel.scrollTop=...inscrollIfPinned()/scrollToBottom(), cleared in nextsetTimeout(0)macrotask; the scroll-event listener bails on programmatic scrolls so they no longer re-pin against an explicit user scroll-up during streaming. (4) sw.js relative-path regression test (tests/test_pwa_manifest_sw.py:172-194, response to review feedback on the original 4-fix bundle): assertsstatic/index.htmlregisters the service worker via the relative'sw.js?v='form and explicitly does NOT contain the absolute'/sw.js?v='form, so future "absolute is cleaner" rewrites cannot silently break installs behind a reverse proxy at a subpath. The original PR's fourth fix (#1481, switching to absolute/sw.js) was a subpath-mount regression and was reverted in response to review; the regression test pins the correct shape. (static/style.css,api/models.py,static/ui.js,tests/test_pwa_manifest_sw.py)
Notes
- 3866 → 3874 tests passing (+8: #1493's 3 sidebar-cancel tests, #1495's 4 FD-leak tests, #1492's 1 sw.js relative-path regression test).
- Pre-release Opus advisor pass (initial 2-PR stage): ship-as-is, no MUST-FIX. Two non-blocking SHOULD-FIX deferred to follow-up: (1) #1493's stop-menu-after-natural-completion edge case where a freshly-arrived approval/clarify card on the same session could be wrongly hidden in a ≤5s window (mostly cosmetic); (2) #1495's
delete_cli_sessioncould switch to layeredwith closing(...) as conn, conn:to preserve auto-commit/rollback semantics for any future write callsites that forget explicitconn.commit(). - Two of three PRs independently approved by
nesquenabefore stage (#1493, #1495). PR #1492 went through a full review cycle and absorbed review feedback (sw.js absolute-path change reverted, regression test added) — verified maintainer-side that the contributor's response addresses all blocking points and matches master byte-for-byte onstatic/index.html. - This release closes Bug #2 of the umbrella issue #1458. Bug #1 was closed by v0.50.269 (#1483) + v0.50.270 (#1487). Bug #3 (HTTP-unhealthy without FD exhaustion) is the remaining work item.
[v0.50.271] — 2026-05-02
Changed (1 self-built PR)
- Composer voice buttons: distinct icon, distinct labels, opt-in voice mode (#1488, self-built, closes #1488) — the composer footer rendered two near-identical mic icons whose tooltips both said "Voice input": one was push-to-talk dictation (older feature), the other was turn-based hands-free voice mode (newer). After researching how ChatGPT, Claude, and Gemini handle the same problem, this PR adopts the industry convention: mic = dictation, audio-waveform = voice mode. (1) Voice-mode button now uses Lucide's
audio-linesglyph (six vertical bars of varying height — the universal "two-way voice conversation" icon, also registered inLI_PATHSfor reuse). (2) Distinct, localized tooltips:voice_dictate: 'Dictate'(withvoice_dictate_active: 'Stop dictation'flip-state) andvoice_mode_toggle: 'Voice mode'(withvoice_mode_toggle_active: 'Exit voice mode'flip-state). The legacyvoice_togglekey (which resolved to "Voice input" in every locale and caused the duplicate-tooltip bug) is removed. (3) Voice mode is now opt-in via Settings → Preferences → "Hands-free voice mode button" — default off keeps the composer uncluttered for the broad-majority case (plain dictation only). The dictation mic stays visible by default, unchanged. Toggle islocalStorage-backed (hermes-voice-mode-button), andpanels.js's onchange handler callswindow._applyVoiceModePref()so the audio-waveform button appears/disappears immediately with no reload. 17 new regression tests intests/test_issue1488_composer_voice_buttons.pypin: distinct static + i18n titles, audio-lines glyph shape (≥5 vertical-bar paths, no leftover mic-with-sparkles rect), all 4 new keys in all 9 locales, removal of stalevoice_toggle, English labels match ChatGPT/Gemini convention, pref gating (no unconditionaldisplay=''left in boot.js), Settings checkbox + i18n, panels.js wiring, and active-state tooltip flips. Browser-verified end-to-end on port 8789 (default 1 mic / pref-on 2 distinct icons / live re-apply via Settings). (static/index.html,static/icons.js,static/i18n.js,static/boot.js,static/panels.js,tests/test_issue1488_composer_voice_buttons.py)
[v0.50.270] — 2026-05-02
Fixed (1 contributor PR)
- Bootstrap validates the launcher Python can import the agent (#1315, by @ccqqlo) — companion fix to v0.50.269's #1478 (which addressed the supervisor crash loop) — this PR addresses a different production failure mode. Pre-fix,
ensure_python_has_webui_deps()only validatedimport yaml. If the discovered launcher Python hadyamlbut didn't haverun_agent.AIAgenton its import path (a real failure mode when the WebUI's local venv is found before the agent venv), the server would start and report/health200 OK, then 500 the first chat with a crypticAIAgent not availableerror. Fix: new_python_can_run_webui_and_agent(python_exe, agent_dir)helper subprocess-imports bothyamlandrun_agent.AIAgent. The function now prefers the agent venv when the launcher can't import AIAgent, falls back to the local venv withpip install -r requirements.txtonly if needed, and raises a clear RuntimeError pointing atHERMES_WEBUI_PYTHONif no interpreter on the system can do both. Plus 1 maintainer compatibility fix (widened 3lambda p: pstubs intests/test_bootstrap_foreground.pyfrom #1478 tolambda *a, **kw: a[0]because the new function signature has 2 positional args), 1 maintainer CI fix (sidestepvenv.EnvBuilder.create()in the fail-loud test by settingREPO_ROOTtotmp_pathwith a pre-existing fake.venv/bin/python— the prior stub only patchedsubprocess.runbutEnvBuilderinternally callssubprocess.check_output()), and 1 Opus advisor optional-followup (one-line comment atbootstrap.py:_python_can_run_webui_and_agentdocumenting why the PYTHONPATH prepend is load-bearing — it shadows stalerun_agentpackages in system site-packages). 2 regression tests intests/test_bootstrap_python_selection.pypin (a) prefer-agent-venv when launcher can't import AIAgent, (b) loud RuntimeError when no interpreter can do both. (bootstrap.py,tests/conftest.py,tests/test_bootstrap_foreground.py,tests/test_bootstrap_python_selection.py)
Notes
- Together with #1478 (v0.50.269), this completes the Bug #1 family of
bootstrap.pyfailure modes from issue #1458 — the supervisor-respawn loop AND the start-healthy-then-cryptic-fail mode are both now caught at boot time with clear errors. - #1458 Bugs #2 (state.db FD leak) and #3 (HTTP-unhealthy wedge) remain open awaiting diagnostic data.
- Maintainer-applied auto-rebase + auto-fix policy: 3 commits absorbed into the contributor's branch (rebase compatibility, CI fix, optional Opus follow-up). All preserve attribution via
Co-authored-by: ccqqlotrailers.
[v0.50.269] — 2026-05-02
Fixed (1 self-built + 2 contributor follow-ups)
-
bootstrap.py--foregroundmode for process supervisors (#1478, self-built, closes #1458 Bug #1) — thebootstrap.pydouble-fork pattern (subprocess.Popen([python, "server.py"], start_new_session=True)then exit 0) breaks every process supervisor. launchd / systemd / supervisord / runit / s6 see the parent exit, mark the program "completed," and respawn it — but the orphaned server still owns port 8787, so the new bootstrap fails to bind, exits non-zero, supervisor respawns again. Loop until something else crashes the orphan and the next respawn finds the port free. Reporter described this as "the agent fixes it eventually" — that's the loop intermittently succeeding. Fix: new--foregroundflag (and supervisor-environment auto-detection viaINVOCATION_ID/JOURNAL_STREAM/NOTIFY_SOCKET/SUPERVISOR_ENABLED/XPC_SERVICE_NAME/HERMES_WEBUI_FOREGROUND). In foreground mode, replace the bootstrap process image withserver.pyviaos.execvso the supervisor sees the long-lived server as the original child. KeepAlive / Restart=always now work correctly. Plus 1 Opus pre-merge MUST-FIX (_is_real_supervisor_value()helper rejects macOS Terminal's noise values likeXPC_SERVICE_NAME=0andapplication.com.apple.Terminal.<UUID>— without this, every Mac dev running interactive./start.shwould silently auto-promote to foreground mode, losing the /health probe and browser open) + 2 SHOULD-FIX (test env-var leakage cleanup, pre-execvos.access(python_exe, os.X_OK)guard so a non-executable launcher path raises a clear RuntimeError instead of OSError-then-respawn-loop). 44 regression tests + newdocs/supervisor.mdreference (runnable launchd plist + systemd.service+ supervisord conf + diagnosticlsof/ppidrecipe). Bugs #2 (state.db FD leak) and #3 (HTTP-unhealthy wedge) remain open under #1458 awaiting diagnostic data. (bootstrap.py,docs/supervisor.md,.gitignore,tests/test_bootstrap_foreground.py) -
/api/sessionspayload missingpending_user_message(#1479, by @Thanatos-Z) — surgical 6-LOC follow-up to v0.50.267 #1473. The frontend reload/sidebar recovery filter atsessions.js:1342-1349checks boths.active_stream_idANDs.pending_user_messageto keep mid-restore sessions visible, butSession.compact()(the dict serialized into the/api/sessionspayload) was missingpending_user_message. The filter only worked via theactive_stream_idclause. In practice not user-visible becauseactive_stream_idandpending_user_messageare set/cleared atomically together (verified atapi/routes.py:4232-4240), so any session with the latter also had the former. The fix prevents future drift if the atomicity invariant ever changes. (api/models.py,static/i18n.js,tests/test_issue856_session_streaming_state.py) -
bfcache
pageshowdoesn't restore active session (#1480, by @dso2ng) — when a browser restores the WebUI from bfcache (back/forward navigation), the frozen DOM is brought back without re-running boot. Sessions withactive_stream_idorpending_user_messageset looked stale in the active pane because the in-flight reattach logic (the v0.50.267 #1473 fix) only ran on fresh page loads. Fix: the pageshow handler nowawait loadSession(S.session.session_id)to refresh through the normal load path, thenawait checkInflightOnBoot(...)to reattach SSE. Tightened existing bfcache layout-restore tests via a shared_pageshow_handler()helper that walks the listener body via brace matching instead of the prior brittle[ps_idx:ps_idx + 1600]window. Newtests/test_1466_bfcache_inflight_reattach.py. (static/boot.js,tests/test_1045_bfcache_layout_restore.py,tests/test_1466_bfcache_inflight_reattach.py)
[v0.50.268] — 2026-05-02
Fixed (contributor PR batch — 4 PRs)
- Sync URL after session id rotation (#1395, by @dso2ng) — adds calls to
_setActiveSessionUrl(...)at two points instatic/messages.jswhere a session_id rotation can land (stream completion + settled session restore), so the tab URL andlocalStorage['hermes-webui-session']track the rotated id. Production-safe viatypeof _setActiveSessionUrl === 'function'guard. Follow-up to #1392 which shipped in v0.50.254. - Nest delegated child sessions under collapsed lineage roots (#1450, by @dso2ng) — when a delegated child session's parent was a hidden compression segment inside a collapsed lineage, the child fell through as a standalone
Cli Sessionrow with the wrong indentation. Now_attachChildSessionsToSidebarRows()looks up the visible collapsed lineage root and attaches child sessions there, preserving the compact lineage row while still showing children under it. (api/agent_sessions.py,api/models.py,static/sessions.js,static/style.css,tests/test_session_lineage_collapse.py,tests/test_session_lineage_metadata_api.py) /api/session/duplicateendpoint (#1462, by @AlexeyDsov) — new server-side endpoint creates an independent session copy with all messages, model, workspace, and per-session settings intact. Replaces the prior client-sidenew + renamedance which was non-atomic and could leave half-baked "(copy)" sessions if the rename call failed. Plus 5 maintainer review-feedback fixes applied directly to the contributor's branch (copy.deepcopy()for messages and tool_calls so duplicates are actually independent, explicit.save()so duplicates persist immediately,pinned/archived=Falseso duplicates of archived sessions are visible, status=404 for missing session, removed redundant local imports). Plus 3 Opus advisor SHOULD-FIX follow-ups: carrypersonality/enabled_toolsets/context_length/threshold_tokensso per-session customizations transfer; guard(session.title or "Untitled") + " (copy)"so legacy sessions withtitle=nulldon'tTypeError. (api/routes.py,static/sessions.js,tests/test_session_duplicate.py,tests/test_stage268_opus_followups.py)- Android PWA app installation (#1476, by @galvani) — adds 192px and 512px PNG icons (one with
purpose: "any maskable"for adaptive icons), updatesstatic/manifest.json, switchesapple-touch-iconto PNG for iOS compatibility, and whitelists/manifest.json+/manifest.webmanifestinapi/auth.pyPUBLIC_PATHSso the install prompt works regardless of auth state. (api/auth.py,static/apple-touch-icon.png,static/favicon-192.png,static/favicon-512.png,static/favicon-512.svg,static/index.html,static/manifest.json)
Fixed (Opus pre-release follow-up: i18n)
- Child-count UI was hardcoded English (#1450 follow-up) — the sidebar child-count badge and meta-line both rendered
${childCount} child${childCount===1?'':'ren'}as a literal English string, breaking 8 of the 9 supported locales. Addedsession_meta_childrenarrow-function key to all 10 locale blocks (en,ja,ru,es,de,zh,zh-Hantx2,pt,ko) using locale-appropriate phrasing, and replaced both callsites instatic/sessions.jswitht('session_meta_children', childCount). 6 regression tests intests/test_stage268_opus_followups.pypin the i18n key presence + the absence of hardcoded strings.
Maintainer-applied auto-rebase + auto-fix
This release is the first under the May 2 2026 auto-rebase + auto-fix policy: contributor PRs that are otherwise merge-ready but have mechanical blockers (CONFLICTING with master, small review nits) get rebased + fixed by maintainer + force-pushed back to the contributor's branch, rather than waiting for the contributor to round-trip. Two PRs in this batch followed that path:
- #1462 — 5 review-feedback fixes applied directly (deepcopy independence, persist on duplicate, reset pinned/archived, 404 status, import cleanup).
Co-authored-by: Alexey Dsovtrailer preserves attribution. - #1353 (NOT in this release — deferred to v0.50.269 due to scale + durability path requiring independent review) — rebased onto master, resolved 7 conflicts across 2 files, skipped 2 commits per the contributor's own commit message intent, force-pushed back. Now MERGEABLE for the next batch.
[v0.50.267] — 2026-05-02
Fixed (contributor PR batch — 7 PRs)
_norm_model_idstrips multi-segment provider prefixes (#1454, by @happy5318) —s.split(':', 1)[1]only stripped the first colon-separated segment, leavingjingdong:GLM-5un-normalized for@custom:jingdong:GLM-5-style IDs. Now usess.split(':')[-1](with a trailing-empty fallback to preserve distinct ids on malformed input). Same fix applied to the/branch. (api/config.py)- Frontend
_normalizeConfiguredModelKeymatches backend (#1474, by @happy5318) — the JavaScript helper had the same one-segment-only bug as the Python helper. Mirror fix + trailing-empty fallback. Plus surface the configured-model provider name in the model dropdown badge (e.g. "Primary (jingdong)"). (static/ui.js) pushStateinstead ofreplaceStatefor chat navigation (#1461, by @JKJameson) — switching between chats wrote to the same browser-history entry, so the back button could not return to a prior chat. Now each chat-switch creates a new history entry. One-line change. (static/sessions.js)- Session rename: ondblclick handler + loading guard (#1465, by @AlexeyDsov) — adds a native
ondblclickhandler as a fallback to the existing manual click-counter (which can miss double-taps when the click-delay racing setTimeout fires between pointerups), plus a guard preventing rename while the session is still loading. (static/sessions.js) - Reuse in-flight session stream on switch-back (#1467, by @dso2ng) —
attachLiveStream()now reuses the existing EventSource transport when (sessionId, streamId) match and the browser hasn't marked it CLOSED, instead of always tearing down and reopening. The server-side stream queue is not a replay log, so the close-and-reopen window dropped events that landed during the gap. 4 regression tests pin the invariants. (static/messages.js,tests/test_inflight_stream_reuse.py) - Handle 401 redirect gracefully in loadSession flow (#1460, by @joaompfp) — when
api()redirects to/loginafter the auth session expires (e.g. server restart), it returnsundefined. Five callsites inloadSession/_ensureMessagesLoaded/_loadOlderMessages/_ensureAllMessagesLoaded/_positionModelDropdownnow defensively check for undefined data and bail without state mutation. (static/sessions.js,static/ui.js) - Batch session actions + in-flight reload recovery (#1473, by @Thanatos-Z) — fixed three regressions: (1) batch action bar rendered as an empty/global bottom bar with literal
{0}placeholders because i18n placeholder substitution only ran for arrow-function values —t()now substitutes{N}placeholders at runtime for non-function values when args are passed; (2) batch project-picker dropped ontodocument.bodyorphaned itself on list re-render — now scoped to the action bar; (3) sessions withactive_stream_idorpending_user_messageset butmessage_count=0(mid-restore from in-flight reload) were filtered out of the sidebar — filter widened. 6 regression tests. (static/boot.js,static/i18n.js,static/sessions.js,static/style.css,tests/test_session_batch_select.py)
Defensive hardening (Opus pre-release follow-up)
_norm_model_idtrailing-empty fallback — Opus advisor flagged aSHOULD-FIXedge case in #1454/#1474: malformed configured-model IDs ending in a colon or slash (@custom:foo:bar:orprovider/model/) wouldsplit('...')[-1]to an empty string, collapsing distinct IDs to the same key in the configured-model badge filter. Both backend (api/config.py:1513) and frontend (static/ui.js:524) helpers now fall back to the original input when the last segment is empty (parts[-1] or s/last || s). 5 regression tests pin the guard, the clean multi-segment fix, and the frontend mirror. (api/config.py,static/ui.js,tests/test_norm_model_id_trailing_empty_guard.py)
[v0.50.266] — 2026-05-02
Fixed (i18n parity)
- Server-side
_LOGIN_LOCALEmissing ja/pt/ko (#1442) — the password/login page is rendered server-side BEFORE the JS i18n bundle loads, so its strings come from_LOGIN_LOCALEinapi/routes.py, notstatic/i18n.js. The dict only contained 6 entries (en/es/de/ru/zh/zh-Hant), so users withlanguage=ja|pt|koset saw the English login page even after their UI language preference was saved. v0.50.264 added Japanese as the 8th built-in locale, making the gap newly visible. Fix: addedja,pt,koentries with the same 7 sub-keys (lang/title/subtitle/placeholder/btn/invalid_pw/conn_failed) that the existing locales carry, mirroring the correspondinglogin_*strings fromstatic/i18n.js. 20 regression tests intests/test_login_locale_parity.pypin two invariants: every locale registered inLOCALES(i18n.js) must have a matching_LOGIN_LOCALEentry, and every locale's user-facing login-flow keys (13 of them) must NOT equal the English value. Adding a new locale toi18n.jswithout updatingroutes.pynow trips a test. (api/routes.py,tests/test_login_locale_parity.py) - English-leaking login-flow keys in i18n.js (#1442 audit) — while auditing the login-flow surface, found 13 keys still in English across
ko(10:login_placeholder,login_btn,login_invalid_pw,login_conn_failed,sign_out_failed,password_placeholder,settings_saved_pw,settings_saved_pw_updated,auth_disabled,disable_auth_confirm_title),es(3:sign_out_failed,auth_disabled,disable_auth_confirm_title), andpt(3 missing entirely:sign_out_failed,auth_disabled,disable_auth_confirm_title). All 13 now use natural translations matching the existing locale's terminology. The wider English-leak gap across non-login translation entries is a much larger problem requiring native-speaker review and is tracked separately. (static/i18n.js)
Fixed (Safari IME composition — broader coverage)
_isImeEnterhelper not used in 6 other Safari-affected Enter guards (#1443) — PR #1441 (v0.50.264) widened the chat composer (#msg) Enter guard frome.isComposingto a 3-guard_isImeEnter(e)helper that combinese.isComposing || e.keyCode === 229 || _imeComposingfor Safari's race where the committing keydown fires AFTERcompositionendwithisComposing=false. Six other Enter-input handlers were left on the original narrow guard: session rename, project create, project rename, app dialog (confirm/prompt), message edit, and workspace rename. Japanese/Chinese/Korean users on Safari composing into any of those would still get their IME-confirming Enter committed prematurely. Fix: exposed_isImeEnteraswindow._isImeEnterfromstatic/boot.js, then replacede.isComposingwithwindow._isImeEnter && window._isImeEnter(e)at all 6 sites. The state-free part of the helper (isComposing || keyCode === 229) handles Safari's race for any focused input without needing per-input composition listeners or a per-input_imeComposingflag. The defensive&& window._isImeEntershort-circuits if the helper isn't loaded yet (boot.js loads after sessions.js/ui.js withdefer, but the keydown handlers fire on user interaction which happens after all scripts execute). 9 regression tests intests/test_issue1443_ime_helper_promotion.pypin each of the 6 sites + verifye.isComposingEnter-guards no longer remain insessions.js/ui.js. The existingtests/test_ime_composition.pyalternation regex was extended to accept the windowed form alongsidee.isComposingand bare_isImeEnter(e)— codifies the v0.50.264 reflection note about loosening pattern-shape tests when changing the shape of a guarded check. (static/boot.js,static/sessions.js,static/ui.js,tests/test_ime_composition.py,tests/test_issue1443_ime_helper_promotion.py)
Fixed (assistant-output readability)
- Glued-bold-heading lift in renderMd (#1446) — LLMs in thinking/reasoning mode frequently emit "section headers" glued to the end of the previous paragraph with no whitespace:
Para 1 text.**Heading to Para 2**\n\nPara 2 text.**Heading to Para 3**. CommonMark renders that correctly as paragraph-end inline<strong>, but visually it looks like trailing emphasis on the body text rather than a section break. Reported by Cygnus (Discord, May 1 2026, "Markdown feedback 2 of 3", relayed by @AvidFuturist). Fix: added a single regex pre-pass inrenderMd()that lifts the glued bold into its own paragraph:s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g, '$1\n\n**$2**\n\n'). Constraints chosen to avoid false positives: trigger only on[.!?]IMMEDIATELY before**(no space — almost always an LLM-glued heading, not intentional emphasis); inner text ≤80 chars; no*or newline in the inner text (single-line bold only); trailing\n\nrequired (preservesthis is **important** to know.mid-paragraph emphasis untouched). Position: betweenrawPreStashrestore andfence_stashrestore, so fenced code blocks (still\x00P/\x00Fplaceholders at lift-time) are protected. Mirrored intests/test_sprint16.pyrender_md()so the Python mirror stays in sync with the JS. 17 regression tests intests/test_issue1446_glued_heading_lift.pycover all 3 trigger forms (.!?), 5 preserve-emphasis cases, chain rendering, source-level position pin, regex shape pin, and 5 node-driver tests against the actualstatic/ui.jsfor fenced/inline code protection. (static/ui.js,tests/test_sprint16.py,tests/test_issue1446_glued_heading_lift.py) - Markdown headings visually indistinguishable from body text (#1447) — pre-fix
.msg-bodyheading sizes were 18/16/14/13/12/11px against a 14px body, making h3 the same size as body and h4–h6 actually SMALLER than body. Reported by Cygnus (Discord, May 1 2026, "Markdown feedback 3 of 3", relayed by @AvidFuturist): "Headings seem to be missing across the board in Hermes. They're there, but all plaintext. They get lost so easily in all the plaintext." Fix: new sizes 24/20/17/15/14/13px withfont-weight:700(was 600),color:var(--strong, var(--text)), andline-height:1.3(vs body's 1.75 for tighter heading rhythm); h1 and h2 carry aborder-bottom:1px solid var(--border)for "section title" affordance (mirrors GitHub/Notion convention); h5 and h6 usetext-transform:uppercase+letter-spacing:0.04emfor "label-style" affordance instead of being smaller-than-body. Addedmargin-top:0for the first heading of a message so opening with a heading doesn't push down with extra top margin. Companion fixes: synced.preview-md h1-h6to match.msg-bodyexactly (file preview pane previously had only h1-h3 rules at 18/15/13px); updateddata-font-size="small"anddata-font-size="large"h1-h6 overrides to scale proportionally with the new defaults so the hierarchy is preserved at all three font-size settings. 9 regression tests intests/test_issue1447_heading_hierarchy.pypin the size hierarchy, the bottom borders on h1/h2, the uppercase affordance on h5/h6, the.preview-mdsync, and the small/large override scaling. (static/style.css,tests/test_issue1447_heading_hierarchy.py)
[v0.50.265] — 2026-05-02
Added
- Opt-in WebUI extension hooks (#1445) — adds a deliberately-small, self-hosted extension surface for administrators who want to inject local CSS/JS into the WebUI shell without forking the core repo. Disabled by default; activates only when
HERMES_WEBUI_EXTENSION_DIRpoints to an existing directory. Three env vars expose the surface:HERMES_WEBUI_EXTENSION_DIR(filesystem root for served assets),HERMES_WEBUI_EXTENSION_SCRIPT_URLS(comma-separated same-origin script URLs to inject before</body>),HERMES_WEBUI_EXTENSION_STYLESHEET_URLS(same-origin stylesheet URLs to inject before</head>). New/extensions/...static route is auth-gated (NOT inPUBLIC_PATHS, unlike/static/...) so administrator-supplied code only runs for authenticated sessions. URL validation rejects external schemes, protocol-relative URLs, fragments, traversal (raw + percent-encoded + double-encoded), control characters, quotes, and angle brackets. Filesystem serving sandboxes paths under the configured root viaPath.resolve()+relative_to(), rejects dotfiles, dot-directories, encoded backslashes, and symlink escapes. CSP unchanged — extensions live at same origin so existing'self'directive covers them. 7 regression tests intests/test_extension_hooks.pypin the disabled-by-default contract, URL validation against external/protocol-relative/javascript:/data:/API/encoded-traversal, HTML escaping during injection, the auth-gate vs public-static distinction, sandboxed static serving, fail-closed when disabled or unreadable, and symlink-escape rejection. Documentation indocs/EXTENSIONS.md(204 lines) covers extension authoring guidance for SPA-style additions, including avoiding destructive DOM mutations like replacingmain.innerHTML. Trust model: extensions are intentionally administrator-controlled — JS injected this way runs in the WebUI origin and can call any authenticated API the logged-in browser session can. The PR explicitly does NOT introduce remote extension loading, a plugin marketplace, Python plugin execution, manifests, a browser-facing config endpoint, or new dependencies. (api/extensions.py,api/routes.py,docs/EXTENSIONS.md,tests/test_extension_hooks.py,README.md) @ryansombraio — PR #1445
Fixed (Opus pre-release advisor)
_fully_unquote_pathiteration cap raised from 3 to 10 — Opus advisor noted that quadruple-encoded..(%2525252e%2525252e) collapsed to%2e%2eafter 3 iterations and slipped through the URL-injection validator. Not exploitable in practice (downstreamPathdoesn't decode%2eeither, so the literal directory%2e%2ewon't exist) but the validator's documented contract is "URLs must point to/extensions/or/static/," and a malformed URL that's neither cleanly that nor cleanly rejected violates the contract. Iteration cap is now 10 (URL strings stabilize in <5 iterations in practice; the cap is defensive). (api/extensions.py)- Trust-model callout at top of
docs/EXTENSIONS.md— moved the strongest trust-model warning ("extensions execute with full WebUI session authority") from the middle of the doc to a blockquote callout at the top, right after the lead paragraph. A casual operator skimming for "should I enable this?" now sees the hard truth before the friendly intro. Also adds explicit "do not pointHERMES_WEBUI_EXTENSION_DIRat a user-writable directory" guidance. (docs/EXTENSIONS.md) - URL list cap (32 entries) + reject-URL logging — caps configured URL lists at 32 entries to avoid pathological page rendering when a misconfigured env var ships thousands of URLs. Also logs a one-shot warning per process for each rejected URL (e.g. when an admin typos
https://...and the validator drops it as external) so the silent-failure mode of "extension just doesn't load" produces a log signal an admin can find. (api/extensions.py) - MIME map expansion — adds
ttf(font/ttf),otf(font/otf), andwasm(application/wasm) to the served-MIME table..wasmspecifically would fail to instantiate in Chrome served astext/plain; the others are ergonomic for older font formats. (api/extensions.py) - 5 regression tests in
tests/test_pr1445_opus_followups.pypin the new invariants: quadruple-encoded..collapses correctly, the same URL is now rejected by the validator, URL list caps at the configured max with a warning log, rejected URLs log exactly once per process, and the expanded MIME map serves.ttf/.otf/.wasmwith the correct Content-Type without charset suffixes for binary types. (tests/test_pr1445_opus_followups.py)
[v0.50.264] — 2026-05-02
Added
- Japanese (
ja) locale (#1439) — addsjaas the 8th built-in UI locale, slotted betweenenandruinstatic/i18n.js. 825 keys translated to natural, concise Japanese (kanji + hiragana + katakana mix; technical terms in their commonly-used Japanese form:Cronジョブ,MCPサーバー,APIキー,トークン). Translation style prefers terse 体言止め over polite forms (保存,キャンセル,削除) to match the brevity of the English originals. All${var}and{0}-style placeholders preserved verbatim, all 26 arrow-function values mirrored with parameter names intact. Settings → Language now lists 日本語; the existingObject.entries(LOCALES)discovery path picks it up automatically. The fallback chain (_locale[key] ?? LOCALES.en[key]) means any future English-only string still renders cleanly. 8 regression tests intests/test_japanese_locale.pypin block existence, representative translations, full key-set parity with English (zero missing, zero extra), the 8 known en-duplicates mirrored exactly, placeholder preservation, arrow-function value mirroring, and_label: '日本語'using actual Japanese script. (static/i18n.js,tests/test_japanese_locale.py) @snuffxxx — PR #1439
Fixed (Opus pre-release advisor)
- IME composition flag could get stuck if compositionend never fires — Opus advisor caught a recoverable footgun in PR #1441's manual
_imeComposingflag: if the user loses focus mid-composition (window blur / IME implementation quirk on older Safari WebKit),compositionendmay never fire, leaving_imeComposing=trueuntil the next composition starts AND ends. Result: Enter-to-send is silently broken until page reload. Added ablurlistener on#msgthat also resets the flag — cheap belt-and-suspenders against the unrecoverable stuck state. (static/boot.js,tests/test_pr1441_ime_safari_guard.py)
Fixed
-
IME composition Enter sent message prematurely on Safari (#1441) — the
#msgkeydown handler had ane.isComposingguard that swallows IME-confirming Enter on Chrome and Firefox (where the committing keydown fires beforecompositionend), but failed on Safari (where the committing keydown fires AFTERcompositionendwithisComposing=false). Result: Japanese/Chinese/Korean users on macOS Safari + Hermes had to copy/paste from another app because every IME-confirming Enter sent the message instead of just accepting the conversion. Fix: widened guard frome.isComposingto a_isImeEnter(e)helper that also checkse.keyCode === 229(IME virtual key on broader browser/IME combos) AND a manual_imeComposingflag set oncompositionstartand reset in asetTimeout(…, 0)aftercompositionend(so the trailing keydown still sees_imeComposing=true). Helper is used in both the autocomplete-dropdown Enter path and the send-Enter path. The composition-listener IIFE null-guards$('msg')so login/onboarding pages without a composer don't throw. No behavior change for non-IME users — all three guards return falsy for normal Enter. 6 regression tests intests/test_pr1441_ime_safari_guard.pypin: helper definition + all 3 guards, compositionstart sets the flag, compositionend defers reset to next tick, blur resets to recover from missed compositionend (Opus follow-up), IIFE null-guards$('msg'), both Enter paths use the helper. Existingtest_ime_composition.py::test_boot_chat_enter_send_respects_ime_compositionwas loosened to accept eithere.isComposingOR_isImeEnter(e). (static/boot.js,tests/test_ime_composition.py,tests/test_pr1441_ime_safari_guard.py) @ryan-remeo — PR #1441 -
Markdown renderer: triple backticks mid-line corrupted downstream rendering (#1438) — The fence regex
/```([\s\S]*?)```/ghad no line anchoring. A literal triple backtick appearing inside a code block's content (e.g. a regex pattern with ``` in a lookbehind, a script that documents fences, embedded markdown-in-markdown) terminated the outer fence at the wrong place. The leaked tail then went through bold/italic/inline-code passes, eating*characters as italic markers and producing literal</strong>tags in the rendered output. Reported by Cygnus (Discord, May 1 2026), relayed by @AvidFuturist.Fix: anchor all 3 fence regexes per CommonMark §4.5 — opening fence must start a line (with up to 3 spaces of indent), closing fence must also start a line. Pattern:
(^|\n)[ ]{0,3}\``(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$). The(?:...\n)?` group keeps empty fences (```\n```) working. Patched sites:static/ui.js:1559—renderMd()fenced-block stash (the assistant-message renderer)static/ui.js:66—_renderUserFencedBlocks()(user-message renderer)static/ui.js:2599—_stripForTTS()(TTS speech pre-strip)
Plus the Python mirror in
tests/test_sprint16.py. Triple backticks in the middle of a line are now treated as literal text (CommonMark-conformant) and no longer break out of code blocks. 20 regression tests intests/test_issue1438_fence_anchoring.pycover Cygnus's exact repro, inline```in paragraphs, partial/streaming fences, empty fences, indented fences (3-space ✓, 4-space ✗), language tags, two adjacent blocks, and source-level guards on all 3 patched sites.
[v0.50.263] — 2026-05-02
Fixed
- Context-window indicator broken on older sessions ("100" / "890% used") (#1436, fixes #1436) —
#1356(closed Apr 30) fixed the same symptom on the live SSE path but didn't cover the GET /api/session load path, so any session that pre-dates#1318(whencontext_lengthwas added toSession) returnedcontext_length=0from/api/session. Combined with two cascading frontend fallbacks (promptTok = last_prompt_tokens || input_tokens,ctxWindow = context_length || 128*1024), the ring rendered "100" capped from 800-4000% and the tooltip showed "890% used (context exceeded), 1.2M / 131.1k tokens used" — a misleading prompt to compress that the user couldn't address. Empirically: 23 of 75 sessions on the dev server were broken before this fix. Two-layer fix: (1) backendapi/routes.pynow resolvescontext_lengthviaagent.model_metadata.get_model_context_length()when the persisted value is 0, mirroring the SSE-path fallback inapi/streaming.py:2333-2342. (2) frontendstatic/ui.js:1269no longer falls back to cumulativeinput_tokenswhenlast_prompt_tokensis missing — that fallback divides cumulative input by the context window, producing nonsense percentages. Older sessions without last-prompt data now render "·" + "tokens used" (honest no-data) on the ring instead of a misleading >100% percentage. 10 regression tests intests/test_issue1436_context_indicator_load_path.pypin: persisted-value pass-through, zero-value fallback, fallback-receives-correct-model, empty-model-skips-fallback (avoids 256K default-for-unknown trap), exception-swallowed-on-import-failure, frontend-no-input_tokens-fallback, frontend-uses-last_prompt_tokens-only, no-data-branch-renders-dot, load-path-imports-the-helper, fix-comment-references-issue-number. Reported by @AvidFuturist. (api/routes.py,static/ui.js,tests/test_issue1436_context_indicator_load_path.py)
[v0.50.262] — 2026-05-02
Fixed
- New-chat button (
+) and Cmd/Ctrl+K were no-ops while the first message was streaming (#1432, closes #1432) — the empty-session guard from #1171 (message_count===0→ focus composer instead of creating a new session) didn't account for in-flight streams, where the user's message hasn't been merged intos.messagesserver-side yet. Clicking+during the first response of a brand-new session was silently dropped, so users couldn't actually start a parallel conversation. The guard now also requires!S.busy && !S.session.active_stream_id && !S.session.pending_user_message— the same in-flight signal already used by_restoreSettledSession()inmessages.js:1081. Reported by @Olyno. (static/boot.js) - Profile-name field auto-capitalized typed values despite the "lowercase only" hint (#1423, closes #1423) — the input had
autocomplete="off"but was missingautocapitalize="none",autocorrect="off", andspellcheck="false", so mobile keyboards (iOS Safari/WKWebView, Android Chrome) silently capitalized the first letter and desktop spellcheck could rewrite the value on blur. The form lowercases on submit, so stored data was always correct — the bug was a misleading display during typing. Same attributes added to the Base URL field for the same reason (URLs are not natural-language text). The API key field istype="password"and already has correct browser behavior. (static/panels.js)
[v0.50.261] — 2026-05-02
Changed
- Composer footer: session-toolsets chip is now responsive — the per-session toolsets restriction chip (introduced in #493) was crowding the composer footer on standard widths once it shared space with model, reasoning, profile, workspace, context-ring, and send. The PR #1433 fix hid it unconditionally via JS; this release replaces that with a responsive CSS rule so the chip is visible only when the composer-footer container is at least 1100px wide (i.e. wide desktops with the workspace panel closed). At narrower widths the chip is hidden by the base CSS rule, and the existing
@container composer-footer (max-width: 520px)and@media (max-width: 640px)rules continue to enforce hidden on tablets and phones. JS no longer setsdisplay:nonedirectly — visibility is controlled entirely by CSS so the responsive cascade is the single source of truth. The underlying state and/api/session/toolsetsendpoint continue to work for cron and scripted callers regardless of UI visibility. Inlinestyle="display:none"removed fromindex.htmlso the CSS base rule is the only source of the default-hidden state. Refs #1431, #1433. @nesquena-hermes (static/ui.js,static/style.css,static/index.html)
Fixed (Opus pre-release advisor)
- Toolsets dropdown stays open after resize crosses 1100px threshold — Opus advisor caught a latent bug promoted by the new responsive cascade. The
composerToolsetsDropdownis a DOM sibling ofcomposerToolsetsWrap, not a child, so CSS hiding the wrap does NOT cascade-hide an open dropdown. If a user opened the dropdown at composer-footer ≥ 1100px and then opened the workspace panel (or resized the window), the dropdown would stay open without a visible anchor and the resize handler would re-anchor it to the footer's left edge with no chip in sight. The bug existed pre-stage-261 at the 520/640 thresholds but those fire rarely; the new 1100px threshold is reachable with a single workspace-panel toggle. Three fixes: (1) resize listener now closes the dropdown (instead of repositioning it) whenchip.offsetParent === null. (2)_positionToolsetsDropdown()now early-returns + closes when chip is hidden — defense-in-depth. (3)toggleToolsetsDropdown()early-returns when chip is hidden — currently latent (only the chip's own onclick invokes it) but defensive against future #1431 redesign code. (static/ui.js) display:flex→display:blockon the wrap — Opus advisor noted that sibling wraps (.composer-profile-wrap,.composer-model-wrap,.composer-reasoning-wrap) all use the natural block display, whiledisplay:flexwould blockify the chip'sinline-flexlayout. Changed for consistency. (static/style.css)- 13 regression tests in
tests/test_issue1431_toolsets_chip_responsive.pypin: the base hide rule, the wide-container reveal rule (block or flex), the narrow-container hide rule (520px container), the mobile viewport hide rule (640px @media), the JS-doesn't-force-display-none invariant, the JS-clears-inline-style invariant, the state-tracking-still-works invariant, the no-inline-display-none-in-html invariant, the /api/session/toolsets endpoint preservation, the dropdown-machinery preservation (toggleToolsetsDropdown,_populateToolsetsDropdown), AND the three Opus-found resize-guard invariants (resize handler closes dropdown when chip hidden,_positionToolsetsDropdowndefense-in-depth,toggleToolsetsDropdowndefense-in-depth). (tests/test_issue1431_toolsets_chip_responsive.py)
[v0.50.260] — 2026-05-01
Fixed
- Docker compose UID/GID alignment (#1428, fixes #1399) — the two- and three-container compose files had a UID mismatch between containers sharing the
hermes-homevolume:hermes-agentandhermes-dashboardran as UID 10000 (image default) whilehermes-webuiran as UID 1000 (WANTED_UIDdefault), causingPermission deniederrors on every shared file. All services now read from${UID:-1000}and${GID:-1000}so they align by construction. Empirically tested on both two- and three-container setups by the contributor. (docker-compose.two-container.yml,docker-compose.three-container.yml) @sunnysktsang — PR #1428
Changed
- Docker UX overhaul — Docker reliability has been a recurring pain point. This release ships a coordinated set of doc/config improvements:
- All 3 compose files now document the
HERMES_SKIP_CHMODandHERMES_HOME_MODEescape hatches inline (the v0.50.254 fix for #1389 wasn't surfaced for Docker users). - New
.env.docker.exampletemplate specifically for Docker users, covering UID/GID, paths, password, and permission-handling escape hatches with explicitUID=1000/GID=1000placeholders so macOS users don't skim past the warning. - New
docs/docker.md— comprehensive guide covering all 3 compose files, common failure modes (with one-line fixes), bind-mount migration recipe, multi-container architecture diagram, macOS Docker Desktop file-sharing implementation note, and pointer to the community all-in-one image for Podman 3.4 / multi-arch users. - README Docker section rewritten — clearer 5-minute quickstart pointing at the single-container setup; failure-mode table with one-line fixes; pointer to
docs/docker.mdfor the deep dive; stale/root/.hermesreference removed (the agent images use/home/hermes/.hermes). - 12 regression tests in
tests/test_v050260_docker_invariants.py— UID/GID alignment positive + negative-pattern guards, escape-hatch documentation,.env.docker.exampleshape,docs/docker.mdfailure-mode coverage, README link integrity, and YAML validity for all 3 compose files. (docker-compose.yml,docker-compose.two-container.yml,docker-compose.three-container.yml,.env.docker.example,docs/docker.md,README.md,tests/test_v050260_docker_invariants.py)
- All 3 compose files now document the
Changed (Opus pre-release advisor)
HERMES_HOME_MODEsemantic asymmetry warning — Opus advisor caught a footgun in my initial draft:HERMES_HOME_MODEmeans different things in the WebUI vs. the agent image. WebUI'sHERMES_HOME_MODEis a credential-FILE mode threshold (e.g.0640allows group bits on.env), but the agent'sHERMES_HOME_MODEis the HERMES_HOME directory mode (default0700).0640on a directory has no owner-execute bit, so the agent can't traverse its own home directory and bricks. My initial draft recommendedHERMES_HOME_MODE=0640as the example value in agent service blocks — corrected to0750(group-traversable) for multi-container setups. All three surfaces now match: compose files (per-service comments),.env.docker.example(multi-container warning section),docs/docker.md(failure mode #2 callout). 3 new regression tests pin the asymmetry:test_agent_service_does_not_recommend_invalid_home_mode,test_compose_files_warn_about_home_mode_asymmetry,test_env_docker_example_warns_about_home_mode_asymmetry. (docker-compose.two-container.yml,docker-compose.three-container.yml,.env.docker.example,docs/docker.md,tests/test_v050260_docker_invariants.py)
[v0.50.259] — 2026-05-01
Fixed
- SessionDB WAL handle leak — close before replacing on cached agent —
_run_agent_streamingcreated a newSessionDBinstance per request and replaced the cached agent's_session_dbreference without closing the old one. EachSessionDB.__init__opens a SQLite connection that holds 3 file descriptors once WAL kicks in (state.db,state.db-wal,state.db-shm). After ~73 messages on a long-lived agent (the empirically-confirmed crash count from the bug report), leaked FDs exhausted the 256 default limit causingEMFILEcrashes. Fix wraps the swap with an explicitagent._session_db.close()(idempotent + thread-safe via SessionDB's internal_lock+if self._conn:guard). (api/streaming.py) @wali-reheman — PR #1421
Changed (Opus pre-release advisor)
- Same FD-leak fix applied to LRU eviction path —
SESSION_AGENT_CACHE.popitem(last=False)was dropping the evicted agent on the floor withevicted_sid, _ = .... The agent's_session_dbwould only release its FDs when GC eventually finalized the agent — which on a long-running server may be never. Now captures the evicted entry, calls_evicted_agent._session_db.close()explicitly. Same shape as #1421's fix on the cached-agent reuse path. 5 regression tests intest_v050259_sessiondb_fd_leak.pycover both paths plusSessionDB.close()idempotency. (api/streaming.py,tests/test_v050259_sessiondb_fd_leak.py)
[v0.50.258] — 2026-05-01
Fixed
- Login stability: 30-day session TTL, redirect-back, connectivity probe — three independent fixes for users on flaky networks (VPN, Tailscale). (1)
SESSION_TTLextended from 24 hours to 30 days inapi/auth.pyso users no longer get kicked out daily. (2) When a session expires and the user is redirected to/login, the server now passes?next=<original-path>so_safeNextPath()instatic/login.jsredirects them back after a successful login instead of dumping them on the login screen. (3) Login page now probes/healthon load (a public endpoint) and distinguishes "session expired / wrong password" from "can't reach server" — when the server is unreachable, shows a clear "Cannot reach server — check your VPN / Tailscale connection." message, disables the form, retries every 3 seconds, and auto-reloads the page once the server becomes reachable again. (api/auth.py,static/login.js) @bsgdigital — PR #1419
Changed (Opus pre-release advisor)
- Login redirect URL encoding fix — multi-param queries no longer truncated — the original PR #1419 implementation built the outer
?next=parameter viaquote(path, safe='/:@!$&\'()*+,;=')which kept?and&literal. Two problems: (a) paths with multi-param queries (e.g./api/sessions?limit=50&offset=0) round-tripped as/api/sessions?limit=50because the inner&terminated the outernextvalue, (b) attacker-controlled paths with embedded&next=...injected a second top-levelnextparameter (browsers parse first-match, Python parse_qs parses last-match — parser-divergence footgun even though_safeNextPath()rejects the actual exploit). Fix encodes the entirepath?queryblob withsafe='/'so?,&,=all percent-encode. The outernextthen holds exactly one path-with-query string. 6 regression tests intest_v050258_opus_followups.pypin the round-trip behavior across simple paths, single-query paths, multi-param queries, and attacker-injection neutralization. (api/auth.py,tests/test_v050258_opus_followups.py)
[v0.50.257] — 2026-05-01
Added
-
Cron run history + full-output viewer (#468) — new
GET /api/crons/history?job_id=X&offset=N&limit=Mendpoint lists all output files for a job (filename + size + mtime) without loading content. NewGET /api/crons/run?job_id=X&filename=Yreturns full content + a snippet extracted from the## Responsesection. Tasks panel renders a per-job run history with click-to-expand. (api/routes.py,static/panels.js,static/i18n.js) @bergeouss — PR #1402, fixes #468 -
Per-session toolset overrides (#493) — new
Session.enabled_toolsets: list[str] | Nonefield threaded through_run_agent_streaming. NewPOST /api/session/toolsetsendpoint validates input shape (non-empty list of non-empty strings, or null to clear). Settings panel adds a per-session toolset chip with global/custom modes. Honors the override at the streaming hot path via_resolve_cli_toolsets. (api/models.py,api/routes.py,api/streaming.py,static/panels.js,static/i18n.js,static/index.html,static/style.css,static/ui.js) @bergeouss — PR #1402, fixes #493 -
Codex OAuth in-app device-code flow — new
api/oauth.py(stdlib only — no external HTTP libs). Two endpoints:GET /api/oauth/codex/start(initiates Codex device-code flow, returnsuser_code+verification_uri) andGET /api/oauth/codex/poll?device_code=X(SSE for polling token endpoint). Successful poll writes credentials to~/.hermes/auth.jsonundercredential_pool.openai-codex. Onboarding wizard adds a "Sign in with ChatGPT" path. Idempotent: existing OAuth credential entries are updated in place; new ones useuuid.uuid4().hex[:8]with retry-on-collision (3 attempts). (api/oauth.py,api/routes.py,static/onboarding.js,static/i18n.js,static/index.html,static/style.css) @bergeouss — PR #1402
Fixed
- Named custom provider routing in model picker —
@custom:NAME:modelform preserved (#557 follow-up to #1390) — when the model picker iteratedcustom_providersentries with anamefield (e.g.[{name: "sub2api", base_url, models: [...]}]), the option IDs were stored as bare model strings. On chat start, the backend resolved those bare strings through the active/default provider, silently routing the request to the wrong endpoint (e.g. DeepSeek instead of the user's selectedsub2apiproxy). Now the picker prefixes IDs with@<slug>:<model>whenever the active provider differs from the named slug, so_resolve_compatible_session_model_state(added by #1390) routes through the correct named provider. The frontend_findModelInDropdownalready strips@provider:prefixes during normalization, so legacylocalStorage["hermes-webui-model"]values with bare IDs continue to resolve. 5 new tests acrosstest_issue1106_custom_providers_models.py,test_provider_mismatch.py,test_security_redaction.py. (api/config.py) @Thanatos-Z — PR #1415
Changed (Opus pre-release advisor)
-
api/oauth.py::_write_auth_jsonchmod 0600 BEFORE rename —tmp.replace()preserves the temp file's umask-derived mode (commonly 0644 or 0664).auth.jsoncontains OAuth access/refresh tokens; on shared systems those tokens landed world-readable through the temp-file→rename window. Fix setstmp.chmod(0o600)before the atomic rename, with atry/except OSErrorthat logs but doesn't abort if chmod fails on filesystems that don't support POSIX modes. Theapi.startup::fix_credential_permissionssweep also catches this on next process start as belt-and-suspenders. (api/oauth.py,tests/test_v050257_opus_followups.py) -
_handle_cron_historyand_handle_cron_run_detailregex-validatejob_id— the_checkpoint_root() / ws_hash / checkpointpath-traversal vector caught in v0.50.255 (#1405) had a sibling here:CRON_OUT / job_id / *.md.Path() / "../escape"does NOT normalize. While_handle_cron_run_detailhad a downstreamis_relative_to(CRON_OUT.resolve())check,_handle_cron_historydidn't. New regex^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$with explicit./..rejection at the parameter boundary. Mirrors the rollback fix shape. (api/routes.py,tests/test_v050257_opus_followups.py) -
_handle_cron_historyclampsoffsetandlimit— rawint(qs.get("offset", ["0"])[0])raisedValueErroron?offset=fooand surfaced as a generic 500. No upper bound onlimiteither. Now wrapped intry/except (ValueError, TypeError)returning a 400 on bad input, andlimitclamped to[1, 500]. (api/routes.py) -
CRITICAL: per-session toolset override (#493) was non-functional —
_run_agent_streamingcalled_session_meta.get('enabled_toolsets')on the result ofSession.load_metadata_only(), which returns a Session instance (not a dict). TheAttributeErrorwas swallowed by the surroundingexcept Exception:block, so the user's toolset chip silently no-op'd every time and the agent always ran with the global toolsets. Caught by Opus pre-release advisor on the empirical streaming path (CI green, contributor tests green — would have shipped non-functional). Fix usesgetattr(_session_meta, 'enabled_toolsets', None). Source-level negative-pattern test prevents the dict-access shape from returning. (api/streaming.py,tests/test_v050257_opus_followups.py)
[v0.50.256] — 2026-05-01
Fixed
- TTS speaker icon and four other Lucide icons rendered invisibly (#1413, closes #1413) —
static/icons.js::LI_PATHSwas missing five icon names thatstatic/*.jscallsli('NAME', ...)with. Theli()helper logsconsole.warn('li(): unknown icon NAME')and returns an empty string when the name isn't registered, so the host element renders withdisplay:flexand a click handler but no glyph. Five missing entries added: (1)volume-2— TTS speaker button on every assistant message (ui.js:3376); regression from #499, surfaced after #1411 (v0.50.255) fixed the CSS specificity collision and made the empty button visible-but-empty. Reported by @AvidFuturist via Telegram. (2)chevron-up— queue pill chevron (ui.js:2178); had a▲ASCII fallback but only whenliitself was undefined, not when it returned''. (3)hash, (4)cpu, (5)dollar-sign— Insights panel stat cards (panels.js:883-885); fresh regression from #1405 (v0.50.255). New regression testtest_issue1413_li_path_coverage.pywalks everyli('NAME', ...)call acrossstatic/*.jsand asserts eachNAMEis registered inLI_PATHS— guards the entire class of bug, not just the five fixed here. (static/icons.js,tests/test_issue1413_li_path_coverage.py) — fixes #1413, reported by @AvidFuturist via Telegram
[v0.50.255] — 2026-05-01
Added
-
Insights panel — usage analytics dashboard (#464) — new
GET /api/insights?days=Nendpoint walks_index.json(no full session loads) and aggregates session/message/token counts, model breakdown, and activity-by-day-of-week + activity-by-hour. New nav rail entry between Todos and Settings; the panel renders stats cards, a token breakdown row, and ASCII-style horizontal-bar charts. Period filter (7/30/90 days). (api/routes.py,static/panels.js,static/index.html,static/i18n.js,static/style.css) @bergeouss — PR #1405, fixes #464 -
Rollback UI — restore from agent checkpoints (#466) — new
api/rollback.pyexposes 3 endpoints (GET /api/rollback/list,GET /api/rollback/diff,POST /api/rollback/restore) over the agent'sCheckpointManagershadow git repos at{hermes_home}/checkpoints/<sha256-of-canonical-workspace>/<commit_hash>/.git. Workspace is allowlisted viaload_workspaces()(added during contributor security passd9f3a69)._validate_checkpoint_id()regex-guards the checkpoint parameter against path-traversal (Opus pre-release advisor finding —Path()does NOT normalize..). Restore copies files viashutil.copy2and never deletes; diff usesdifflib.unified_diff. (api/rollback.py,api/routes.py) @bergeouss — PR #1405, fixes #466 -
Turn-based voice mode — STT + TTS chained flow — new voice-mode button in the composer; activating it puts the agent in a listen → send → think → speak → listen loop. Uses the browser's Web Speech API (gated on both
SpeechRecognitionANDspeechSynthesissupport). Auto-send on 1.8s silence after a final transcript. Honors saved voice preferences (hermes-tts-voice,hermes-tts-rate,hermes-tts-pitch). Bails out onnot-allowed/service-not-allowed/audio-captureerrors. Pre-release fix: the patchedautoReadLastAssistantfired globally — if the user navigated to a different session between send and stream completion, TTS would speak the wrong session's reply. Now capturesS.session.session_idat thinking-time and bails to listening if the active session changed. (Opus pre-release advisor.) (static/boot.js,static/i18n.js,static/index.html,static/style.css) @bergeouss — PR #1405 -
API redact toggle — opt out of response-layer redaction — adds
api_redact_enabledsetting (defaults toTrueso existing users see no behavioral change). When disabled,redact_session_data()returns payloads as-is. Useful for users who pipe the WebUI API into automation that needs the original strings. (api/helpers.py,api/config.py,static/panels.js,static/i18n.js) @bergeouss — PR #1405 -
Subagent tree visualization — UI affordance for sessions that spawn subagents. (
static/panels.js,static/sessions.js,static/style.css,static/i18n.js) @bergeouss — PR #1405
Fixed
-
Session provider context preserved across model picker → runtime resolution (#1240) — the WebUI model picker can show multiple providers exposing the same bare model id (e.g.
gpt-5.5from OpenAI Codex, OpenRouter, Copilot). Previously sessions persisted only the bare model, so a session selected as "gpt-5.5 from OpenAI Codex" silently rerouted through whatever provider became default after a config change. Newmodel_provider: str | Nonefield onSessionis persisted in metadata, threaded through every chat path (/api/session/new,/api/session/update,/api/chat/start,/api/chat/sync,/btw,/background,_run_agent_streaming), and is gated incompact()to emit only when truthy (matches v0.50.251 lineage end_reason gating). Newmodel_with_provider_context(model_id, model_provider)inapi/config.pybuilds the@provider:modelform when provider differs from configured default, then passes throughresolve_model_provider(). New_should_attach_codex_provider_context()narrow exception detects bare GPT-* models under active OpenAI Codex (because Codex/OpenRouter/Copilot expose overlapping GPT names). New_resolve_compatible_session_model_state()returns(effective_model, effective_provider, model_was_normalized). Frontend addsMODEL_STATE_KEY='hermes-webui-model-state'localStorage with structured persistence and migrates from the legacyhermes-webui-modelkey. 13 new tests intest_provider_mismatch.py, 2 intest_model_picker_badges.py. (api/config.py,api/models.py,api/routes.py,api/streaming.py,static/boot.js,static/messages.js,static/panels.js,static/sessions.js,static/ui.js) @starship-s — PR #1390, refs #1240 -
TTS toggle: speaker icon never appeared when "Text-to-Speech for responses" was ticked (#1409, closes #1409) —
_applyTtsEnabled()setbtn.style.display=enabled?'':'none'on every.msg-tts-btn. The''branch removes the inline override, after which the.msg-tts-btn{display:none;}rule fromstyle.cssre-hides the button. Both the "enabled" and "disabled" branches left the icon hidden, so the toggle had no visible effect since the feature shipped in #499. Fixed by switching to a body-class toggle (body.tts-enabled) plus a compound CSS selector (body.tts-enabled .msg-tts-btn{display:inline-flex;}). The new shape bypasses the.msg-action-btn/.msg-tts-btncascade collision and survives subsequentrenderMd()re-renders without re-querying every button. (static/panels.js,static/style.css,tests/test_499_tts_playback.py) — PR #1411, fixes #1409, reported by @AvidFuturist via Discord -
Ollama (local) no longer falsely reports "API key configured" when only Ollama Cloud key is set (#1410, closes #1410) — both providers were mapped to the same
OLLAMA_API_KEYenv var in_PROVIDER_ENV_VAR, so configuring Ollama Cloud lit up the local Ollama card too. The runtime inhermes_cli/runtime_provider.pyonly consumesOLLAMA_API_KEYwhen the base URL hostname isollama.com— local Ollama is keyless by design — so the WebUI was reporting "configured" for a key local Ollama doesn't even read. Dropped the bare"ollama": "OLLAMA_API_KEY"mapping; local Ollama users who genuinely need a key can still setproviders.ollama.api_keyinconfig.yaml, and_provider_has_key()continues to honor that path. (api/providers.py,tests/test_provider_management.py) — PR #1411, fixes #1410, reported by @AvidFuturist via Discord
Changed
-
api/rollback.py— checkpoint id regex validation (defense-in-depth) — Opus pre-release follow-up. Thecheckpointparameter on/api/rollback/diffand/api/rollback/restorewas joined into the path via_checkpoint_root() / ws_hash / checkpoint.Path("/a/b") / "../escape"does NOT normalize, so an authenticated caller could pass../<other-ws-hash>/<sha>and read or restore from another allowlisted workspace's checkpoint store. New_validate_checkpoint_id()regex-guards with^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$and rejects literal./... (api/rollback.py) -
redact_session_data()readsapi_redact_enabledonce per response, not per string — Opus pre-release follow-up. The new_redact_textper-stringload_settings()call (added by #1405's redact-toggle feature) caused hundreds of disk reads + JSON parses per/api/session?session_id=Xresponse on a 50-message session — every nested string inmessages[]andtool_calls[]recursed back into_redact_value→_redact_text→load_settings. Now read once at the top ofredact_session_data()and threaded through via a private_enabledkeyword. Fast path when disabled: still walks but returns immediately. (api/helpers.py,tests/test_v050255_opus_followups.py) -
Voice mode pins active session id at thinking-time — Opus pre-release follow-up. The patched
autoReadLastAssistantfires globally; if the user navigated to a different session between sending a turn and stream completion, TTS would speak the wrong session's last assistant message. New_voiceModeThinkingSidclosure variable capturesS.session.session_idin_voiceModeSend;_speakResponsebails to_startListening()if the current sid no longer matches. (static/boot.js,tests/test_v050255_opus_followups.py) -
api/rollback.py::_inspect_checkpointdrops bareExceptionfrom except tuple — Opus pre-release follow-up. The previousexcept (subprocess.TimeoutExpired, OSError, Exception)made the specific catches redundant and swallowed everything. Now(subprocess.TimeoutExpired, OSError)only. (api/rollback.py,tests/test_v050255_opus_followups.py)
[v0.50.254] — 2026-05-01
Fixed
-
API 500 regression on /api/sessions, /api/memory:
_combined_redactTypeError (#1394, closes #1394) — PR #1387 follow-upfc88981started passingforce=Truetoredact_sensitive_text(), but older hermes-agent builds don't accept theforcekwarg. Every redaction call on the hot path crashed withTypeError, degrading the entire API to 500 errors._combined_redactnow wraps the call intry/except TypeErrorand falls back to the no-kwarg call. The local fallback (ghp_/sk-/hf_/AKIA) still runs unconditionally, so coverage doesn't regress. (api/helpers.py) @bergeouss — PR #1400, fixes #1394 -
Code block tree-view: newlines stripped from data-raw, jsyaml retry loop missing (#1397, closes #1397) — Two bugs in the JSON/YAML tree-view renderer. (1) Browsers normalize newlines to spaces inside HTML attribute values (HTML spec); the
data-rawattribute on.code-tree-wraplost every newline, so multi-line YAML/JSON came out as single-line tree views. Fixed by encoding\nas before writing the attribute. (2) When jsyaml hadn't loaded yet,initTreeViews()setdata-tree-init=1immediately and bailed — the lazy-load callback never re-invoked init, leaving the block in raw view forever. Fixed by removingdata-tree-initand calling_loadJsyamlThen(initTreeViews)to retry after load. (static/ui.js) @bergeouss — PR #1400, fixes #1397 -
Credential permission fixer respects HERMES_HOME_MODE and HERMES_SKIP_CHMOD (#1389, closes #1389) —
fix_credential_permissions()was unconditionally forcing 0600 on every credential file inHERMES_HOMEat startup. Docker setups that intentionally use group bits (e.g.HERMES_HOME_MODE=0640for shared volumes) had their declared mode silently overridden. NowHERMES_SKIP_CHMOD=1bypasses the fixer entirely; whenHERMES_HOME_MODEis set, the fixer only strips world bits (0o007) and preserves operator-declared group access. (api/startup.py) @bergeouss — PR #1400, fixes #1389 -
Sidebar session click is now instant on mouse, drag-aware on touch (#1398) — clicking a chat in the sidebar previously had a 300ms delay on every device to disambiguate single-tap from double-tap-rename. Mouse users perceived this as lag. Now the delay is 0 for
pointerType==='mouse'and stays 300ms for touch (where it's needed for tap-vs-drag disambiguation). Adds pointermove drag detection: movement >5px from pointerdown marks the gesture as a drag, cancels the pending tap timer, suppresses hover highlighting via a.draggingclass, and clears 50ms after release so the row doesn't flash hover mid-scroll. (static/sessions.js,static/style.css) @JKJameson — PR #1398 -
Per-tab session URL anchors via
/session/<id>(#1392) — replaces the cross-tablocalStorage['hermes-webui-session']active-session bus with per-tab URL ownership. Each tab anchors its active conversation in the path (/session/<id>), so two tabs viewing different sessions can no longer yank each other around when localStorage changes. The<base href>script instatic/index.htmlstops at the/session/marker so subpath mounts (/myapp/session/<id>) still resolve assets correctly; allnew URL('api/...', location.href)calls migrated todocument.baseURI||location.hreffor the same reason. New helpers_sessionIdFromLocation(),_sessionUrlForSid(),_setActiveSessionUrl()insessions.js. Lineage-aware active highlighting (_sessionLineageContainsSession) keeps a forked session highlighted even when collapsed inside a parent lineage row. Thepopstatehandler navigates between sessions via browser back/forward but refuses to switch mid-stream (S.busyguard, mirroring the cross-tab storage handler). The cross-tab storage handler was deliberately defanged so it only re-renders the sidebar — it no longer force-loads the new sid into the current tab. (api/routes.py,static/boot.js,static/commands.js,static/index.html,static/messages.js,static/sessions.js,static/terminal.js,static/ui.js,static/workspace.js,tests/test_session_cross_tab_sync.py,tests/test_session_lineage_collapse.py) @dso2ng — PR #1392
Changed
-
Settings toggle: "Show CLI sessions" → "Show non-WebUI sessions" (#1407) — the old label was misleading: the feature surfaces conversations from CLI, Telegram, Discord, Slack, WeChat, and other non-WebUI channels — not just CLI. The new label captures the actual scope. Pure rename across all 8 locales (en, zh, zh-Hant, ru, es, de, pt, ko); underlying logic untouched. Reordered channel examples by global adoption (Telegram, Discord, Slack first; WeChat de-emphasized). (
static/i18n.js,static/index.html,tests/test_korean_locale.py) @franksong2702 — PR #1407 -
popstatehandler refuses to switch sessions mid-stream — Opus pre-release follow-up. Mirrors the sameS.busyguard the cross-tab storage handler had. A user mid-stream who absent-mindedly hits browser Back used to lose their active turn (PR #1392 introduced the popstate listener without the guard). Now shows a toast and stays on the current session. 1 regression test intest_v050254_opus_followups.py. (static/sessions.js)
Added
- Messaging sessions get a WebUI handoff path without exposing every raw channel segment — Weixin and Telegram sessions imported from Hermes Agent are now treated as messaging-source conversations: sidebar results keep only the latest visible session per channel, preserve source metadata through compact/import paths, and avoid destructive/duplicating menu actions that would imply WebUI owns the external channel history. Messaging sessions with enough external conversation rounds show a composer-docked handoff prompt; clicking it generates a transcript card summary for the user without inserting a fake command bubble. This is PR2 for the #1013 channel-handoff direction and intentionally does not cover the separate CLI Session follow-up. (
api/models.py,api/routes.py,static/index.html,static/messages.js,static/sessions.js,static/style.css,static/ui.js,tests/test_gateway_sync.py,tests/test_issue1013_handoff_dock.py) @franksong2702 — refs #1013
[v0.50.253] — 2026-05-01
Added
/branchslash command — fork a conversation from any message (#1342, closes #465) — adds a/branch [name]slash command and a "Fork from here" hover action on every message. Forking deep-copies the conversation up to a given message index into a brand-new session that inherits the source'sworkspace,model,profile, and the title (with "(fork)" appended). Fresh state forsession_id, timestamps, tokens, cost,active_stream_id,pending_user_message,pending_attachments. The newparent_session_idfield onSessionis gated incompact()to emit only when truthy — sessions without a fork link don't leakparent_session_id: Noneinto/api/sessionspayloads, preserving the v0.50.251 lineage end_reason gating inagent_sessions.py. Endpoint validatessession_idis a string andkeep_count >= 0before slicing. 21 regression tests intest_465_session_branching.py. (api/routes.py,api/models.py,static/commands.js,static/i18n.js,static/icons.js,static/sessions.js,static/ui.js,tests/test_465_session_branching.py) @bergeouss — PR #1342, fixes #465
Fixed
-
Local model setup no longer fails mid-conversation with
LOCAL_API_KEYerror (#1388, closes #1384) — whenmodel.base_urlpointed at an OpenAI-compatible loopback endpoint that didn't match theollama/localhost/lmstudiokeyword classifier (e.g.http://192.168.1.10:8080/v1, llama.cpp on127.0.0.1:8080, vLLM, TabbyAPI, custom proxies),_build_available_models_uncachedauto-detected the provider as"local"and persisted that intoconfig.yaml. Inference worked initially because the main agent has its own direct path that uses the explicitbase_url + api_key, but once the conversation grew enough to trip auto-compression — or when vision / web extraction / skills-hub fired — the agent's auxiliary client routed throughresolve_provider_client("local", …), fell through every branch (since"local"is not inhermes_cli.auth.PROVIDER_REGISTRY), and raisedProvider 'local' is set in config.yaml but no API key was found. Three-layer fix: (1) the auto-detect block now writesprovider: "custom"instead of"local"for unknown loopback hosts —customis the canonical OpenAI-compat fall-through; (2)resolve_model_provider()rewrites legacy"local"to"custom"at read time so existing broken configs heal automatically; (3)set_hermes_default_model()refuses to persist"local"going forward, with a_PROVIDER_ALIASES["local"] = "custom"entry. 9 regression tests intest_issue1384_local_provider.py. (api/config.py,tests/test_issue1384_local_provider.py) — PR #1388 -
Mobile composer layout: progressive-disclosure config panel + scoped titlebar safe-area (#1381) — the mobile composer had two separate pressure points: normal browser/webview shells could end up with extra titlebar spacing from top safe-area padding, and the composer had more always-visible controls than narrow phone widths can comfortably support. The titlebar fix: top safe-area padding now applies only in
(display-mode: standalone), (display-mode: fullscreen)— installed/PWA mode — via--app-titlebar-safe-top. The composer fix: a phone-only config button collapses workspace/model/reasoning/context controls into a panel above the composer, keeping the primary inline row at attach + voice + profile + workspace files + config + send. Compact context badge on the config button. Pre-release fixes: (1) base.composer-mobile-config-btn{display:none}rule had equal specificity with.icon-btn{display:flex}and lost the cascade (later in source wins) — bumped to.icon-btn.composer-mobile-config-btn{display:none}so the button stays hidden at desktop widths. (2) Uppercase WORKSPACE/MODEL/REASONING kicker labels at 700-weight overflowed the 60px copy column on iPhone 14 — hidden inside the open panel via.composer-mobile-config-action:not(.composer-mobile-context-action) .composer-mobile-config-kicker{display:none}so the icon + value gives a clean two-row layout. Context row keeps its kicker since it stretches to full panel width. Plus a follow-up commit from the contributor tightening composer spacing on 320px legacy phones (@media (max-width: 340px)block). 47 mobile-layout regression tests pass. (static/i18n.js,static/index.html,static/panels.js,static/style.css,static/ui.js,tests/test_mobile_layout.py) @starship-s — PR #1381
Changed
-
/branchendpoint validates input types and ranges — Opus pre-release follow-up. Reject non-stringsession_idwith a clear 400 (was raising TypeError → confusing 500 fromget_session()). Reject negativekeep_countwith a clear 400 (Python slice semantics on negative produces "all but last N", which is confusing fork behavior). 2 regression tests intest_v050253_opus_followups.py. (api/routes.py) -
Strip 9 orphan
wiki_*i18n keys — Opus pre-release follow-up. Commit52bfcea(#1342) leakedwiki_panel_title,wiki_panel_desc,wiki_status_label,wiki_entry_count,wiki_last_modified,wiki_not_available,wiki_enabled,wiki_disabled,wiki_toggle_failedacross all 8 locales (72 lines total) from a different branch — zero references outsidei18n.js. Stripped, with regression test pinning that they don't return. (static/i18n.js,tests/test_v050253_opus_followups.py)
[v0.50.252] — 2026-05-01
Fixed
- CLI session import no longer crashes when metadata row is missing —
_handle_session_import_clionly assignedmodelinside thefor cs in get_cli_sessions(): if cs["session_id"] == sidloop. Sessions that existed in the messages store but were missing from the metadata index (post-pruning, race during cron job export, etc.) reached the downstreamimport_cli_session(sid, title, msgs, model, ...)call withmodelunbound and crashed withUnboundLocalError. The fix initializesmodel = "unknown"before the loop so the import proceeds with a sensible default. Added a regression test that asserts the init lives before the loop. (api/routes.py,tests/test_session_import_cli_fallback_model.py) @trucuit — PR #1386 - Streaming scroll no longer yanks the viewport when tool/queue cards insert (#1360) — three independent paths could re-pin a user mid-read while the agent streamed: (a) browser scroll-anchoring on
#messagesshifted the scroller when card heights changed, (b) the queue-card rendersetTimeoutcalled unconditionalscrollToBottom()regardless of stream state, and (c) the queue-pill click handler did the same. Now#messageshasoverflow-anchor:none, the near-bottom re-pin dead zone widens from 150px to 250px (small macOS-app windows + trackpad momentum no longer re-pin too eagerly), and both queue-card paths respectS.activeStreamId— usingscrollIfPinned()mid-stream and falling back toscrollToBottom()only after the stream ends. 4 regression tests pin all four invariants. (static/style.css,static/ui.js,tests/test_issue1360_streaming_scroll_hardening.py) @NocGeek — PR #1377, fixes #1360 - API credential redaction no longer regresses for
ghp_*/sk-*/hf_*/AKIA*tokens —_build_redact_fn()previously returned the agent'sredact_sensitive_textdirectly wheneveragent.redactimported. The agent redactor missed several common credential prefixes that the WebUI's local fallback already knew how to mask, so session/search/memory API responses could leak plaintext credentials. Now both run in series — agent first (handles broader patterns whenHERMES_REDACT_SECRETSis enabled), local fallback second (always-on, catches the common token shapes). The chained order is safe: agent masking shortens tokens to aprefix...suffixform that the fallback regex's character class no longer matches, so no double-redaction. The agent-broader patterns (Stripesk_live_, GoogleAIza…, JWTeyJ…) still depend on the env var; opening a follow-up to switch the WebUI call toforce=True. (api/helpers.py) @NocGeek — PR #1379 /statusslash command shows the resolved Hermes home directory (refs #463) — the WebUI/statuscard already showed model, profile, workspace, timestamps, and token counts but was missing the profile-aware Hermes home path that the CLI'shermes statusdisplays.session_status()now returnsprofileandhermes_homekeys (resolved viaget_hermes_home_for_profile()so named profiles resolve to their dedicated dirs), andcommands.js cmdStatusrenders the newHermes home:line. Newstatus_hermes_homei18n key added across all 8 locales (en/ru/es/de/zh/zh-Hant/pt/ko). (api/session_ops.py,static/commands.js,static/i18n.js,tests/test_session_ops.py) @NocGeek — PR #1380, refs #463
Added
/api/models/livenow caches results for 60 seconds — repeated model-list refreshes (every panel open, every workspace switch) hit upstream provider APIs every time. The new in-memory TTL cache keyed by(active_profile, provider)returns deep copies so callers can't mutate the cache, expires after 60s, and is guarded bythreading.RLockfor thread-safety. The cache lives next to_handle_live_modelsand is cleared via_clear_live_models_cache()in tests. 4 regression tests cover hit-within-TTL, expiry, profile-scoping (default vs research stay separate), and mutation isolation. (api/routes.py,tests/test_live_models_ttl_cache.py) @NocGeek — PR #1378- WebUI explains CLI-only slash commands instead of forwarding them to the model — typing
/browser connector any other Hermes CLI-only command in the WebUI used to fall through as plain text, so the model would explain the command instead of the app. The frontend now lazy-fetches/api/commandsmetadata, matches by name and aliases, and intercepts any command flaggedcli_onlywith a local assistant message that explains the command is CLI-only. Special note for/browserabout how WebUI's browser tools must be configured server-side (CLI-only/browseritself does not work in the WebUI). Built on the existingcli_onlyfield that/api/commandsalready exposed; no agent-side changes. (static/commands.js,static/messages.js,tests/test_cli_only_slash_commands.py) @NocGeek — PR #1382
Changed
- API credential redaction now uses
force=True—_combined_redact(introduced by #1379) now passesforce=Truetoredact_sensitive_textso the agent's broader patterns (Stripesk_live_, GoogleAIza…, JWTeyJ…, DB connection strings, Telegram bot tokens) run regardless of the user'sHERMES_REDACT_SECRETSopt-in. The local fallback then handles the short-prefix shapes the agent omits (ghp_,sk-,hf_,AKIA). WebUI API responses are a hard safety boundary — no opt-in should be required. (api/helpers.py) — Opus pre-release follow-up _active_profile_for_live_models_cachelogs the fallback path — whenget_active_profile_name()raises (transient state, mid-switch, etc.) the live-models cache (#1378) falls back to"default", mis-scoping the cache for up to 60s. Now logs at debug so we can detect this in production logs without changing the blast radius (TTL still caps the bad-cache window). (api/routes.py) — Opus pre-release follow-up
[v0.50.251] — 2026-04-30
Fixed
- Sidebar lineage collapse now works for WebUI JSON sessions, not just imported gateway rows — PR #1358 (v0.50.249) added the client-side lineage-collapse helper but
/api/sessionsonly included_lineage_root_idfor gateway-imported rows. WebUI JSON sessions (the common case) had no grouping key, so cross-surface continuation chains (CLI-close → WebUI continuation, or compression chains within WebUI) still rendered as separate sidebar rows. Now/api/sessionsreadsparent_session_idandend_reasonfromstate.db.sessionsfor every WebUI session id in the sidebar payload, walks the parent chain whenend_reason in {'compression', 'cli_close'}, and exposes_lineage_root_id+_compression_segment_count. Cycle-detected via aseenset; depth-bounded to 20 hops to cap pathological data. Pre-release fix: swapped the original full-table-scan for a parameterizedWHERE id IN (...)query that hits PRIMARY KEY +idx_sessions_parent— ~50× faster at 1000 rows, scales linearly. Pre-release fix: chunked IN clause to 500 vars to stay under SQLITE_MAX_VARIABLE_NUMBER on older sqlite (Python 3.9 ships sqlite 3.31 with default limit 999) — without this a power user with 2000+ sessions in the sidebar would hitOperationalError: too many SQL variables, the silent except-wrapper would swallow it, and lineage collapse would never work for them. Pre-release fix: tightenedparent_session_idexposure — only emitted when the parent'send_reasoniscompressionorcli_close(not foruser_stop/etc), since the frontend's_sessionLineageKeyfalls through toparent_session_idand would incorrectly collapse two children of a non-continuation parent into a single row. (api/agent_sessions.py,api/models.py,tests/test_session_lineage_metadata_api.py,tests/test_pr1370_lineage_metadata_perf_and_orphan.py,tests/test_gateway_sync.py) @dso2ng — PR #1370 - Manual cron runs persist output and metadata like scheduled runs — manual WebUI cron runs called
cron.scheduler.run_job(job)and then only cleared the in-memory running flag. The job's output was dropped (never written viasave_job_output) andlast_run_at/last_statuswere never updated. Now the manual-run wrapper (_run_cron_tracked) matches the scheduled-cron path atcron/scheduler.py:1334-1364exactly: saves output, marks the job complete, treats emptyfinal_responseas a soft failure (with the same error string), and records failures viamark_job_run(False, str(e)). (api/routes.py,tests/test_cron_manual_run_persistence.py) @NocGeek — PR #1372 (split out from the held #1352 per pre-release feedback) - Reasoning trace, tool calls, and partial output preserved on Stop/Cancel — three distinct data-loss paths fixed: §A reasoning text accumulated in a thread-local
_reasoning_textwas invisible tocancel_stream()because it went out of scope when the thread was interrupted; §B live tool calls in thread-local_live_tool_callswere similarly lost; §C reasoning-only streams (no visible tokens) produced no partial assistant message because the thinking-block regex strip returned empty string and theif _stripped:guard skipped the append. The fix mirrors the existingSTREAM_PARTIAL_TEXTpattern (#893) by adding two new shared dicts (STREAM_REASONING_TEXT,STREAM_LIVE_TOOL_CALLS) populated during streaming and read bycancel_stream(). The cancel path now appends the partial assistant message when content text, reasoning trace, OR tool calls exist (not just text). Eliminates "paid tokens disappeared" reports on Stop. 8 regression tests covering all three sections plus tools+text combinations. (api/config.py,api/streaming.py,tests/test_issue1361_cancel_data_loss.py) @bergeouss — PR #1375, fixes #1361 - New profiles route sessions to the profile dir on first use, not back to default —
get_hermes_home_for_profile()had aif profile_dir.is_dir(): return profile_dir; return _DEFAULT_HERMES_HOMEfallback. New profiles (no session yet, so no dir) routed every session back to default until the directory existed on disk — making profile switching silently broken for the first session of every new profile. Removed theis_dir()guard; the profile path is now returned unconditionally and the directory is created on first use by the agent/session layer. Path traversal is still blocked by the_PROFILE_ID_REregex (^[a-z0-9][a-z0-9_-]{0,63}$); R19j tests were updated to pin that the regex is now the sole defense. R19c was tightened to assert the new behavior. 5 regression tests intest_issue1195_session_profile_routing.pycovering existing-profile, non-existent-profile (the core fix), None, empty-string, and 'default' return paths. (api/profiles.py,tests/test_issue798.py,tests/test_issue1195_session_profile_routing.py) @bergeouss — PR #1373, fixes #1195
[v0.50.250] — 2026-04-30
Fixed
- Cross-tab thinking-card cleanup no longer touches the wrong session's DOM — switching browser tabs while a stream is running could leave
finalizeThinkingCard()operating on a staleliveAssistantTurnnode — the thinking card belonged to the stream that started it, not the session currently displayed in the active tab. The guard early-returns when the live turn'sdataset.sessionIddoes not matchS.session.session_id. Per-site stamps were also added: every place that createsliveAssistantTurn(3 sites instatic/ui.js) now writes the current session id ontodataset.sessionIdso the guard has the data it needs to compare. Without the stamps the guard would always early-return (becauseundefined !== "<sid>"is always true), breaking the streaming UI completely — caught during pre-release review of #1366. Plus a regression test that fails any futureliveAssistantTurncreation site that forgets the stamp. (static/ui.js,tests/test_pr1366_finalize_thinking_card_guard.py) @JKJameson — PR #1366 - Clarify SSE health timer is now an actual stale-detector, not an unconditional 60s force-reconnect — the timer at
static/messages.js:1715shipped in v0.50.249 / PR #1355 closed and re-opened the EventSource every 60s regardless of activity, with a comment that wrongly claimed it was a "no event in 60s" detector. Effects on healthy connections: one TCP/SSE setup+teardown per minute per active session, plus aclarify._lockround-trip and freshinitialsnapshot push from the server. Now trackslastEventAtoninitial/clarifyevent arrivals; only reconnects when the gap exceeds 60s. On a session with steady clarify traffic the timer never reconnects; on a long-idle session it still reconnects roughly every 60-120s (the residual idle reconnect could be eliminated with a server-sidepingevent or a longer threshold — tracked as a follow-up). Originally pulled out of the v0.50.249 batch as out-of-scope; brought back per the rule that small correctness-improving fixes ship even when flagged out-of-scope. (static/messages.js) — PR #1367 (Opus pre-release review of v0.50.249, SHOULD-FIX #2) - Preferences panel autosaves all fields (Phase 2 of #1003) — extends the autosave pattern from the Appearance panel to the Preferences panel so 13 preference fields (send_key, language, show_token_usage, simplified_tool_calling, show_cli_sessions, sync_to_insights, check_for_updates, sound_enabled, notifications_enabled, sidebar_density, auto_title_refresh_every, busy_input_mode, bot_name) save automatically without requiring a manual "Save Settings" click. 350ms debounce on field changes (additional 500ms wrapper on the bot_name text input). Inline status feedback (saving / saved / failed + retry). Password field still requires explicit save (security — never autosave passwords). Model selector still requires explicit save (different code path). Reuses the i18n keys (
settings_autosave_saving/saved/failed/retry) already present in all 8 locales from Phase 1. (static/index.html,static/panels.js) @fecolinhares — PR #1369
[v0.50.249] — 2026-04-30
Added
- Real-time clarify notifications via SSE long-connection — replaces the 1.5s HTTP polling loop for clarify (
/api/clarify/pending) with a Server-Sent Events endpoint at/api/clarify/stream?session_id=that pushes clarify events to the browser the instant they fire. Mirrors the approval-SSE pattern shipped in v0.50.248 (#1350) including all the correctness lessons learned during that release: atomic subscribe + initial snapshot inside a singlewith clarify._lock:block (no snapshot/subscribe race),_clarify_sse_notifyinvoked from inside_lockin bothsubmit_pendingandresolve_clarify(no notify-ordering race), payload built fromq[0].datahead-of-queue (not the just-appended entry), andresolve_clarifyre-emits the new head (orNone/0when empty) so trailing clarify prompts never get stuck. Frontend usesEventSourcewith automatic 3s HTTP polling fallback ononerror, plus a 60s reconnect timer to recover from silently-broken connections. Boundedqueue.Queue(maxsize=16)per subscriber with silent drop on full prevents memory leaks from slow tabs. 29 new static-analysis + unit + concurrency tests. (api/clarify.py,api/routes.py,static/messages.js,tests/test_clarify_sse.py) @fxd-jason — PR #1355
Fixed
- Context window indicator no longer shows misleading "100% used (0% left)" when context_length is missing from the live SSE payload — the v0.50.247 / PR #1348 fallback to
agent.model_metadata.get_model_context_length()was applied to the session-save path but NOT to the live SSEusageevent. For sessions on large-context models (e.g. claude-sonnet-4.6 via OpenRouter, 1M tokens) where the agent didn't have a compressor configured,usage.context_lengthwas omitted from the SSE payload, the JS frontend defaulted to 128K, and cumulativeinput_tokensover multiple turns overflowed against the 128K default — clamping the ring to 100% with a tooltip claiming the context was "0% left." The fix mirrors the session-save fallback exactly: whenusage.context_lengthis missing, resolve viaget_model_context_length(model, base_url)and write it onto theusagedict before serialization. Symmetric fallback added forlast_prompt_tokens(usess.last_prompt_tokensinstead of the cumulativeinput_tokenscounter). Frontend now tracksrawPctseparately from the clampedpct; whenrawPct > 100the tooltip shows${rawPct}% used (context exceeded)instead of misleading users. (api/streaming.py,static/ui.js) — PR #1356 - "Uploading…" composer status persists for the entire stream duration after a file upload —
setComposerStatus('Uploading…')was set beforeuploadPendingFiles()but never cleared after the upload completed; onlysetBusy(false)at the end of the agent stream eventually wiped it. Users saw "Uploading…" displayed during the agent response, which is misleading. The fix clears the status unconditionally after the upload await completes. UX defect, no behavior change to upload correctness or message text. (static/messages.js) — PR #1356 - Imported CLI/gateway session metadata survives compact() round-trip —
Session.load_metadata_only().compact()was droppingis_cli_session,source_tag,session_source, andsource_label, so imported agent/Telegram/messaging sessions in the sidebar lost their provenance after the metadata-only fast path. Adds these four fields toSession.__init__, theMETADATA_FIELDSsave round-trip, andcompact()output. Without this, sidebar payloads couldn't distinguish imported sessions from native WebUI ones. (api/models.py,tests/test_gateway_sync.py) @dso2ng — PR #1357 - Sidebar collapses compression-lineage segments instead of showing every segment as a separate row — when an agent session has a compression lineage (
_lineage_root_idpopulated by the gateway-import path inapi/agent_sessions.py:169), the sidebar previously listed each segment as its own top-level conversation, cluttering the list with what the user perceives as a single conversation. Adds a pure client-side helper_collapseSessionLineageForSidebar()that groups by_lineage_root_id/lineage_root_id/parent_session_id, keeps only the most recently active tip per group, and stores_lineage_collapsed_counton the visible row for future UI affordances. Non-destructive — no session JSON or messages are merged, deleted, or rewritten. Only collapses rows when lineage metadata is present. (static/sessions.js,tests/test_session_lineage_collapse.py) @dso2ng — PR #1358 - Active session synchronizes across multiple browser tabs — multiple WebUI tabs sharing the same
localStoragewould diverge from each other when one tab switched sessions, leaving idle tabs with stale in-memory active-session state until their next user action wrote into the wrong session. Adds astorageevent listener on thehermes-webui-sessionlocalStorage key. Idle tabs auto-load the new active session and re-render the sidebar cache. Busy tabs (currently mid-turn) do not auto-switch — they show a brief toast instead, so the user notices but the active turn isn't interrupted. (static/sessions.js,tests/test_session_cross_tab_sync.py) @dso2ng — PR #1359
[v0.50.248] — 2026-04-30
Added
- Real-time approval notifications via SSE long-connection — replaces the 1.5s HTTP polling loop with a Server-Sent Events endpoint at
/api/approval/stream?session_id=that pushes approval events to the browser the instant they fire. Cuts approval latency from up to 1.5s down to near-instant and eliminates the "always polling" network noise users observed. Backend uses a thread-safe subscriber registry (_approval_sse_subscribersdict, boundedqueue.Queue(maxsize=16)per subscriber, silent drop on full to prevent leaks from slow tabs). 30s keepalive comments prevent proxy/CDN timeouts;_CLIENT_DISCONNECT_ERRORS+finallyblock guarantee subscriber cleanup on any exit path. Subscribe and snapshot are taken atomically under a single_lockacquisition so asubmit_pending()arriving in the gap can't be lost. Notify runs inside the queue-mutation lock in bothsubmit_pendingand_handle_approval_respondso two parallel callers can't deliver out-of-order with stalepending_count. SSE payload always reflects head-of-queue, never tail, matching/api/approval/pending's contract — with parallel tool-call approvals (#527), the just-appended entry is at the tail but the UI must show the head._handle_approval_respondnow re-emits the new head after popping so a trailing approval queued behind the one being responded to is surfaced immediately instead of getting stuck until the next event. Frontend usesEventSourcewith automatic 1.5s HTTP polling fallback ononerror(preserves degraded-mode parity with v0.50.247). 50 tests cover wiring, lifecycle, multi-subscriber, cross-session isolation, queue overflow, concurrent subscribe/notify stress, atomic-lock invariants, head-fidelity, trailing-approval re-emission, and notify-order monotonicity. (api/routes.py,static/messages.js,tests/test_approval_sse.py,tests/test_pr1350_sse_atomic_subscribe.py,tests/test_pr1350_sse_notify_correctness.py) @fxd-jason — PR #1350
Fixed
- Context indicator percentage shows even without explicit
context_length— frontend companion to the v0.50.246 backend fix. The context ring used to display·(no data) whenevercontext_lengthwas 0 or missing — fresh agents, interrupted streams, or models without compressor state. Now defaults to 128K whenusage.context_lengthis falsy and labels the indicator with(est. 128K)so users can tell apparent vs. measured. Falls back toinput_tokensforlast_prompt_tokensso the ring lights up immediately on the first user message. (static/ui.js) @fxd-jason — PR #1349
[v0.50.247] — 2026-04-30
Added
- Cron job sessions auto-assigned to a dedicated "Cron Jobs" project — sessions originating from the cron scheduler now appear in their own project group in the sidebar instead of mixed in with regular chat sessions. Detection runs against either the session's
source_tag == 'cron'or acron_ID prefix, both for liveget_cli_sessions()calls and on_handle_session_import_cliimport. The project is created idempotently on first cron session viaensure_cron_project()(thread-safe, returns the sameproject_idon every subsequent call). Locale parity across all 8 supported languages (en, es, de, zh, zh-Hant, ru, pt, ko) for the newcron_jobs_projectkey. (api/models.py,api/routes.py,static/i18n.js,tests/test_1079_cron_session_project.py) @bergeouss — PR #1345, closes #1079
[v0.50.246] — 2026-04-30
Added
- Render fenced code blocks in user messages — typing a triple-backtick fenced code block in the composer now renders with proper code styling, syntax-aware diff/patch coloring, and the same
<pre><code>pipeline used for assistant responses. Plain user text outside fences stays escaped (no markdown bold/italic/links interpreted in user bubbles); only fenced blocks are upgraded. Includes specialized colored-line rendering fordiff/patchlanguages. (static/ui.js,tests/test_1325_user_fenced_code.py) @bergeouss — PR #1335, fixes #1325
Fixed
- Stop/Cancel during streaming no longer wipes the user's typed message (data-loss bug) — When a user clicked Stop while the agent was streaming,
cancel_stream()clearedpending_user_messagebefore the streaming thread had merged the user turn intos.messages, persisting a session with neither the pending field nor a corresponding message. The user's typed text was permanently lost from the session JSON, not just the in-memory client copy. Nowcancel_stream()synthesizes a user turn intos.messagesfrompending_user_message(with attachments preserved) when the most recent user message isn't already that turn — guards against double-append by content-matching against the last user message. (api/streaming.py,tests/test_issue1298_cancel_and_activity.py) — fixes #1298 (issue 2) - Activity panel no longer auto-collapses when new tool/thinking events arrive — Both
ensureActivityGroup()(which re-creates the group withtool-call-group-collapsedon every destroy/recreate) andfinalizeThinkingCard()(which force-adds the collapsed class on every tool boundary) ignored the user's manual expand. Tracks the user's last explicit toggle on the live activity group in a per-turn singleton (_liveActivityUserExpanded), restored on re-create and respected by the finalize path. Cleared between turns byclearLiveToolCards(). (static/ui.js,tests/test_issue1298_cancel_and_activity.py) — fixes #1298 (issue 1) - Stale Mermaid render errors no longer leak into every chat — Mermaid's render-failure path leaves a temporary
<div id="d<id>">body-level node containing a "Syntax error in text" SVG. The previous code never removed it, so once any Mermaid block failed (or got mis-detected as Mermaid), every subsequent tab kept the syntax-error SVG visible regardless of content. Also tightens Mermaid detection so line-numbered tool output (123|line) and code blocks that don't start with a recognized Mermaid keyword are no longer mis-parsed as Mermaid; failed blocks are marked so a later render pass can't retry them. (static/ui.js,tests/test_issue347.py) @dso2ng — PR #1337 - Static asset cache busts automatically on every release —
<script src="static/ui.js">and friends were cached indefinitely by browsers and the service worker, so a new release with bug fixes could be invisible to a user until they hard-refreshed. Nowindex.htmlandsw.jsregistration both inject the currentWEBUI_VERSIONgit tag as a?v=query string, URL-encoded server-side so unusual git tag formats can't break the JS. The service worker also no longer intercepts requests for itself, ensuring the browser always fetches the freshly-versionedsw.jsdirectly from the network. (api/routes.py,static/index.html,static/sw.js,tests/test_pwa_manifest_sw.py) @dso2ng — PR #1337 - Context window indicator persists across page reloads (#1318 — fully fixed) —
Session.__init__now acceptscontext_length,threshold_tokens, andlast_prompt_tokens;save()persists them via theMETADATA_FIELDSround-trip andcompact()exposes them on the GET/api/sessionresponse. Critically,api/streaming.pynow writes the values fromagent.context_compressoronto the session inside the post-merge per-turn save block, so the values land on disk and survive a page reload. Without that writer, the model fields would have been pure scaffolding — present but never populated. The frontend context-ring indicator was previously losing its percentage on every session load because nothing was writing these fields to disk; that data flow is now end-to-end. (api/models.py,api/routes.py,api/streaming.py,tests/test_pr1341_context_window_persistence.py) @fxd-jason — PR #1341 (focused split from the held PR #1318) + writer added during pre-release review fallback_providerslist config no longer crashes streaming —api/streaming.py:1701previously read_cfg.get('fallback_model')and called.get('model', '')on the result. When users hadfallback_providers: [{...}, {...}]in their config (the chained-fallback form documented in CHANGELOG since v0.50.151), the streaming path crashed withAttributeError: 'list' object has no attribute 'get'. Now consults bothfallback_model(single dict, legacy) andfallback_providers(list, new), picks the first valid entry from the list, and defends both paths withisinstancechecks. (api/streaming.py,tests/test_pr1339_fallback_providers_list.py) @jimdawdy-hub — PR #1339
Changed
- CI test stability —
test_checkpoint_fires_on_activity_counter_incrementwas rewritten to use deterministicthreading.Eventsynchronization instead oftime.sleepwindows. The old version polled at 0.1s intervals and slept 0.15s/0.25s/0.25s between activity increments, which intermittently failed under CI scheduling jitter (one save instead of two). The new version waits up to 3.0s for the checkpoint thread to actually advance after each increment, with no sensitivity to scheduler timing. (tests/test_issue765_streaming_persistence.py)
Documentation
CONTRIBUTORS.md— new file with stack-ranked credit roll for all 66 contributors, generated fromgit log+gh api+ CHANGELOG attribution lines. Top contributors table at top ofREADME.md.- README, ROADMAP, ARCHITECTURE, SPRINTS, TESTING — refreshed to v0.50.246 / 3309 tests; removed stale
v0.50.36-local.1header from ARCHITECTURE.md; updated SPRINTS.md "Where we are now" to reflect ~95% Claude parity. (PR #1340 — already merged, brought forward in this release.)
[v0.50.245] — 2026-04-30
Fixed
- Cron
Run Nowno longer crashes withNameError: run_job is not defined—_run_cron_tracked()runs in a worker thread but referencedrun_jobonly via a local import inside_handle_cron_run()(a different scope). Manual cron execution now importsrun_jobinside the worker function itself, and the redundant import is removed from the route handler. Adds AST-based regression tests so future refactors can't silently re-break the worker-thread scope. (api/routes.py,tests/test_cron_run_job_import.py) @fxd-jason — PR #1317, fixes #1310 (also addressed by #1312/#1329, closed as duplicates) - Context auto-compressed banner no longer repeats every turn after first compression — the fallback compression detector compared cumulative
compression_count > 0, which stays true forever after the first compression event, so the banner re-fired on every subsequent turn. Now snapshotscompression_countbeforerun_conversation()and compares against the snapshot, so the banner only fires when compression actually happens during the current turn. (api/streaming.py) @qxxaa — PR #1316 - Mobile workspace panel sliver and composer footer overlap (#1300) — saved desktop workspace-panel widths leaked into compact/mobile layouts, leaving a thin right-edge workspace sliver and a stale shadow on closed panels. Composer footer controls also showed icon/text overlap at intermediate widths when sidebars constrained the chat column. The fix clears/reapplies the rightpanel inline width only when the viewport is outside the compact/mobile breakpoint, hides the closed off-canvas shadow, and adds staged composer-footer container queries so workspace/model labels collapse before they overlap. (
static/boot.js,static/style.css,tests/test_mobile_layout.py) @franksong2702 — PR #1328, fixes #1300 - Streaming sessions stay visible in the sidebar during their first turn — the
Untitled + 0-messagesfilter (#1171) hid sessions during the initial streaming turn because PR #1184 deferred the firstsave()until the first message landed. Navigating away during a long first turn made the active conversation disappear from the sidebar (looked like data loss to users). The filter now exempts sessions withactive_stream_id(index path) or withactive_stream_idpluspending_user_message(full-scan path), so in-progress conversations remain visible while truly empty scratch sessions are still hidden. 7 new regression tests cover both filter paths and edge cases. (api/models.py,tests/test_streaming_session_sidebar.py) @franksong2702 — PR #1330, fixes #1327 - Default model rehydration when providers share slash-qualified IDs (#1313) —
_deduplicate_model_ids()only de-duplicated bare IDs and skipped slash-qualified IDs entirely, so when two providers exposed the samevendor/model(e.g. two custom providers both listinggoogle/gemma-4-27b), the dropdown contained duplicate<option value>entries and reopening Preferences could snap the saved default model back to the first provider that shared the ID. The dedupe now covers slash IDs as well, the configured-model badge lookup respects the matching provider, and the frontend matcher prefers the configuredactive_providerwhen rehydrating a saved default model. (api/config.py,static/panels.js,static/ui.js,tests/test_issue1228_model_picker_duplicate_ids.py,tests/test_model_picker_badges.py) @hacker2005 — PR #1326, fixes #1313 - Configured fallback models always appear in the dropdown — the model picker only rendered configured models that already existed in the loaded
<select>options, so when/api/modelsexposed a fallback chain inconfigured_model_badgesbut the underlying provider's catalog (especiallylocal-ollama) was empty or partial, the Configured section showed an incomplete chain. The dropdown now synthesizes entries fromconfigured_model_badgesfor any configured model missing from the catalog, sorts them as primary → fallback 1 → fallback N, and renders them under a single "Configured" header above the per-provider groups. (static/ui.js,tests/test_model_picker_badges.py) @renatomott — PR #1322 - Duplicate header copy buttons on language-fenced code blocks — for code blocks with a language header, the copy button is appended to the sibling
.pre-header, not inside<pre>, but the existing duplicate guard only checked inside<pre>. Repeated post-render passes (cache replays, streaming updates) could append duplicate copy buttons in the header. The guard now also checks the header before creating a new button. (static/ui.js,tests/test_issue1096_copy_buttons.py) @dso2ng — PR #1324, fixes #1096 - zh-Hant locale labels — restore Traditional Chinese in tree/raw view and MCP server settings — a recent locale-merge accidentally left Russian strings in the zh-Hant block for tree-toggle labels, the parse-failed note, and Settings → System → MCP Servers. zh-TW users saw mixed Russian/Chinese UI text in those areas. The labels are now restored to Traditional Chinese, plus a regression test that asserts no Cyrillic characters can slip back into the zh-Hant block. (
static/i18n.js,tests/test_chinese_locale.py) @dso2ng — PR #1323 - Docker
HEALTHCHECKinstruction added — the Dockerfile was missing aHEALTHCHECK, sodocker pscouldn't show health, Docker Composedepends_on: condition: service_healthydidn't work, and orchestration tools (K8s, Swarm) couldn't use native health probes. Added a 30s-interval HEALTHCHECK that hits the existing/healthendpoint. (Dockerfile) @zichen0116 — PR #1332 .env.examplestate-dir default aligned withbootstrap.py—HERMES_WEBUI_STATE_DIRin.env.examplereferenced the obsolete~/.hermes/webui-mvppath whilebootstrap.pyanddocker-compose.ymlalready use~/.hermes/webui. Updated the example file so users following it land in the same state dir as the rest of the codebase. (.env.example) @zichen0116 — PR #1331
[v0.50.244] — 2026-04-30
Added
- Text-to-Speech playback for agent responses — Web Speech API powers a per-message 🔊 speaker button on every assistant message, plus an optional auto-read toggle that speaks each response when streaming finishes. Voice / rate / pitch controls are exposed in Settings → Preferences. All TTS preferences are stored in
localStorage(no server round-trip). Strips markdown, code blocks, andMEDIA:paths before speaking; pauses synthesis when the composer is focused. Opt-in — TTS is hidden by default until enabled in Settings. Locale coverage for en, ru, es, de, zh, zh-Hant, pt, ko. (static/ui.js,static/panels.js,static/messages.js,static/boot.js,static/style.css,static/index.html,static/i18n.js) @fecolinhares — PR #1303, closes #499 - Sienna skin (warm clay & sand earth palette) — opt-in alongside the existing default/Ares/Mono/Slate/Poseidon/Sisyphus/Charizard set. Full palette rewrite (light + dark variants) with clay accent (
#D97757) on a soft sand background; neutral tool-card chrome, accent-tinted active session indicator. No forced migration, default skin staysdefault(gold); users opt in via Settings → Skin. (static/style.css,static/boot.js,static/index.html,tests/test_sienna_skin.py) — PR #1307 (salvaged from #1084)
Fixed
- Cmd/Ctrl+K new chat works while a conversation is busy — drops the
!S.busyguard so users can start a new conversation mid-stream. The in-flight stream keeps running on its own session; the user just gets a fresh blank one. (static/boot.js,tests/test_mobile_layout.py) — PR #1306 (salvaged from #1084) - Stale saved session 404 cleanup + structured
api()errors — when a saved session ID returns 404,loadSession()now clearslocalStorage.hermes-webui-sessionand rethrows so boot can fall through to the empty state instead of sticking on "Session not available in web UI." across reloads. The cleanup is gated on!currentSidso click-into-404 doesn't wipe state. The globalapi()helper now attaches.status/.statusText/.bodyto thrown errors, so callers can branch on HTTP status without re-parsing the message string. (static/sessions.js,static/workspace.js,tests/test_stale_empty_session_restore.py,tests/test_1038_pwa_auth_redirect.py) — PR #1304 (salvaged from #1084)
[v0.50.243] — 2026-04-30
Fixed
- Chip composer model badge — removed the
PRIMARYprojection — The chip-projected configured-model badge added in #1287 was eating ≈30% of chip width (235px → 164px) without adding signal, since the model name is already right next to it. The dropdown rows still showPrimary/Fallback Nbadges where they actually help distinguish picker entries. Backend_build_configured_model_badges()and theconfigured_model_badgespayload on/api/modelsare preserved for the dropdown to consume. (static/index.html,static/ui.js,static/style.css,tests/test_model_picker_badges.py) — PR #1301 - Claude Opus 4.7 label rendering — Adds explicit label entries for
anthropic/claude-opus-4.7,claude-opus-4.7, andclaude-opus-4-7so the picker no longer renders "Claude Opus 4 7" (missing dot) when the dashed-form model ID falls through to the generic dash-replace formatter. (api/config.py) — PR #1301 - Cron output snippet preserves the
## Responsesection —/api/crons/outputreturnedtxt[:8000]which could drop the useful response section when a large skill dump appeared in the prompt context. Now: if## Responseexists, preserves a short header plus the response section; if no marker exists, returns the file tail rather than the head. (api/routes.py,tests/test_sprint10.py) @franksong2702 — PR #1297, fixes #1295
[v0.50.242] — 2026-04-30
Reverted
- Assistant message serif font (Georgia) — Reverted the global
.assistant-turn .msg-body { font-family: var(--font-assistant) }rule introduced in v0.50.240 (PR #1282). Assistant responses now render in the same system sans-serif stack as the rest of the UI, matching pre-v0.50.240 behavior. The--font-assistantCSS token has been removed. (static/style.css) - Calm Console theme — Removed the
data-theme="calm"palette and its associated picker entry, theme-apply branch, and server-side enum value. The theme was the only consumer of the assistant serif rule and was not pulling its weight as a third theme option. Users who selectedcalmwill fall back to the default theme on next page load (the server settings validator now rejectscalmand resets todark). (static/style.css,static/boot.js,static/index.html,api/config.py,tests/test_ui_tool_call_cleanup.py)
[v0.50.241] — 2026-04-30
Added
- Inline audio/video media editor with playback speed controls — MEDIA: tokens and file attachments for audio/video now render as a full media editor card with 0.5×–2× speed buttons, rate stored in
localStorage, and aMutationObserverthat auto-applies the saved rate to any newly rendered player. Composer tray shows compact inline players for attached audio/video files. (static/ui.js,static/boot.js,static/style.css,static/workspace.js) @nickgiulioni1 — PR #1290 (rebased #1232) - HTTP byte-range streaming for audio/video —
/api/media?inline=1now handlesRange:request headers and returns HTTP 206 Partial Content, enabling seekable playback of large audio and video files. Path access is guarded by the existingwithin_allowedcheck before_serve_file_bytesis called. (api/routes.py) @nickgiulioni1 — PR #1290 - PDF and media previews in workspace file browser — PDF, audio, and video files in the workspace panel now render inline instead of forcing download. (
static/workspace.js) @nickgiulioni1 — PR #1290 - Configured model badges — models that appear in
config.yamlas primary or fallback are now labeled withPrimary/Fallback Nbadges in the model picker, and the badge is carried through to the selected-model chip in the composer header. Badge data persists through the on-disk model cache so it survives server restarts. (api/config.py,static/ui.js,static/index.html,static/style.css) @renatomott — PR #1287 - Appearance autosave — Theme, skin, and font-size pickers in Settings › Appearance now save immediately with inline
Saving…/Saved/Failed — Retrystatus. These controls no longer set the global unsaved-changes dirty state, so closing Settings after tweaking appearance never prompts to discard. Font size is also now persisted toconfig.yamland restored on page load. (static/boot.js,static/panels.js,api/config.py,static/i18n.js) @franksong2702 — PR #1289, refs #1003 - Agent session source normalization — Imported Hermes Agent sessions now expose
raw_source,session_source, andsource_labelmetadata through both/api/sessionsand gateway watcher SSE snapshots. Existingsource_tag/is_cli_sessioncompatibility fields remain unchanged so sidebar display is preserved; this lays the groundwork for source-aware sidebar policies. (api/agent_sessions.py,api/gateway_watcher.py,api/models.py) @franksong2702 — PR #1294, refs #1013
[v0.50.240] — 2026-04-30
Added
- Compact tool activity mode (
simplified_tool_calling) — new setting (default on) groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn instead of showing every step as a separate visible row. Keeps long agent runs readable while keeping full transparency a click away. Also adds a Calm Console theme (calm) with earth/slate palette and serif assistant prose. (api/config.py,static/ui.js,static/panels.js,static/boot.js,static/style.css,DESIGN.md) @Michaelyklam — PR #1282 - PDF first-page preview —
MEDIA:links to.pdffiles now lazy-load a canvas preview of page 1 via PDF.js CDN (4 MB cap, download fallback). HTML sandbox iframe —.html/.htmfiles render inline in a sandboxed<iframe srcdoc>withallow-scriptsonly (256 KB cap). 10 new i18n keys × 7 locales. (static/ui.js,static/style.css,static/i18n.js) @bergeouss — PR #1280, closes #480 #482 - Inline Excalidraw diagram preview —
.excalidrawfiles render as a pure-SVG diagram inline (no external deps; supports rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). (static/ui.js,static/i18n.js) @bergeouss — PR #1279, closes #479 - Inline CSV table rendering — fenced
csvblocks andMEDIA:CSV files render as scrollable HTML tables with auto-separator detection (comma/semicolon/tab) and quote stripping. (static/ui.js,static/i18n.js) @bergeouss — PR #1277, closes #485 - Inline SVG, audio, and video rendering — SVG files render as
<img>, audio files as<audio controls>, video files as<video controls>. File attachment previews in the composer also get inline display. (static/ui.js,static/i18n.js) @bergeouss — PR #1276, closes #481 - Batch session select mode — a new select-mode toggle in the session list lets users choose multiple sessions and perform bulk Archive, Delete, or Move to Project actions. 11 new i18n keys × 7 locales. (
static/sessions.js,static/i18n.js) @bergeouss — PR #1275, closes #568 - Collapsible skill category headers — clicking a category header in the Skills panel collapses or expands its contents without a full re-render; collapsed state persists across filter cycles. (
static/panels.js,static/style.css) @bergeouss — PR #1281 providers.only_configuredsetting — opt-in config flag that restricts the model picker to providers explicitly configured inconfig.yaml. Default false (existing behavior unchanged). (api/config.py) @KingBoyAndGirl — PR #1268- OpenCode Go model catalog updated — adds 7 new models: Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. (
api/config.py) @nesquena-hermes — PR #1284, closes #1269
Fixed
- Profile
TERMINAL_CWDno longer causes TypeError —_build_agent_thread_env()merges all thread-local env keys into one dict before passing to_set_thread_env(), so aterminal.cwdentry inconfig.yamlcan no longer conflict with the per-session workspace path. (api/streaming.py) @hi-friday — PR #1266 - Service worker no longer caches subpath API routes — the SW cache-bypass regex now matches
/api/*under any mount prefix (e.g./hermes/api/*), fixing stale session lists when running behind a subpath reverse proxy. (static/sw.js) @Michaelyklam — PR #1278 - SSE client disconnect leaks resolved —
TimeoutErrorandOSErrorare now treated as normal disconnects;QuietHTTPServersuppresses them silently. Server backlog raised to 64 and handler threads daemonized. Session list renders before saved-session restore so a client-side boot error can no longer leave the sidebar empty. (api/routes.py,server.py,static/boot.js,static/sessions.js) @KayZz69 — PR #1267 - i18n: Korean and Chinese MCP keys corrected, missing locale keys added — 23 Korean MCP strings that had English text replaced with correct Korean; 23 Chinese (zh) strings that had Spanish text replaced with Chinese; 41 missing keys added to zh-Hant; 229 missing keys added to de. (
static/i18n.js) @bergeouss — PR #1274, closes #1273
[v0.50.239] — 2026-04-29
Fixed
- h4–h6 markdown headings now render correctly —
renderMd()heading replacers are now applied longest-first (######before#####before####before###…), fixing the regression where h4–h6 headings were emitted as literal#text. CSS adds correct font sizes andcolor:var(--muted)for h6. (static/ui.js,static/style.css) @the-own-lab — Closes #1258
[v0.50.238] — 2026-04-29
Added
- Portuguese (pt-BR) locale — full i18n coverage for
ptlocale across all UI panels (chat, sessions, commands, settings, cron, workspace, profiles, skills). (static/i18n.js) @fecolinhares — Closes #1242
Fixed
- Compaction preserves visible prompts — WebUI now keeps model-facing compacted context separately from the visible transcript, so automatic context compaction no longer replaces earlier user prompts in the scrollback. (
api/models.py,api/streaming.py,api/routes.py) @franksong2702 — Closes #1217 - MiniMax China provider visible in model picker —
MINIMAX_CN_API_KEYnow maps to theminimax-cnprovider instead of being collapsed into globalminimax; WebUI includes a static MiniMax (China) model catalog/display label soproviders.minimax-cn: {}can render a populated picker group. (api/config.py,api/providers.py) @franksong2702 — Closes #1236 - Terminal resize and collapse controls restored — restores the collapse/expand dock markup and controlled height CSS variable lost during the v0.50.237 batch integration, and reinstates regression coverage for terminal resizing and collapsed-state behavior. (
static/index.html,static/style.css,static/terminal.js,tests/test_embedded_workspace_terminal.py) @franksong2702 - GET
/api/mcp/serversreturned 404 — the route was placed afterhandle_get()'sreturn Falsesentinel; moved inside the function before the 404 return. (api/routes.py) @KingBoyAndGirl — Closes #1251 - MCP Servers UI showed Korean labels in English locale — 26 i18n keys in the English locale block (
en) were accidentally set to Korean translations from PR #538; replaced with correct English text. (static/i18n.js) @bergeouss — Closes #1254 - Live model fetch for custom providers — when
provider=custom, the live-model endpoint now readsmodel.base_urlfrom config and fetches/v1/modelsfrom the user's custom OpenAI-compat endpoint. (api/routes.py) @KingBoyAndGirl — Closes #1247 - Profile terminal env applied in WebUI sessions —
api/terminal.pynow loads the active profile's env overlay before spawning the PTY shell. (api/terminal.py) @dso2ng — Closes #1245 - SSRF: custom provider
base_urltrusted —_is_ssrf_blocked()now whitelists user-configured custom provider base URLs, preventing false SSRF blocks for legitimate private-network endpoints. (api/routes.py) @KingBoyAndGirl — Closes #1244 - SESSION_AGENT_CACHE LRU limit — unbounded dict replaced with
functools.lru_cache(cap 256); prevents memory growth in long-running servers with many sessions. (api/config.py) @happy5318 — Closes #1250 - Native image uploads as multimodal inputs — image attachments uploaded to the workspace are now forwarded to vision-capable models as OpenAI-style
image_urldata-URL parts instead of text paths. Magic-byte validation rejects non-image files; workspace path validation uses.resolve()+.relative_to()(symlink-safe); 20 MiB per-image cap. (api/streaming.py,api/routes.py,api/upload.py,static/ui.js) @yzp12138 — Closes #1229 @provider:modelhint preserved when hint matches active provider —_resolve_compatible_session_model()was stripping the@provider:prefix when the hint matched the active provider, causing duplicate model IDs from different providers to snap back to the wrong provider on the next render. The hint is now returned unchanged soresolve_model_provider()can route correctly. (api/routes.py) @nesquena-hermes — Closes #1253
[v0.50.237] — 2026-04-29
Added
- Embedded workspace terminal —
/terminalslash command opens a compact PTY-backed terminal card anchored above the composer. Supports collapse/expand/dock, resize, restart, clear, copy output, and per-session workspace binding. Env vars are allowlisted so server credentials are not exposed in the shell. (api/terminal.py,static/terminal.js,static/commands.js,static/i18n.js) @franksong2702 — Closes #1099 - Collapsible JSON/YAML tree viewer — fenced
json/yamlcode blocks get a Tree/Raw toggle. Tree view renders collapsible, type-colored nodes (keys blue, strings green, numbers blue, booleans amber, nulls muted); auto-collapsed beyond depth 2. Default is Tree for blocks with 10+ lines. YAML parsing uses js-yaml loaded lazily via CDN with SRI. (static/ui.js,static/style.css,static/i18n.js) @bergeouss — Closes #484 - Inline diff/patch viewer — fenced
diff/patchblocks render with colored+/-/@@lines.MEDIA:links to.patch/.difffiles fetch and render inline with a 50 KB cap. (static/ui.js,static/style.css,static/i18n.js) @bergeouss — Closes #483 - MCP server management UI — Settings › System panel now lists MCP servers with transport badges, and provides add/edit/delete forms. Backend:
GET/PUT/DELETE /api/mcp/serverswith masked secrets (round-trip safe). i18n coverage across 7 locales. (api/routes.py,static/panels.js,static/i18n.js) @bergeouss — Closes #538 - Cron run status tracking and watch mode — after "Run Now", the cron detail view shows a live spinner, running label, and elapsed timer (polls every 3 s). Auto-starts watch when opening an already-running job.
GET /api/crons/statusendpoint. Double-run guard prevents concurrent execution of the same job. (api/routes.py,static/panels.js,static/style.css,static/i18n.js) @bergeouss — Closes #526 - Duplicate cron job — Duplicate button in cron detail header pre-fills the create form with the existing job settings, appends "(copy)" to the name (auto-increments on collision), and saves as paused. (
static/panels.js,static/i18n.js) @bergeouss — Closes #528 - Upload and extract zip/tar archives into workspace — zip, tar.gz, tgz, tar.bz2, tar.xz files are auto-extracted into a named subfolder. Zip-slip/tar-slip protection via
is_relative_to(); zip-bomb protection via 200 MB cumulative extraction limit on actual bytes. (api/upload.py,api/routes.py,static/ui.js,static/i18n.js) @bergeouss — Closes #525 - Workspace directory CRUD — right-click context menu on workspace file/dir rows adds Rename and Delete for directories.
shutil.rmtree()guarded bysafe_resolve()path validation. Expanded-dir cache updated on rename/delete. (api/routes.py,static/ui.js,static/i18n.js) @bergeouss — Closes #1104 - Workspace drag-to-reorder — drag handles on workspace rows;
PUT /api/workspaces/reorderpersists new order. Reorder is confirmed (not optimistic); unmentioned workspaces are appended. (api/routes.py,static/panels.js,static/i18n.js) @bergeouss — Closes #492 - Compress affordance in context ring — context usage tooltip shows a pre-fill button for
/compressat ≥50% usage (hint style) and ≥75% (urgent red style). No auto-fire. (static/ui.js,static/index.html,static/style.css,static/i18n.js) @bergeouss — Closes #524 - DeepSeek V4, Z.AI/GLM provider, model tags — adds
deepseek-v4-flashanddeepseek-v4-pro; keeps V3/R1 as(legacy)until 2026-07-24. Adds Z.AI/GLM provider (glm-5.1,glm-5,glm-5-turbo,glm-4.7,glm-4.5,glm-4.5-flash). Provider cards show model names; custom providers fromconfig.yamlare scanned. (api/config.py,api/onboarding.py,static/panels.js) @jasonjcwu — Closes #1213 - NVIDIA NIM provider — adds
nvidiato the provider catalog with display name, aliases, model list, API key mapping, OpenAI-compat endpoint (https://integrate.api.nvidia.com/v1), and onboarding entry. (api/config.py,api/providers.py,api/routes.py,api/onboarding.py) @JinYue-GitHub — Closes #1220
Fixed
- Background session unread dots — sidebar unread dots no longer depend solely on
message_countdelta. Explicit completion markers, polling fallback, INFLIGHT/S.busy sidebar spinner tracking, localStorage-persisted observed-running state, and auto-compression session-id rotation all handled. (static/sessions.js,static/messages.js) @franksong2702 — Closes #856 - Clarify draft preserved on timeout — unsent clarify text is moved to the main composer when the clarify card expires or is dismissed. Countdown indicator shows remaining time; urgent styling for final seconds. (
api/clarify.py,static/messages.js,static/style.css,static/index.html) @sixianli — Closes #1216 - Mobile busy-input composer button — unified send/stop/queue/interrupt/steer action button so mobile users (tap-only) can queue, interrupt, or steer while the agent is busy. Dynamic icon/label/color. Removes separate cancel button path. (
static/ui.js,static/messages.js,static/sessions.js,static/boot.js,static/i18n.js) @starship-s — Closes #1215 - Session sidecar repair hardened — centralized
_apply_core_sync_or_error_marker()helper; non-blocking lock acquire to avoid deadlock in cache-miss repair path; streaming-finally and cache-miss repair paths share logic. (api/models.py,api/streaming.py) @starship-s — Closes #1230 - Scroll position preserved when loading older messages —
_loadOlderMessagesnow uses#messages(the actual scrollable container) instead of#msgInner; resets_scrollPinnedafter restoring position soscrollToBottomdoes not re-fire. (static/sessions.js) @jasonjcwu — Closes #1219 - Model picker duplicate IDs across providers —
_deduplicate_model_ids()detects bare model IDs appearing in 2+ groups and prefixes collisions with@provider_id:(deterministic alphabetical tie-break). Frontendnorm()regex strips@provider:prefixes for fuzzy matching. (api/config.py,static/ui.js) @bergeouss — Closes #1228 /api/modelscache metadata preserved — disk and TTL cache now includeactive_provideranddefault_modelalongsidegroups. Legacygroups-only cache files are rejected and rebuilt. (api/config.py) @franksong2702 — Closes #1239- Clarify model scope copy — composer model-selector dropdown shows "Applies to this conversation from your next message." sticky note; preferences Default Model shows "Used for new conversations." helper text. (
static/ui.js,static/boot.js,static/i18n.js) @franksong2702 — Closes #1241 - Workspace panel stale after profile switch —
loadDir('.')called inswitchToProfile()Case B so the file tree refreshes to the new profile. (static/panels.js) @bergeouss — Closes #1214 - OAuth providers show as unconfigured — expanded
_OAUTH_PROVIDERSset; liveget_auth_status()fallback for unknown OAuth providers (gated by pid regex validation and closedkey_sourceallowlist). (api/providers.py) @bergeouss — Closes #1212 - MCP delete button XSS — replaced
onclick="...esc(s.name)..."inline handler withdata-mcp-nameattribute + event delegation (absorb fix). (static/panels.js) - Zip/tar-slip path traversal — replaced
startswithprefix check withis_relative_to(); zip-bomb check now tracks actual extracted bytes instead of trustingmember.file_size(absorb fix). (api/upload.py) - Terminal PTY env secret leak — terminal shell env uses a safe allowlist instead of
os.environ.copy(), preventing API keys from being visible inside the terminal (absorb fix). (api/terminal.py) - Terminal resize handle wired —
terminalResizeHandleelement added toindex.html;_terminalEls()returnshandle(absorb fix). (static/index.html,static/terminal.js)
[v0.50.235] — 2026-04-28
Fixed
- Profile switch shows correct workspace, model, and chip label immediately — Three separate
bugs caused profile switching to appear broken: (1)
switch_profile(process_wide=False)returned the old profile's workspace becauseget_last_workspace()routed through thread-local profile context (still pointing at the old profile during the switch); (2) the model dropdown showed stale results because the in-memory models cache wasn't invalidated; (3) the profile chip stayed on the old name becausesyncTopbar()returned early without updating it when no session was active. (api/profiles.py,api/routes.py,static/ui.js,tests/test_profile_switch_1200.py) (PR #1203) - Flaky test stabilisation —
test_server_now_ms_compensates_positive_skewused exact-ms equality across twoDate.now()calls; fixed with midpoint averaging and ±5 ms tolerance. (tests/test_issue1144_session_time_sync.py)
[v0.50.234] — 2026-04-28
Fixed
- XSS hardening in markdown renderer — HTML tags in LLM output were filtered by
tag name only, allowing event handlers like
onerrorandonclickto pass through on<img>and other elements. The sanitizer now strips all attributes except a per-tag allowlist and blocksjavascript:,data:, andvbscript:URL schemes. Incomplete raw tags (<img src=x onerror=...//with no closing>) are escaped before paragraph wrapping so they cannot be completed by the renderer's own output. (static/ui.js) - Delegated image lightbox — inline
onclickhandlers on<img class="msg-media-img">replaced with a single delegateddocument.addEventListener('click'), eliminating the last source of inline event handler HTML in rendered output. (static/ui.js) - Workspace trust for macOS symlink paths —
/etcon macOS resolves to/private/etcwhich previously bypassed the blocked-roots check. The new_is_blocked_workspace_pathhelper compares both the raw and resolved path. Also adds/Systemand/Libraryto the blocked roots. (api/workspace.py) - Legacy
/api/chatworkspace validation — the synchronous chat fallback endpoint was not routing throughresolve_trusted_workspace(), allowing arbitrary paths to be set as workspace. (api/routes.py) linked_filestype guard — skill view responses with anullor non-dictlinked_filesfield no longer crash the skills API. (api/routes.py) (by @bschmidy10, PR #1201)
[v0.50.233] — 2026-04-28
Fixed
- Workspace trust for /var/home paths — workspaces under
/var/home(used by systemd-homed on Fedora/RHEL) were incorrectly blocked because_is_blocked_system_pathflagged/varas a system root. The home-directory trust check in bothresolve_trusted_workspaceandvalidate_workspace_to_addnow correctly trusts any path underPath.home()regardless of where the home directory lives on disk. (api/workspace.py) (by @frap129, PR #1199)
v0.50.236 — 2026-04-28
Bug fixes
- fix(providers): OAuth provider cards now show "Configured" badge when token is via config.yaml —
get_providers()was unconditionally overwritinghas_key=True(from_provider_has_key()) withhas_key=Falsewhenget_auth_status()returnedlogged_in=False, discarding valid working tokens inconfig.yaml. Also: the Settings panel was filtering out all OAuth providers entirely (filter(p=>p.configurable)— OAuth providers always haveconfigurable=False). Fixes surfaced the actionable auth error string (e.g. "refresh token consumed by Codex CLI") in the provider card body. (#1202)
Improvements
- ux(profiles): profile chip shows spinner and name immediately when switching — The profile chip now gives instant visual feedback on click: the new profile name appears immediately (optimistic update), a small spinner appears on the icon, and the button is disabled to prevent double-clicks. All are cleaned up in a
finallyblock so the UI never gets stuck in a loading state. On error, the chip reverts to the previous name. Additionally, the model dropdown fetch and workspace list fetch are now parallelized (Promise.all) instead of sequential, cutting switch time roughly in half.
Features
- feat: YOLO mode toggle —
/yoloslash command and "Skip all this session" button on approval cards. Enables session-scoped approval bypass. ⚡ amber pill in composer footer shows YOLO is active. (by @bergeouss, PR #1152, closes #467)
v0.50.225 — 2026-04-27
Added
- Cron job attention state — recurring jobs that land in a broken state (
enabled=false,state=completed,next_run_at=null) now show an amber "needs attention" badge instead of the misleading "off" badge. Detail panel shows a warning banner with Resume & recalculate, Run once, and Copy diagnostics actions. Korean locale translated. (static/panels.js,static/style.css,static/i18n.js) [#1133 @franksong2702]
Fixed
- Image attachments: composer tray thumbnails — pasted/dragged images now show as 56×56 thumbnail chips in the composer instead of paperclip pills. Blob URL revoked on remove. (
static/ui.js,static/style.css) [#1135] - Image attachments: chat history inline — uploaded images in sent messages now load correctly via
api/file/raw?session_id=SID&path=FILENAMEinstead of the brokenapi/media?path=FILENAMEpath. Click any image to open a lightbox overlay (dark backdrop, 90vw/90vh, × or Escape to close). (static/ui.js,static/style.css) [#1135] Closes #1095 - pytest state isolation —
conftest.pynow uses direct assignment forHERMES_WEBUI_STATE_DIR/HERMES_HOME/HERMES_WEBUI_DEFAULT_WORKSPACEso tests importingapi.configin the pytest process cannot inherit the real~/.hermes/webuistate tree. (tests/conftest.py) [#1136 @franksong2702]
v0.50.223 — 2026-04-26
Added
- Drag & drop workspace files into composer — files and folders in the workspace file tree are now draggable; dropping them into the chat composer inserts an
@pathreference at the cursor with smart spacing. OS file drag-and-drop (attach files) still works as before. (static/ui.js,static/panels.js) [#1123 @bergeouss] Closes #1097 - Composer placeholder reflects active profile — when a named profile is active (not
default), the composer placeholder and title bar show the profile name (capitalised) instead of the globalbot_name; falls back tobot_name/Hermes for the default profile. (static/boot.js,static/panels.js) [#1122 @bergeouss] Closes #1116
Fixed
- Copy buttons — clipboard-write Permissions-Policy — added
clipboard-write=(self)to thePermissions-Policyheader so Firefox allowsnavigator.clipboard.writeText(). Extracted_fallbackCopy()with explicitfocus()beforeselect()and correct visible-but-hidden positioning (no more-9999pxoffscreen failure). (api/helpers.py,static/ui.js) [#1125 @bergeouss] Closes #1096 - Model picker shows all configured providers —
XAI_API_KEYandMISTRAL_API_KEYenv vars now map tox-aiandmistralairespectively. Providers configured inconfig.yamlunderproviders:are also detected and shown in the model picker. (api/config.py) [#1126 @bergeouss] Partially closes #604 - api() retries on stale keep-alive after idle — after a long idle period,
fetch()throws aTypeErrorwhen the TCP connection has been dropped by a NAT or proxy timeout.api()inworkspace.jsnow retries up to 3 times onTypeErroronly; 4xx/5xx HTTP errors and 401 redirects are not retried. (static/workspace.js) [#1121 @bergeouss] Closes #1118 - Google Fonts allowed in CSP — Mermaid themes inject
@import url(fonts.googleapis.com)at render time; the CSPstyle-srcandfont-srcdirectives now includefonts.googleapis.comandfonts.gstatic.com. (api/helpers.py) [#1121 @bergeouss] Closes #1112
v0.50.221 — 2026-04-26
Fixed
- Custom providers model dropdown — models dict keys in
custom_providers[].modelsnow all appear in the dropdown; previously only the singularmodelfield was read. (api/config.py) [#1111 @bergeouss] Closes #1106 - Custom providers SSRF false positive — hostnames from user-configured
custom_providers[].base_urlare now trusted through the SSRF check; local inference servers (llama.cpp, vLLM, TabbyAPI) no longer blocked. (api/config.py) [#1113 @bergeouss] Closes #1105 - Mobile/iPad session navigation — tap no longer fails on first touch; replaced hover-triggered layout-shift pattern with
onpointerup+ right/middle-click filter +touch-action:manipulation. Desktop hover padding restored via@media (hover:hover)so mouse users are unaffected. (static/sessions.js,static/style.css) [#1110 @sheng-di] - Pasted/dragged images render inline — image attachments now show as
<img>with click-to-fullscreen instead of a paperclip badge. Hoisted_IMAGE_EXTSto module scope (was causingReferenceErrorinrenderMessages); addedavifsupport. (static/ui.js) [#1109 @bergeouss] Closes #1095 - Copy buttons on HTTP —
_copyText()helper checksisSecureContextand falls back toexecCommand('copy')for plain-HTTP self-hosted installs. Silent failure inaddCopyButtonsfixed with error feedback. All 6 locales getcopy_failedkey. (static/ui.js,static/i18n.js) [#1107 @bergeouss] Closes #1096
v0.50.220 — 2026-04-26
Fixed
- Workspace panel collapse priority — as the right panel narrows, the git badge now disappears first (below 220px), the "Workspace" label second (below 160px), and the icon buttons survive the longest. Previously
.panel-headerusedjustify-content:space-betweenwith no flex-shrink ratios, compressing all three children simultaneously. Fix: declare.rightpanelas acontainer-type:inline-sizecontainer, replacespace-betweenwithgap:6px+flex-shrinkladder (icons=0, label=2, badge=3), and add@container rightpanelqueries. (static/style.css) [#1089] - Project color dot truncated/invisible on long titles — the colored project marker on session items was appended inside
.session-title(overflow:hidden;text-overflow:ellipsis), so long titles clipped the dot off entirely. Fix: move dot to a flex sibling in.session-title-rowbetween title and timestamp; move.session-timefromposition:absolutetomargin-left:autoin flex flow; reduce desktop rest padding-right from 86px to 8px (no longer reserving space for an absolute timestamp); mobile rest padding-right from 86px to 40px (same fix). (static/sessions.js,static/style.css) [#1089]
v0.50.219 — 2026-04-26
Fixed
- Project context menu transparent background — the right-click menu on project chips no longer bleeds the session list through it.
_showProjectContextMenuwas usingbackground: var(--panel), but--panelis not defined in this codebase — CSS fell back totransparent. Fix: usevar(--surface)(same opaque variable used by.session-action-menuand other floating popovers). (static/sessions.js) [#1086] - Project rename / create input auto-sizing — the rename and new-project input is no longer fixed at 100px. CSS changed to
min-width:40px; max-width:180px; width:auto. New_resizeProjectInput()helper measures the current value via a hidden span (font properties read fromgetComputedStyle) and updates the pixel width as the user types. Wired into both_startProjectRenameand_startProjectCreate. (static/sessions.js,static/style.css) [#1086]
v0.50.218 — 2026-04-26
Fixed
- Long URL / unbreakable string overflow — chat bubble boundaries no longer overflow when a message contains very long URLs, file paths, or base64 data.
overflow-wrap: anywhereadded to.msg-bodyand the user-bubble variant so continuous non-whitespace text wraps at the column edge instead of bleeding into adjacent layout areas. (static/style.css) Closes #1080 [#1081] - Project chip rename now works — double-clicking a project chip now reliably triggers the rename input. Root cause:
onclickwas callingrenderSessionListFromCache()which destroyed the chip DOM node beforeondblclickcould fire. Fixed with a 220ms_clickTimerdelay ononclick(same pattern used by session items), so a double-click cancels the single-click and invokes rename instead. (static/sessions.js) Closes #1078 [#1082] - Block-level constructs inside blockquotes — fenced code blocks, headings, horizontal rules, and ordered lists inside blockquotes now render correctly;
>-entity-encoded blockquotes from LLM output also render correctly (entity decode moved before the blockquote pre-pass). New pre-pass walks lines fence-aware, strips>prefix, recursively renders stripped content with the full pipeline, stashes rendered HTML with\x00Qtoken. (static/ui.js,static/style.css) [#1083]
Added
- Project color picker — right-clicking a project chip now shows a context menu with Rename, a row of color swatches, and Delete. Selecting a swatch updates the project color via
/api/projects/rename. (static/sessions.js) Closes #1078 [#1082]
v0.50.217 — 2026-04-26
Fixed
/queue,/interrupt,/steersend normally when agent is idle — typing any of these commands while nothing is running now sends the message as a normal turn instead of showing an error toast. Matches CLI behaviour: commands are mode-sensitive (queue/interrupt/steer when busy, plain send when idle)./stopwhen idle still shows the error — stopping nothing is always an error. (static/commands.js) [#1076]
v0.50.216 — 2026-04-26
Added
- Compression chain collapse —
get_importable_agent_sessions()now merges linear compression continuation chains into a single sidebar entry, showing the chain tip's activity time and model. The chain root's title and start time are preserved for display; the latest importable segment is used for import. Non-compression parent/child pairs are unchanged. (api/agent_sessions.py,tests/test_gateway_sync.py) Closes #1012 [#1012 @franksong2702] - Comprehensive markdown renderer improvements — blockquote grouping, strikethrough, task lists, CRLF normalisation, nested blockquotes, lists inside blockquotes. See details below. (
static/ui.js) [#1073]
Fixed
- Blockquote rendering — consecutive
> linesnow group into one<blockquote>, blank>continuation lines become<br>, bare>(no space) handled,>>nested blockquotes recurse correctly, lists inside blockquotes render<ul>, inline markdown (bold/italic/code) works inside quotes. (static/ui.js) [#1073] - Strikethrough —
~~text~~now renders as<del>text</del>in all contexts (paragraphs, blockquotes, list items). (static/ui.js) [#1073] - Task lists —
- [x]renders as ✅,- [ ]renders as ☐ in all unordered list contexts including inside blockquotes. (static/ui.js) [#1073] - CRLF line endings — Windows
\r\nline endings are normalised at the start ofrenderMd()so\rnever appears in rendered text. (static/ui.js) [#1073] - HTML/HTM preview in workspace —
.htmland.htmfiles now render correctly in the workspace preview iframe. Root cause:MIME_MAPwas missing these extensions; the fallbackapplication/octet-streamcaused browsers to refuse to render in the iframe. (api/config.py) [#1070] - Approval card obscured by queue flyout — the approval card's "Allow once / Allow session / Always allow / Deny" buttons are no longer hidden behind the queue flyout when both are visible simultaneously. (
static/style.css— one line:z-index:3on.approval-card.visible) [#1071] /steer,/interrupt,/queuenot working while agent is busy — typing these commands while the agent is running now executes them immediately instead of queuing the raw text. Root cause:send()returned early inside the busy block before reaching the slash-command dispatcher. Fix: intercept the three control commands at the top of the busy block. (static/messages.js) [#1072]- Reasoning chip always visible — the composer reasoning chip is now shown for all effort states. When effort is unset/default it shows a muted "Default" label; when explicitly set to
noneit shows "None". Previously both states hid the chip entirely, removing the affordance to inspect or change it. (static/ui.js,static/style.css) Closes #1068 [#1074 @franksong2702] - Steer settings copy updated — removed "falls back to interrupt" / "interrupt + send" language across all 6 locales; steer mode now correctly described as "mid-turn correction without interrupting". (
static/i18n.js,static/index.html) [#1072]
v0.50.215 — 2026-04-26
Added
- Real
/steercommand — wires/steer <text>through the agent's thread-safeagent.steer()method rather than falling back to interrupt. Steer text is stashed in_pending_steerand injected into the next tool-result boundary without interrupting the current run, giving the agent a mid-turn course correction. New/api/chat/steerPOST endpoint with five graceful fallback reasons (no_cached_agent,agent_lacks_steer,session_not_found,not_running,stream_dead) — any fallback transparently falls back to the existing interrupt+queue mechanism. (api/routes.py,api/streaming.py,static/commands.js,static/messages.js,static/i18n.js) Closes #720 follow-up [#1066 @nesquena] - Steer leftover delivery — if the agent finishes its turn before hitting a tool boundary, the stashed steer text is drained and emitted as a
pending_steer_leftoverSSE event; the frontend queues it as a next-turn message, mirroring the CLI's existing leftover path. (api/streaming.py,static/messages.js) [#1066]
Fixed
- Pending files preserved on steer→interrupt fallback — the busy-mode steer path in
send()now defersS.pendingFiles=[]until after_trySteer()returns, so staged file attachments are not lost when the steer endpoint falls back to interrupt+queue. (static/messages.js)
v0.50.214 — 2026-04-26
Added
- Busy input mode setting — new
Settings → Preferences → Busy input modedropdown with three options:Queue(default, preserves existing behavior),Interrupt(cancel the current stream and re-send immediately),Steer(placeholder for future mid-stream injection, currently falls back to Interrupt with a toast). (api/config.py,static/messages.js,static/boot.js,static/panels.js,static/index.html,static/i18n.js) Closes #720 [#1062 @bergeouss] /queue,/interrupt,/steerslash commands — per-message overrides for the busy mode regardless of the current setting./queue <msg>enqueues explicitly;/interrupt <msg>cancels the current turn and re-sends;/steer <msg>same today with a future-upgrade toast. (static/commands.js) [#1062 @bergeouss]
Fixed
/queuecommand double-bubble — missingnoEcho:truecaused the raw slash text to be echoed as a user bubble, then the drained message appeared again as a second bubble. (static/commands.js)- Staged-file duplication via slash commands —
cmdQueue,cmdInterrupt, andcmdSteercapturedS.pendingFilesbut never cleared the tray, so staged files were re-attached on the next send. AddedS.pendingFiles=[];renderTray()after enqueue in all three handlers. (static/commands.js)
v0.50.213 — 2026-04-26
Fixed
- Models disk cache now isolated per server instance — moved from
/dev/shm/hermes_webui_models_cache.json(shared across all processes) toSTATE_DIR/models_cache.json. Each server instance (port 8787 production, port 8789 QA, test runs) has its own cache file, so test/staging environments can no longer overwrite the production model list on the next restart. Also fixes macOS/Windows where/dev/shmdoesn't exist. (api/config.py) [#1064]
v0.50.212 — 2026-04-26
Performance
- Model list ~1ms on restart —
get_available_models()now writes to a disk cache at/dev/shmon every cold rebuild and reads it back on restart, eliminating the ~30s Z.AI endpoint-probe delay on every server start. TTL raised from 60s to 24h. (api/config.py) [#1060 @JKJameson] - Thundering-herd prevention — RLock +
_cache_build_in_progressflag ensures only one thread runs the cold rebuild while others wait on a Condition variable instead of triggering duplicate 10s provider calls. (api/config.py) [#1060 @JKJameson] - Credential pool cache —
load_pool()results cached per provider (24h TTL) to avoid repeated expensive auth-store reads on every model list refresh. (api/config.py) [#1060 @JKJameson]
Fixed
- Stale SSE blocking — switching sessions now discards in-flight SSE tokens from the previous session before attaching the new one; no cross-session token bleed. (
static/sessions.js) [#1060 @JKJameson] - Pending files cleared after send — ghost attachments no longer appear in the composer tray after sending. (
static/sessions.js) [#1060 @JKJameson] - Textarea focus on session switch — message input automatically focused after switching sessions. (
static/sessions.js) [#1060 @JKJameson] - Instant click for inactive sessions — no loading spinner blocking fast repeated session switches. (
static/sessions.js) [#1060 @JKJameson] - Double-click titlebar to rename — session title can be renamed by double-clicking the active session in the sidebar. (
static/sessions.js) [#1060 @JKJameson] - Draft persistence across switches — composer draft saved/restored when switching sessions. (
static/panels.js) [#1060 @JKJameson] - user-select:none on session titles — prevents accidental text selection on double-click. (
static/style.css) [#1060 @JKJameson] - Cache disk-delete in invalidate_models_cache() —
invalidate_models_cache()now also removes the on-disk snapshot so test isolation is preserved and stale cached data is never served after invalidation. (api/config.py) - _cache_build_in_progress reset on exception — rebuild exceptions no longer leave the flag stuck, which would block waiting threads for 60s. (
api/config.py)
v0.50.211 — 2026-04-25
Changed
- Compact sidebar timestamps — session timestamps in the left sidebar now show short labels (
1m,6m,1h,1d,1w) instead of verbose strings like "6 minutes ago". Keeps all existing i18n paths; bucket headers (Today, Yesterday, This week) unchanged. (static/sessions.js,static/i18n.js) [#1057 @pavolbiely]
Added
- Adaptive session title refresh — new opt-in setting (
Settings → Preferences → Adaptive title refresh) re-generates the session title from the latest exchange every N turns (5, 10, or 20). Off by default. Runs in a daemon thread after stream end, never blocks the stream. Manual title renames are preserved (double-checked before and after LLM call). (api/streaming.py,api/config.py,static/panels.js,static/i18n.js,static/index.html) [#1058 @bergeouss]
Fixed
- Settings picker active state — theme, skin, and font-size picker cards in Settings → Appearance now correctly highlight the selected option. Root cause: the base CSS rule used
!importantonborder-color, overriding the inline style set by_syncThemePicker()and siblings. Fix moves to an.activeclass with its own!importantrule. (static/style.css,static/boot.js) [#1059]
v0.50.210 — 2026-04-25
Added
- gpt-5.5 and gpt-5.5-mini in model picker — available for openai, openai-codex, and copilot providers. (
api/config.py) [#1052 @aliceisjustplaying] - Login redirects back to original URL after re-login — the iOS PWA auth redirect now passes
?next=with the current path;login.jshonors it via a_safeNextPath()helper that guards against open-redirect (rejects//, backslash, and non-path-absolute inputs). (static/login.js,static/ui.js,static/workspace.js) [#1053]
Fixed
- Non-standard provider first-run experience — agent dir discovery now searches XDG_DATA_HOME,
/opt,/usr/localpaths; onboarding wizard auto-completes for non-wizard providers (ollama-cloud, deepseek, xai, kimi-k2.6) withprovider_configured=True; wizard model field no longer hardcodesgpt-5.4-miniliteral; session model resolver correctly handles unlisted active providers. (api/config.py,api/onboarding.py,api/routes.py) Closes #1019–#1023 [#1049] - Cron session titles in sidebar — cron-launched sessions now display the human-friendly job name (from
~/.hermes/cron/jobs.json) instead of a generic "Cron Session" label. (api/models.py,api/routes.py) [#1050 @waldmanz] - AIAgent reused per session — fixes Honcho first-turn injection —
AIAgentis now cached persession_idso the agent's turn counter increments correctly across messages. Cache is evicted on session delete/clear. (api/config.py,api/routes.py,api/streaming.py) Closes #1039 [#1051 @qxxaa] - Mermaid Google Fonts CSP violation suppressed —
fontFamily:'inherit'in Mermaid themeVariables prevents@import url('fonts.googleapis.com')from being injected into diagram SVGs. (static/ui.js) Closes #1044 [#1054] - bfcache layout and dropdown restore —
pageshow+event.persistedhandler re-syncs topbar, workspace panel, session list, and gateway SSE; also closes open composer dropdowns frozen by bfcache._initResizePanels()removed from pageshow (bfcache preserves listeners). (static/boot.js) Closes #1045 [#1055]
v0.50.209 — 2026-04-25
Added
- Codex-style message queue flyout — messages typed while a stream is running now appear as a flyout card above the composer (same pattern as approval/clarify cards). Supports drag-to-reorder, inline edit, per-item model badge, Combine/Clear actions, and a collapsed pill outside the composer. Per-session DOM isolation via
_queueRenderKeys[sid]/_queueCollapsed[sid]prevents cross-session bleed. Titlebar#appTitlebarSubchip shows live queue count. (static/ui.js,static/messages.js,static/style.css,static/i18n.js,static/index.html) Closes #965 [#1040 @24601] - Inline HTML preview in workspace panel —
.htmland.htmfiles now render as live sandboxed iframes (sandbox="allow-scripts", noallow-same-origin) in the workspace file browser. A?inline=1parameter on/api/file/rawbypasses the usual attachment disposition; the server addsContent-Security-Policy: sandbox allow-scriptson inline HTML responses to prevent XSS when the URL is opened directly in a browser tab. (static/workspace.js,api/routes.py,static/index.html) Closes #779 [#1035 @bergeouss] - Provider categories in setup wizard — the onboarding provider dropdown groups 10 providers into Easy Start / Open & Self-hosted / Specialized with
<optgroup>sections. Includes Google Gemini, DeepSeek, Mistral, and xAI/Grok with correct current model defaults. (api/onboarding.py,static/onboarding.js) Closes #603 [#1036 @bergeouss]
Fixed
- Manual "Check for Updates" button in System settings — users can now trigger an update check immediately instead of waiting for the periodic background fetch. Error messages are sanitized before display. (
static/panels.js,static/index.html,static/style.css) Closes #785 [#1033 @bergeouss] - "Keep workspace panel open" toggle in Appearance settings — adds a persistent preference so the workspace panel opens automatically on each session if preferred. The toolbar X no longer clears the preference. (
static/panels.js,static/boot.js) Closes #999 [#1034 @bergeouss]
Changed
- CSP allowlist for Cloudflare Access deployments —
default-srcandmanifest-srcnow includehttps://*.cloudflareaccess.com, andscript-srcnow includeshttps://static.cloudflareinsights.com. This unblocks Agent37-style deployments running behind Cloudflare Access without affecting vanilla self-hosters (the new origins are unreachable in non-Cloudflare environments). (api/helpers.py) [#1040 follow-up]
v0.50.207 — 2026-04-25
Added
- Live TPS stat in header — a monospace chip in the titlebar shows tokens per second during streaming, with HIGH watermark from the past hour. Emitted via SSE at 1 Hz during active streams; hidden when idle. (
api/metering.py,api/streaming.py,static/messages.js,static/style.css) [#1005 @JKJameson]
Fixed
- Stale SSE events no longer pollute the new session's DOM on session switch —
appendThinking()andappendLiveToolCard()now guard against events from a prior session's stream arriving after the user has switched sessions. Thinking card also auto-scrolls to top on completion so the response is immediately visible. (static/ui.js) [#1006 @JKJameson] - Show agent sessions no longer shows empty/unimportable rows —
state.dbcan contain agent session rows before any messages are written. The sidebar now filters those out consistently across both the regular/api/sessionspath and the gateway SSE watcher. (api/agent_sessions.py,api/gateway_watcher.py,api/models.py) [#1009 @franksong2702] - Three orphaned i18n keys removed from language dropdown —
cmd_status,memory_saved, andprofile_delete_titlewere placed outside any locale block instatic/i18n.js, causing them to appear as invalid language options. (static/i18n.js) [#1010 @bergeouss] - Cron panel UX polish — Resume button SVG now uses a ▶| icon to distinguish it from Run; toast overlap fixed with
z-indexon the header; running-state badge with spinner shows during active jobs;_cronRunningPollclears correctly on panel close. (static/panels.js,static/index.html,static/style.css,static/i18n.js) [#1011 @bergeouss] - Create Folder and Add as Space from the browser — users can now create directories and immediately register them as workspace spaces without SSH access; server validates paths against blocked roots before
mkdir. (api/routes.py,static/ui.js,static/panels.js,static/i18n.js) [#1018 @bergeouss] - Model-not-found errors now show a helpful message — when a provider returns a 404 (e.g. Qwen model not available), the error is classified and a user-friendly hint appears instead of a raw HTML page. All 6 locales covered. (
api/streaming.py,static/messages.js,static/i18n.js) [#1022 @bergeouss] - Session attention indicators moved to right-side actions slot — streaming spinners and unread dots no longer sit before the session title, avoiding title shifts. Running/unread rows hide the timestamp; idle/read rows keep right-aligned timestamps. Date group carets now point down/right correctly. Pinned group no longer repeats the star icon per row. (
static/sessions.js,static/style.css) [#1024 @franksong2702] - Session sidebar dates now use the last real message time — sorting, grouping, and relative timestamps prefer
last_message_atderived from the last non-tool message instead of metadata-onlyupdated_at, so changing session settings doesn't move old conversations to Today. (api/models.py,api/routes.py) [#1024 @franksong2702] - Running indicators appear immediately after send — the sidebar now treats the active local busy session and local in-flight sessions as streaming while
/api/sessionscatches up. (static/messages.js,static/sessions.js) [#1024 @franksong2702] - Large session switching and reload no longer block on cold model-catalog resolution —
GET /api/session?messages=0now parses only the JSON metadata prefix; metadata-only loads skip the full-session LRU cache; the frontend lazy fetch passesresolve_model=0; hard reload no longer waits forpopulateModelDropdown(). (api/models.py,api/routes.py,static/boot.js,static/sessions.js,static/ui.js) [#1025 @franksong2702] - Auto title generation hardened for reasoning models — title generation now uses a 512-token reasoning-safe budget, retries once with 1024 tokens on empty content or
finish_reason: length, and preserves the underlying failure reason intitle_statuswhen falling back to a local summary. (api/streaming.py) [#1026 @franksong2702]
v0.50.206 — 2026-04-25
Fixed
- Uploaded files now resolve to their full workspace path in agent context — drag-and-drop and paperclip file uploads were correctly saved to the workspace, but the agent received only the bare filename (e.g.
photo.jpg) in the message context rather than an absolute path. The agent could not callread_fileorvision_analyzewithout a full path.uploadPendingFiles()now returns{name, path}objects from the/api/uploadresponse (data.pathwas always returned but never threaded through). The agent message uses the full path; all display surfaces (badges, session history, INFLIGHT state, POST body) continue showing only the bare filename. (static/ui.js,static/messages.js) Closes #996. [#997]
v0.50.205 — 2026-04-24
Fixed
- Workspace add: allow external paths not under home directory — adding a workspace path such as
/mnt/d/Projects(WSL) or any directory outside$HOMEwas blocked by a circular dependency:resolve_trusted_workspace()required the path to already be in the saved workspace list, but saving it required passing the same check. A newvalidate_workspace_to_add()function is now used by/api/workspaces/add— it only rejects non-existent paths, non-directories, and known system roots. The stricterresolve_trusted_workspace()continues to gate actual file read/write operations within a workspace. (api/workspace.py,api/routes.py) Closes #953. [#991]
v0.50.204 — 2026-04-24
Fixed
- Docker: HERMES_HOME corrected from
/root/.hermesto/home/hermes/.hermes—docker-compose.two-container.ymlanddocker-compose.three-container.ymlboth setHERMES_HOME=/root/.hermesand mounted the sharedhermes-homevolume to/root/.hermes. Thenousresearch/hermes-agentimage drops privileges to ahermesuser (uid=10000) viagosu, after which/rootis mode700and inaccessible — causingmkdir: cannot create directory '/root': Permission deniedon every startup. Fixed to use/home/hermes/.hermesthroughout. (docker-compose.two-container.yml,docker-compose.three-container.yml) Closes #967. [#989]
v0.50.203 — 2026-04-24
Fixed
- Queue drain race condition — drain the correct session after cross-session stream completion —
setBusy(false)was drainingS.session.session_id(the currently viewed session) rather than the session that just finished streaming. When the user switched sessions mid-stream, queued follow-up messages for the original session were silently dropped. A new_queueDrainSidvariable is set toactiveSidjust before callingsetBusy(false)in all stream terminal handlers;setBusy()reads it once and clears it. (static/messages.js,static/ui.js,tests/test_regressions.py) By @24601. [#964]
v0.50.202 — 2026-04-24
Fixed
- Throttle inflight localStorage persist to prevent GC crash —
saveInflightState()was called on every token, doingJSON.parse+ mutate +JSON.stringify+localStorage.setItemon the full inflight state map. At 60 tok/s with a 10KB messages array this produced ~36MB of JSON churn per second, the primary GC pressure source causing Chrome renderer crashes (error codes 4/5). A_throttledPersist()wrapper now batches writes to at most once per 2 seconds. State transitions (done/apperror/cancel/error) still flush synchronously so no more than 2s of progress is lost on a crash. (static/messages.js) By @24601. [#972]
v0.50.201 — 2026-04-24
Fixed
- Streaming render cleanup: call
clearTimeoutat all_pendingRafHandlesites — PR #966's render-throttling logic usessetTimeout(→rAF)when within the 66ms budget window, so_pendingRafHandlecan hold asetTimeoutID rather than arequestAnimationFrameID. All four cleanup sites only calledcancelAnimationFrame(), which is a no-op forsetTimeouthandles, leaving stale callbacks that could fire after stream finalization. Fixed to call bothclearTimeout()andcancelAnimationFrame()(each is a no-op on the other's handle type). (static/messages.js) [#985]
v0.50.200 — 2026-04-24
Changed
- Session render cache — skip O(n) rebuild on back-navigation —
renderMessages()now caches rendered HTML per session (keyed bysession_id+ message count). Switching back to a previously-rendered session serves the cached DOM instantly instead of running a full markdown parse, Prism highlight, and KaTeX pass over every message. Cache is limited to 8 sessions and 300KB of rendered HTML per entry. Active streaming sessions always bypass the cache. (static/ui.js) By @24601. [#963]
v0.50.199 — 2026-04-24
Fixed
- Streaming renderer crash under GC pressure —
_scheduleRender()previously usedrequestAnimationFrame(up to 60fps), but each DOM update takes 50–150ms on large sessions. During GC pauses, rAF callbacks accumulated and then fired sequentially, blocking the main thread for seconds and crashing the renderer (Chrome error codes 4/5, ERR_CONNECTION_RESET). The render rate is now capped at ~15fps (66ms min interval) via asetTimeout→requestAnimationFramechain. Stream cleanup now calls bothclearTimeout()andcancelAnimationFrame()so the handle is correctly cancelled regardless of which path scheduled it. (static/messages.js) By @24601. [#966]
v0.50.198 — 2026-04-24
Fixed
_accepts_gzip()hardened for test harness —handler.headers.get()now usesgetattr(handler, 'headers', None)so any synthetic handler without aheadersattribute (including the_FakeHandlerused in session-compress tests) no longer throwsAttributeError. (api/helpers.py)- Stale test assertions updated post-#959 — two static-analysis assertions in
test_issue401.pyandtest_regressions.pyreferenced minified JS string patterns that PR #959 reformatted; updated to accept either form. (tests/test_issue401.py,tests/test_regressions.py) [#981]
v0.50.197 — 2026-04-24
Changed
- Complete Traditional Chinese (zh-Hant) translations — adds full zh-Hant locale coverage (300+ translation entries) across all UI sections. Fixes mixed Simplified/Traditional character inconsistency in the existing zh translations. Also adds English-fallback entries to zh/ru/es/de for newly-added session management and settings keys (session_archive, session_pin, session_duplicate, settings_dropdown_*, etc.). (
static/i18n.js) By @ruxme. [#954]
v0.50.196 — 2026-04-24
Fixed
- Fast conversation switching with metadata-first session load — switching between conversations in the sidebar now does a two-phase load: phase 1 fetches only metadata (title, model, timestamps) instantly, then phase 2 lazily loads the full message history. Backend
Session.save()reorders JSON fields so metadata appears before the messages array, enabling a 1KB prefix-read path for small sessions. JSON responses over 1KB are gzip-compressed (4x smaller for large histories). Includestry/catchin_ensureMessagesLoadedso network errors show "Failed to load" rather than a stuck "Loading conversation…" state. (api/models.py,api/helpers.py,api/routes.py,static/sessions.js) By @JKJameson. [#959]
v0.50.195 — 2026-04-24
Fixed
- Auth sessions now persist across server restarts — previously
_sessionswas an in-memory dict, so every process restart (launchd, systemd, container recycle) invalidated all browser sessions and forced users to log in again. Sessions are now atomically persisted toSTATE_DIR/.sessions.json(0600 permissions) via a temp-file +os.replace()write pattern. Expired sessions are pruned on load. Corrupt or missing session files start fresh without crashing. (api/auth.py,tests/test_auth_session_persistence.py) By @24601. [#962]
v0.50.194 — 2026-04-24
Fixed
- Prevent dropped characters in incremental streaming-markdown path — detects parser/text prefix desync in
_smdWrite()(which can occur after stream sanitization strips content mid-stream) and rebuilds the parser from the full current display text rather than continuing to slice from a stale offset. Adds_smdWrittenTexttracking variable for accurate prefix-alignment checks. (static/messages.js) By @bsgdigital. [#960]
v0.50.193 — 2026-04-24
Fixed
- Strip malformed DSML
function_callstags from DeepSeek/Bedrock responses — extends the existing XML tool-call stripping logic to handle DeepSeek's DSML-prefixed variants (<|DSML|function_calls>,<|DSML |function_calls, and fragmented<|DSML |tokens) in backend (api/streaming.py), live streaming (static/messages.js), and settled render (static/ui.js). Prevents raw function-call XML from leaking into message content. (api/streaming.py,static/messages.js,static/ui.js) By @bsgdigital. [#958]
v0.50.192 — 2026-04-24
Changed
deferattribute added to all local script tags — scripts already sit at the end of<body>so this is largely a belt-and-suspenders improvement, butdefermakes the intent explicit and allows browsers to start parsing before the DOM is fully ready without blocking. Execution order preserved (defer is order-preserving per spec). (static/index.html) By @ruxme. [#951]
v0.50.191 — 2026-04-24
Fixed
- WebUI sessions now pass
platform='webui'to Hermes Agent — previously all browser-originated sessions passedplatform='cli', causing the agent to inject CLI-specific guidance ("avoid markdown, use plain text") that degraded WebUI output quality. Changed toplatform='webui'in all three AIAgent call sites (api/streaming.py,api/routes.py).'webui'has no entry inPLATFORM_HINTSso no conflicting platform guidance is injected. Includes regression tests. (api/streaming.py,api/routes.py,tests/test_webui_platform_hint.py) By @starship-s. [#948]
v0.50.190 — 2026-04-24
Fixed
.venvdiscovery in_discover_python()— adds.venv/bin/python(Linux/macOS) and.venv/Scripts/python.exe(Windows) alongside the existingvenv/paths, fixing issue #938 where setups using a.venvdirectory failed silently to locate the Hermes agent interpreter. (api/config.py) By @xingyue52077. Closes #938. [#949]
v0.50.189 — 2026-04-24
Fixed
- CSP: explicit
manifest-src 'self'directive — addsmanifest-src 'self'to theContent-Security-Policyheader. Browsers fall back todefault-srcwhenmanifest-srcis absent (functionally correct), but being explicit satisfies strict CSP audits and avoids browser-specific deviations. Includes regression test. (api/helpers.py,tests/test_pwa_manifest_csp.py) By @24601. [#961]
v0.50.189 — 2026-04-24
Fixed
- CSP: explicit
manifest-src 'self'directive — addsmanifest-src 'self'to theContent-Security-Policyheader. Browsers fall back todefault-srcwhenmanifest-srcis absent (functionally correct), but the explicit directive satisfies strict CSP audits and avoids any browser-specific deviation. Includes regression test. (api/helpers.py,tests/test_pwa_manifest_csp.py) By @24601. [#961]
v0.50.188 — 2026-04-24
Fixed
/btwcommand: corrected SSE endpoint —attachBtwStream()was connecting to/api/stream(which has never existed), causing every/btwinvocation to get a 404 and produce no answer. Fixed to/api/chat/stream. Also aligned theEventSourceconstructor to useURL()+withCredentials:truefor consistency with the rest ofstatic/messages.js. (static/messages.js) By @bergeouss. Closes #945. [#950]
v0.50.187 — 2026-04-24
Fixed
- Rail/hamburger breakpoint gap closed — at 641–767px the rail was hidden (required ≥768px) and the hamburger was also hidden (only ≤640px), leaving an awkward in-between zone. Rail breakpoint moved to ≥641px so the rail appears alongside the persistent sidebar at medium widths. Mobile slide-in behavior (hamburger toggle, overlay scrim) is unchanged at ≤640px. (
static/style.css) [#956]
v0.50.186 — 2026-04-24
Changed
- Three-column layout with left rail + main-view migration — unifies the shell into a rail (48px, desktop-only) + sidebar + main-view canvas matching the hermes-desktop reference. Every per-item detail/edit surface (skills, tasks, workspaces, profiles, memory) now lives in a dedicated
#mainXcontainer with consistent headers, empty states, and action buttons. Settings moves out of a modal overlay into a full main-view page (ESC closes it). YAML frontmatter renders in a collapsible<details>block in skill detail. Toasts repositioned to top-right with theme-aware success/error/warning/info variants. Composer workspace chip split into files-icon + label buttons..settings-menu→.side-menu/.side-menu-item(shared by memory and settings panels). Mobile: hamburger in titlebar, slide-in sidebar. New i18n keys across en/ru/es/de/zh/zh-Hant for all new form labels. 9 new regression tests. (static/index.html,static/style.css,static/panels.js,static/boot.js,static/sessions.js,static/ui.js,static/i18n.js,tests/test_settings_navigation_and_detail_refresh.py) By @aronprins. [#899]
v0.50.185 — 2026-04-24
Fixed
/btwstream handler hardened —_streamDone=truenow set beforesrc.close()indoneandapperrorhandlers (defensive ordering);_ensureBtwRow()indonegated on session match (S.session.session_id === parentSid) to prevent btw bubble leaking into a different session if the user switches mid-stream;stream_endhandler also sets_streamDone=truefor defense-in-depth. 14 new regression tests added. (static/messages.js,tests/test_reasoning_chip_btw_fixes.py) [#935]/reasoningtoast aligned with BRAIN prefix — success toast now reads🧠 Reasoning effort: <level>consistent with the command's other toasts. (static/commands.js) [#939]- Bootstrap Python discovery finds
.venv/layout —discover_launcher_pythonnow checks bothvenv/and.venv/inside the agent directory, covering installations that use a leading-dot venv layout. (bootstrap.py) [#941]
v0.50.185 — 2026-04-24
Fixed
/btwstream_endnow sets_streamDone— defense-in-depth improvement per Opus code review: thestream_endSSE handler now sets_streamDone=truebefore closing the connection, guarding against any server flow that emitsstream_endwithout a precedingdone/apperrorevent. (static/messages.js)
v0.50.184 — 2026-04-24
Fixed
- Reasoning chip dropdown now opens correctly — the dropdown was placed inside
.composer-leftwhich hasoverflow-y: hidden, clipping the upward-opening menu entirely. Moved#composerReasoningDropdownoutside to sit alongside the model/profile/workspace dropdowns and added_positionReasoningDropdown()for consistent chip-aligned positioning. Z-index raised to 200 to match other composer dropdowns. (static/index.html,static/style.css,static/ui.js) - Reasoning chip icon is now a monochrome SVG — replaced the
🧠emoji in the label with astroke="currentColor"brain-outline SVG matching the style of all other composer chips. (static/index.html,static/ui.js) /reasoning <level>now immediately updates the chip — previously calledsyncReasoningChip()which re-applied the stale cached value. Now calls_applyReasoningChip(eff)directly with the server-confirmed effort level. (static/commands.js)/btwanswer no longer vanishes after rendering —onerrorwas firing when the server cleanly closed the SSE connection afterstream_end, removing the just-rendered answer bubble. A_streamDoneflag now preventsonerrorfrom wiping the row after a successful stream. Also added_ensureBtwRow()call indonehandler so the bubble renders even if notokenevents arrived. (static/messages.js) Closes #933.
Added
- Session attention indicators in the sidebar — the session list now shows a
spinning indicator while a session is actively streaming (even in the
background), an unread dot when a session has new messages the user hasn't
seen, and a right-aligned relative timestamp ("2m ago", "Yesterday") next to
every session title. Streaming state is computed server-side from the live
STREAMSregistry so it's accurate across tabs and after server restart. The unread count is tracked client-side inlocalStorageand cleared automatically when the active session's stream settles. Pinned-star indicator moved into the title row with a fixed 10×10 box for consistent alignment. Includes a 5 s polling loop that activates only while sessions are streaming, and a 60 s timer to keep relative timestamps fresh. (api/models.py,static/sessions.js,static/messages.js,static/style.css) Closes #856. Co-authored by @franksong2702.
Fixed
- Nous static models now use explicit
@nous:prefix — the four hardcoded "(via Nous)" models (Claude Opus 4.6,Claude Sonnet 4.6,GPT-5.4 Mini,Gemini 3.1 Pro Preview) now carry@nous:prefix IDs, matching the format of live-fetched Nous models. Previously they used slash-only IDs that relied on the portal provider guard; the explicit prefix routes them through the same bulletproof@provider:modelbranch and eliminates 404 errors on those entries. (api/config.py,tests/test_nous_portal_routing.py)
Added
- Workspace path autocomplete in Spaces — the "Add workspace path" field in
the Spaces panel now suggests trusted directories as you type, supports
keyboard navigation plus
Tabcompletion, and keeps hidden directories out of the list unless the current path segment starts with.. Suggestions are limited to trusted roots (home, saved workspaces, and the boot default workspace subtree) and never enumerate blocked system roots. (api/routes.py,api/workspace.py,static/panels.js,static/style.css) (partial for #616)
[v0.50.232] — 2026-04-28
Fixed
- Model chip fuzzy-match false positive —
_findModelInDropdown()step-3 fuzzy fallback was stripping the trailing version segment and matching viastartsWith(base) || includes(base), causinggpt-5.5to resolve to@nous:openai/gpt-5.4-mini(both start withgpt.5). The fix uses the full normalized target as the prefix whenbase.length > 4 && base !== target, only falling back to the stripped base for bare roots (≤4 chars) where the strip was a no-op. (static/ui.js) (#1188) - openai-codex not detected in model picker —
OPENAI_API_KEYnow also registers theopenai-codexprovider group in the env-var fallback path, so users who have Codex OAuth set up no longer need a manualconfig.yamledit to see the picker entries. Note: OAuth-authenticated users are already detected viahermes_cli.auth; this fixes the env-var-only fallback path. (api/config.py) (#1189) - Workspace files blank after second empty-session reload — the ephemeral-session guard in
boot.jswas callinglocalStorage.removeItem('hermes-webui-session'), which caused the second reload to fall into the no-saved-session path that never callsloadDir(). Removing that line keeps the session key so every reload follows the sameloadSession → loadDirpath. (static/boot.js) (#1196) - Session timestamps wrong when client and server clocks differ — the session list's relative
time labels and message-footer timestamps now use a server-clock approximation (
_serverNowMs()) derived from theserver_timefield returned by/api/sessions. Fractional-hour timezone offsets (India+0530, Nepal+0545, etc.) are handled correctly via offset-minutes arithmetic. (api/routes.py,static/sessions.js) (#1144, @bergeouss)
[v0.50.231] — 2026-04-28
Fixed
- macOS
/etcsymlink bypass in workspace blocked-roots — on macOS,/etc,/var, and/tmpare symlinks to/private/etcetc._workspace_blocked_roots()now materialises both the literal andPath.resolve()forms of every blocked root, and a new_is_blocked_system_path()helper applies the check with/var/foldersand/var/tmpcarve-outs so pytesttmp_path_factorypaths and other legitimate per-user tmp dirs remain registerable as workspaces. (api/workspace.py,api/routes.py) (#1186) - Workspace panel stuck closed after empty-session reload — a regression from #1182: when a
user had the workspace panel open and reloaded the page on an empty/new session, the panel was
force-closed and the toggle disabled.
syncWorkspacePanelState()now only force-closes in'preview'mode (which requires a session);'browse'mode renders the panel chrome with a no-workspace placeholder. Both boot paths restore the user's localStorage panel preference before the sync call. (static/boot.js) (#1187) - Fenced code content leaking into markdown passes — large tool outputs with diff/patch/log
content (lines starting with
-,+,*,#inside code blocks) were having<ul>/<li>/<h>tags injected by the list/heading regexes, breaking</pre>closure and corrupting subsequent message rendering. The fix keeps fenced blocks stashed as\x00P<n>\x00tokens through ALL markdown passes and restores them AFTER lists/headings/tables, so those regexes never see the rendered HTML. (static/ui.js) (#1154, @bergeouss)
[v0.50.230] — 2026-04-27
Fixed
- No disk write for empty sessions —
new_session()no longer eagerly writes an empty JSON file to disk. The session lives in the in-memorySESSIONSdict only; the first disk write happens at the natural "this is now a real session" moment (first user message via/api/chat/start, or explicits.save()in the btw/background-agent paths). Eliminates orphansessions/*.jsonfiles that accumulated on every page reload, New Conversation click, or onboarding pass without sending a message. Crash-safety: if the process exits between create and first message, the session is lost — since it had no messages, there is nothing to lose. (api/models.py) (#1171 follow-up, #1184)
[v0.50.229] — 2026-04-27
Performance
- Session switch parallelization — directory pre-fetches use
Promise.all()(N×RTT → 1×RTT); git status/ahead/behind run in parallel viaThreadPoolExecutor(max_workers=3);loadDir()andhighlightCode()overlap on the idle path. (api/workspace.py,static/sessions.js,static/workspace.js) (#1158, @jasonjcwu)
Fixed
- Message pagination for long conversations — sessions with more than 30 messages load the
most-recent 30 on switch; older messages load on scroll-to-top or the "↑ load older" indicator.
Stale-response race in
_loadOlderMessagesclosed; all undo/retry/compress/done paths reset pagination state. (api/routes.py,static/sessions.js,static/ui.js,static/commands.js,static/i18n.js) (#1158, @jasonjcwu) - Ephemeral untitled sessions never appear in sidebar — empty Untitled sessions are now
suppressed immediately rather than surfacing for 60 seconds. Both the index-path and full-scan
fallback filters are consistent; boot path skips restoring a zero-message session from storage.
(
api/models.py,static/boot.js,static/sessions.js) (#1182) - iOS Safari auto-zoom on input focus — inputs, textareas, and selects on touch devices now
have a minimum
font-size: max(16px, 1em)via@media (hover:none) and (pointer:coarse), preventing iOS from zooming in on focus. Accessibility-safe: user's OS font preference is respected when it exceeds 16px. (static/style.css) (#1167, #1180)
[v0.50.229] — 2026-04-27
Performance
- Session switch parallelization — directory pre-fetches now use
Promise.all()(N×RTT → 1×RTT); git status/ahead/behind subprocesses run in parallel viaThreadPoolExecutor(max_workers=3);loadDir()andhighlightCode()run concurrently on idle path. Session switches with expanded workspace dirs are measurably faster on high-latency connections. (api/workspace.py,static/sessions.js,static/workspace.js) (#1158, @jasonjcwu)
Added
- Message pagination for long conversations — sessions with more than 30 messages now load
the most-recent 30 on switch; older messages load on scroll-to-top or via the "↑ load older"
indicator at the top of the message list. All undo/retry/compression paths reset pagination
state correctly. (
api/routes.py,static/sessions.js,static/ui.js,static/commands.js) (#1158, @jasonjcwu)
[v0.50.228] — 2026-04-27
Fixed
- Raw
<pre>blocks preserved in markdown renderer — the inline<code>rewrite pass inrenderMd()no longer processes content inside raw<pre>blocks, preventing multiline HTML code blocks from being degraded to backtick strings. (static/ui.js) (#1150, @bsgdigital) - Live model race silently overwrites session model —
syncTopbar()now skips the destructive fallback-to-first-model path while a live model fetch is in flight for the active provider;_addLiveModelsToSelect()re-applies the session model once the fetch completes, so models only present in the live catalog (e.g. Kimi K2) are never silently replaced. (static/ui.js) (#1169) - Tool card output truncated at 220 chars and unscrollable — JS truncation threshold
raised to 800 chars; CSS
overflow:autoadded to.tool-card.open .tool-card-detailso the inner<pre>scroll works correctly;<pre>max-height raised to 360 px. (static/ui.js,static/style.css) (#1170) - New Conversation creates empty session when already on empty session — clicking
the New Conversation button or pressing Cmd/Ctrl+K when the current session has zero
messages now focuses the composer instead of creating another empty Untitled session.
(
static/boot.js) (#1171) .envfile corruption from concurrent WebUI and CLI/Telegram writes — removes the unlocked duplicate_write_env_file()inapi/onboarding.pythat bypassed_ENV_LOCK; rewrites the shared version to preserve comments, blank lines, and original key order rather than rebuilding from a sorted dict. (api/onboarding.py,api/providers.py) (#1164, @bergeouss)
[v0.50.227] — 2026-04-27
Fixed
- Korean locale label and missing Settings descriptions —
ko._labelnormalized to'한국어'; ten Settings pane description keys that were falling back to English are now fully translated. (static/i18n.js) (#1138) - Workspace trust: alternative home roots —
resolve_trusted_workspace()now checks the home-directory allowance before the blocked-roots loop, letting symlinked home paths (e.g./var/home/user) pass through correctly. (api/workspace.py) (#1165) - Custom config-file provider models — the provider-discovery loop now includes entries
defined under
providers:inconfig.yaml, so custom providers no longer silently skip the model list. Shared_PROVIDER_MODELSlist is deep-copied before mutation to prevent cross-session bleed. (api/config.py) (#1161) - Save Settings button missing from System pane — the System settings pane now has a
Save Settings button so password changes and other system fields can actually be
submitted. (
static/index.html) (#1146) - Per-job cron completion dot — the Tasks panel now shows a pulsing green dot on each
cron job that has a new unread completion; the dot clears only when that specific job's
detail view is opened, not on any panel-level navigation. (
static/panels.js,static/style.css) (#1145) - Hide cron agent sessions from sidebar by default — sessions created by the cron
scheduler (source
cronor session_id prefixcron_) are now filtered out of the default session list in both the index path and the full-scan path; imported gateway cron sessions are also hidden viaread_importable_agent_session_rows(). (api/models.py,api/agent_sessions.py) (#1143) - Symlink cycle detection in workspace file browser — intentional symlinks within the
workspace root are now allowed; only self-referencing or ancestor-pointing symlinks are
blocked. Symlink entries render with type, target, and
is_dir. (api/workspace.py) (#1149) /statuscommand enriched — output now includes session id, profile, model+provider, workspace, personality, start time, per-turn token counts, estimated cost, and agent running state. i18n keys added for all locales. (api/session_ops.py,static/commands.js,static/i18n.js) (#1156)- Per-turn cost display on assistant bubbles — each assistant message footer now shows
the token delta and estimated cost for that turn, computed from the cumulative
doneSSE usage minus the previous turn's total. (static/messages.js,static/ui.js) (#1159) - Auto-title: skip generic fallback — when auxiliary title generation fails and the
local fallback would only produce
"Conversation topic", the existing provisional title is kept instead of persisting the generic placeholder. (api/streaming.py) (#1157) - Sidebar session rename first-Enter revert — double-click inline rename now keeps the
new title after the first Enter keypress;
finish()is idempotent via a guard flag and_renamingSidstays locked until the full async path (success, failure, or cancel) completes. (static/sessions.js) (#1162) - Auto-compression renders as transient card — automatic context compression now
renders as a collapsible compression card instead of injecting a fake
*[Context was auto-compressed]*assistant message; preserved task-list user messages also render as sub-cards. (static/messages.js,static/ui.js,static/i18n.js) (#1142)
[v0.50.226] — 2026-04-27
Fixed
- App titlebar restored to rail-era centered layout — removes the TPS metering chip
from the top bar, centers the title and subtitle, and restores the message count in the
subtitle slot. Queue state no longer overrides the titlebar subtitle slot.
(
static/index.html,static/panels.js,static/style.css,static/ui.js,tests/test_app_titlebar_restore.py)
[v0.50.183] — 2026-04-24
Added
/btwslash command — ask an ephemeral side question using current session context without adding to history. Creates a hidden session, streams the answer in a visually distinct bubble, then discards the session. IncludesattachBtwStream()SSE consumer andPOST /api/btwroute. (api/routes.py,api/background.py,static/commands.js,static/messages.js,static/style.css)/backgroundslash command — run a prompt in a parallel background agent without blocking the active conversation. Frontend pollsGET /api/background/statusfor results and displays completed answers inline. Includes badge indicator in composer footer. (api/routes.py,api/background.py,static/commands.js,static/messages.js,static/index.html)- Undo button on last assistant message — surfaced as an ↩ icon on the last assistant message, calling the existing
/undocommand for discoverability. (static/ui.js) - Reasoning effort chip in composer — visual chip to set reasoning effort level from the composer footer without typing a command. (
static/ui.js,static/index.html,static/style.css)
Fixed
- Background task completion hook wired —
complete_background()was never called after a background agent finished, so tasks stayed instatus="running"forever and polling always returned[]. Fixed by wrapping_run_agent_streamingin_run_bg_and_notifywhich extracts the last assistant message and signals the tracker. Also fixedget_results()to retain in-flight tasks during polls so concurrent tasks are not dropped. (api/background.py,api/routes.py,tests/test_background_tasks.py) - Ephemeral sessions correctly skip persistence — added
returnafter the ephemeraldoneevent in_run_agent_streaming(), preventing ephemeral session state from being written to disk after stream completion. (api/streaming.py)
Co-authored by @bergeouss.
[v0.50.181] — 2026-04-24
Changed
- Vendor streaming-markdown@0.2.15 — self-hosts the incremental markdown parser instead of loading it from jsDelivr CDN. The library (12.6 KB) is committed to
static/vendor/smd.min.jsso the app works fully offline / air-gapped, and the exact bytes are pinned in version control. SHA-384 hash preserved in an HTML comment for manual audit. (static/vendor/smd.min.js,static/index.html) Co-authored by @bsgdigital.
[v0.50.180] — 2026-04-23
Added
- Incremental streaming markdown via
streaming-markdown— replaces the per-animation-frame fullinnerHTMLre-render with an incremental DOM-building parser. During streaming, only new character deltas are fed to the parser per frame (_smdWrite()), eliminating DOM thrashing and improving rendering smoothness. Prism.js / KaTeX state no longer gets reset mid-stream. Falls back to the existingrenderMd()path when the library is unavailable. (static/messages.js,static/index.html) Co-authored by @bsgdigital.
[v0.50.179] — 2026-04-23
Fixed
- Onboarding wizard clobbering CLI users' config after server restart — CLI-configured users (who set up via
hermes model/hermes auth) had noonboarding_completedflag insettings.json. After a git branch switch or server restart,verify_hermes_imports()could momentarily returnimports_ok=False, makingchat_ready=Falseand causing the wizard to reappear with a destructive dropdown default (openrouter). Fixed by writingonboarding_completed: Truetosettings.jsonthe first timeconfig_auto_completedevaluates toTrue, so the flag survives future transient import failures. (api/onboarding.py) Co-authored by @bsgdigital.
[v0.50.177] — 2026-04-23
Fixed
- Settings dialog and message controls unusable on mobile — three mobile usability fixes: (1) settings tab strip replaced by a native
<select>dropdown on narrow viewports, panel goes full-width; (2) provider card Save/Remove buttons become icon-only on mobile so the API key input fills the available width; (3) message timestamps, copy, and edit buttons are always visible on touch screens (no hover state on mobile). (static/index.html,static/panels.js,static/style.css) Co-authored by @bsgdigital.
[v0.50.178] — 2026-04-23
Added
- PWA support — installable as a standalone app — adds a Web App Manifest (
manifest.json) and a minimal service worker (sw.js) with cache-first strategy for app shell assets and network-bypass for all/api/*and/streamendpoints. Cache name auto-busts on every deploy via git-derived version injection. Enables "Add to Home Screen" on Android, iOS, and desktop Chrome without any offline API response caching (live backend always required). (static/manifest.json,static/sw.js,static/index.html,api/routes.py) Closes #685. Co-authored by @bsgdigital.
[v0.50.176] — 2026-04-23
Fixed
- Duplicate model dropdown entries when CLI default matches live-fetched model —
_addLiveModelsToSelect()now normalises IDs before the dedup check (strips@provider:prefix usingindexOf+substringto preserve multi-colon Ollama tag suffixes likeqwen3-vl:235b-instruct, strips namespace prefix, unifies separators). (static/ui.js) Closes #907. - New Chat uses stale default model after saving Preferences without reload —
window._defaultModelis now updated in_applySavedSettingsUi()sonewSession()picks up the newly saved default immediately. (static/panels.js) Closes #908. - Injected CLI default model shows raw lowercase label — new
_get_label_for_model()helper looks up the model's formatted label from existing catalog groups before falling back to title-casing the bare ID. (api/config.py) Closes #909.
[v0.50.175] — 2026-04-23
Fixed
- Session persistence hardened against concurrent write races — all session-mutation paths (streaming success/error/cancel, periodic checkpoint, HTTP endpoints for title/personality/workspace/clear/pin/archive/project) now hold a per-session
_agent_lockduring in-memory mutation andSession.save(). The checkpoint thread is stopped and joined before the final save, preventing stale object clobbers.Session.save()uses fsync + atomic rename with a pid+thread_id tmp suffix._write_session_index()gets a dedicated_INDEX_WRITE_LOCKso disk I/O runs outside the globalLOCK, reducing head-of-line blocking. Context compression now runs the LLM call outside the lock with a stale-edit check (409) on write-back. (api/streaming.py,api/models.py,api/routes.py,api/session_ops.py,api/config.py) Closes #765. Co-authored by @starship-s.
[v0.50.174] — 2026-04-23
Fixed
- Interleaved streaming order (Text → Thinking → Tool → Text) — after a tool call completes, new text tokens now create a new DOM segment below the tool card instead of updating the old segment above it. Adds
segmentStart/_freshSegmentflags to track segment boundaries; scopes the streaming cursor to the last live assistant segment only; adds a 3-dot waiting indicator below each tool card; fixesappendLiveToolCard/appendThinkinganchor logic for multi-tool sequences. (static/messages.js,static/ui.js,static/style.css) Co-authored by @bsgdigital.
[v0.50.173] — 2026-04-23
Fixed
- Ordered list items always showed "1." regardless of position — when LLMs
output numbered lists with blank lines between items, the paragraph-splitter
in
renderMd()placed each item in its own<ol>container, causing every<ol>to restart at 1. Fixed by emittingvalue="N"on each<li>so the correct ordinal is preserved even when items are split across multiple<ol>wrappers. (static/ui.js) Closes #886. Co-authored by @bsgdigital.
[v0.50.172] — 2026-04-23
Fixed
- Stop Generation preserves partial streamed content — clicking Stop Generation previously discarded all text the agent had produced, showing only "Task cancelled.". The server now accumulates streamed tokens in a per-stream buffer and persists any partial assistant content to the session when a cancel fires. Thinking/reasoning blocks (
<think>...</think>, including unclosed tags — the common cancel-mid-reasoning case) are stripped before saving. The partial content is flagged_partial: trueand kept in conversation history so the model can continue from it on the next user message. (api/config.py,api/streaming.py) Closes #893.
[v0.50.171] — 2026-04-23
Fixed
- Nous default model picker shows correct selection and saves no longer freeze — two bugs for Nous/portal provider users: (1) Settings → Preferences → Default Model picker showed blank after saving because
set_hermes_default_model()wrote a bare resolved form that didn't match the@nous:...option values in the dropdown; fixed by using_applyModelToDropdown()'s smart normalising matcher to find the right option without requiring an exact string match. (2) Every Settings save triggered a blocking live-fetch from the provider API (~5 s freeze) becauseset_hermes_default_model()calledget_available_models()before returning; the function now returns a lightweight{ok, model}ack and invalidates the TTL cache instead. Config.yaml always stores the CLI-compatible bare/slash form (e.g.anthropic/claude-opus-4.6) so CLI users on the same install are unaffected. (api/config.py,static/panels.js) Closes #895. - Cross-namespace models (minimax/, qwen/) no longer 404 for Nous users —
resolve_model_provider()checked theconfig_base_urlbranch before the portal-provider guard. Nous always has abase_urlin config, so known cross-namespace prefixes were stripped before reaching the portal check. Portal providers are now checked first so all slash-prefixed model IDs reach Nous intact. (api/config.py) Closes #894.
[v0.50.170] — 2026-04-23
Fixed
- Settings default model picker shows live-fetched models — the Settings → Preferences → Default Model dropdown previously only showed static models from
_PROVIDER_MODELS. It now calls_fetchLiveModels()via the new_addLiveModelsToSelect()helper, consistent with the chat-header dropdown. New sessions also respect the saved default model (window._defaultModel) instead of always reading the chat-header value, which reflected the previous session's model. (static/ui.js,static/sessions.js,static/panels.js) Closes #872. Co-authored by @bergeouss.
[v0.50.163] — 2026-04-23
Fixed
- Message ordering after task cancellation — cancelling a stream while the
agent is responding no longer causes subsequent responses to appear above the
"Task cancelled." marker. The cancel handler now fetches the authoritative
message list from the server (same as the done event), and the server persists
the cancel message to the session so both paths stay in sync. Falls back to
the previous local-push behaviour if the API call fails. (
api/streaming.py,static/messages.js) (@mittyok, #882)
[v0.50.161] — 2026-04-23
Fixed
- CI:
test_set_key_writes_to_env_fileno longer flaky in full-suite ordering — two test files (test_profile_env_isolation.py,test_profile_path_security.py) were callingsys.modules.pop("api.profiles")without restoring the module reference, permanently removingapi.profilesfrom the module cache and corrupting state for subsequent tests. Replaced withmonkeypatch.delitem(sys.modules, ...)so the module reference is restored automatically after each test. (tests/test_profile_env_isolation.py,tests/test_profile_path_security.py) api/providers.py_write_env_file()lock and mode fixes — moved file I/O (mkdir + write) inside the_ENV_LOCKblock to prevent TOCTOU race between concurrent key-save requests; replacedwrite_text()withos.open(..., O_CREAT, 0o600)so new.envfiles are created owner-read/write-only from the first byte. (api/providers.py)
[v0.50.160] — 2026-04-23
Fixed
- CI: provider panel i18n keys now present in all 6 locales —
es,de,zh,ru,zh-Hantwere missing the 19 provider panel keys added in v0.50.159, causing locale parity test failures on CI after every push to master. (static/i18n.js)
[v0.50.159] — 2026-04-23
Added
- Provider key management in Settings — new "Providers" tab lets users add, update, or remove API keys for direct-API providers without editing
.envfiles. Covers Anthropic, OpenAI, Google, DeepSeek, xAI, Mistral, MiniMax, Z.AI, Kimi, Ollama, Ollama Cloud, OpenCode Zen/Go. OAuth providers shown as read-only. Keys stored in~/.hermes/.env, take effect immediately. Fully localised (6 locales). (api/providers.py,api/routes.py,static/panels.js,static/i18n.js) (PR #867 by @bergeouss, closes #586)
Security
- Provider write endpoints require auth or local/private-network client (matching onboarding endpoint gate)
.envcreated at 0600 from first byte viaos.open; pre-existing files tightened to 0600 on every write- Full
_ENV_LOCKcoverage across load/modify/write — prevents TOCTOU race between concurrent POSTs
[v0.50.158] — 2026-04-23
Fixed
- Post-update page reload no longer races against server restart —
applyUpdates()andforceUpdate()now poll/healthevery 500ms (up to 15 seconds) instead of firing a blind 2500mssetTimeout. The existing reconnect banner shows "⏳ Restarting… please wait" during the poll window, giving users a visible status and a manual Reload button. If the server is still down after 15s, the banner message changes to prompt a manual reload. Fixes 502 errors seen when the server restart outpaces the fixed delay, especially behind reverse proxies. (static/ui.js) (closes #874)
[v0.50.157] — 2026-04-22
Fixed
- Nous portal models now route and format correctly — two bugs fixed: (1)
_PROVIDER_MODELS["nous"]updated from bare IDs (claude-opus-4.6) to slash-prefixed format (anthropic/claude-opus-4.6) that the Nous portal API expects. (2)resolve_model_provider()now routes cross-namespace models through portal providers (Nous, OpenCode Zen, OpenCode Go) directly instead of mis-routing to OpenRouter. Portal guard returns the full slash-preserved model ID so Nous receives the correct format. 10 regression tests. (api/config.py) (closes #854)
[v0.50.156] — 2026-04-22
Security
- ⚠️ Breaking change — auto-install of agent dependencies is now opt-in — users previously relying on auto-install must now set
HERMES_WEBUI_AUTO_INSTALL=1to restore the previous behaviour. A new_trusted_agent_dir()check validates ownership and permission bits before allowing pip to run. (api/startup.py,README.md) (addresses #842 by @tomaioo)
[v0.50.155] — 2026-04-22
Fixed
- Honcho per-session memory uses stable session ID across WebUI turns —
api/streaming.pynow passesgateway_session_key=session_idtoAIAgent(defensive, same pattern asapi_mode/credential_pool). Without this, Honcho'sper-sessionstrategy created a new Honcho session on each streaming request. (api/streaming.py) (closes #855)
[v0.50.154] — 2026-04-22
Fixed
- Thinking card no longer mirrors main response — removed early return in
_streamDisplay()that bypassed think-block stripping whenreasoningTextwas populated. (static/messages.js) (closes #852)
[v0.50.153] — 2026-04-22
Fixed
- Live-fetched portal models route through configured provider —
_fetchLiveModels()applies@provider:prefix. (closes #854)
[v0.50.152] — 2026-04-22
Fixed
- Image generation renders inline —
MEDIA:token restore renders allhttps://URLs as<img>. (closes #853) - Auto-title strips thinking preambles —
_strip_thinking_markup()strips Qwen3-style plain-text reasoning preambles. (closes #857)
[v0.50.151] — 2026-04-22
Added
- Ollama Cloud support — added
ollama-clouddisplay name + dynamic model-list handler backed byhermes_cli.models.provider_model_ids(). Live-models endpoint routesollama-cloudthrough the same formatter. Server-side_format_ollama_label()and matching client-side_fmtOllamaLabel()turn Ollama tag IDs into readable labels (e.g.qwen3-vl:235b-instruct→Qwen3 VL (235B Instruct)). (#820 by @starship-s, #860)
Fixed
credential_poolproviders now visible in the model dropdown —get_available_models()previously only readactive_providerfrom the auth store. Providers added viacredential_pool(e.g. an Ollama Cloud key stored by the auth layer without a matching shell env var) were silently invisible. The fix loadscredential_poolentries and adds any provider with at least one non-ambient credential todetected_providers. Ambient gh-cli tokens (sourcegh_cli/ labelgh auth token) are explicitly excluded so Copilot doesn't appear merely becauseghis installed. Two-tier detection: primary viaagent.credential_pool.load_pool(), fallback via raw field inspection when the upstream module isn't importable. (#820 by @starship-s, #860)_apply_provider_prefix()helper extracted — removes ~15 lines of duplicated inline@provider:prefixing logic for non-active providers. Semantics unchanged; one fewer place for drift. (#860)- Model chip shows friendly labels for bare Ollama IDs —
static/ui.js:getModelLabel()now routes Ollama tag-format IDs (e.g.kimi-k2.6or@ollama-cloud:glm5.1) through_fmtOllamaLabel(). Custom<option>text uses the same helper.looksLikeBareOllamaIdnarrowed to@ollama*or colon-tag patterns — does not reformat generic IDs likegpt-5.4-mini.syncModelChip()is now called after localStorage restore so the chip reflects the saved selection on first paint. (#860)
[v0.50.150] — 2026-04-22
Fixed
- Profile switching: three related state fixes — (1)
hermes_profile=defaultcookie is now persisted instead of being cleared withmax-age=0, which had caused the browser to fall back to the process-global profile on the next request. (2) ThesessionInProgressbranch ofswitchToProfile()now callssyncTopbar()instead of the undefinedupdateWorkspaceChip(). (3) Sidebar and dropdown active-profile rendering now preferS.activeProfileclient state when available, with a safe fallback. (#849 by @migueltavares)
[v0.50.149] — 2026-04-22
Fixed
GET /api/sessionis now side-effect free for stale-model sessions — the read path previously called_normalize_session_model_in_place(), which could write back to disk and update the session index while handling a plain read. Replaced with a read-only_resolve_effective_session_model_for_display()that returns the effective display model without any write-back. Closes #845. (#848 by @franksong2702)
[v0.50.148] — 2026-04-22
Fixed
- Prune stale
_index.jsonghost rows after session-id rotation — index entries whose backing session file no longer exists (e.g. after context compression rotates the session id) are now pruned on both incremental index writes andall_sessions()reads. Fixes duplicate session entries in the sidebar. Also pre-snapshotsin_memory_idsunder a singleLOCKacquisition inall_sessions()rather than one per row — small but measurable contention reduction. Closes #846. (#847 by @franksong2702)
[v0.50.147] — 2026-04-22
Fixed
- Font size setting now visibly changes UI text — selecting Small or Large
in Appearance settings previously had no visible effect because the CSS override
only changed
:root{font-size}, but the stylesheet uses 230+ hardcodedpxvalues that are unaffected by root font-size. Added explicit per-element overrides for the key UI surfaces: chat message body, sidebar session list, composer textarea, and workspace file tree. Closes #843. (#844)
[v0.50.146] — 2026-04-22
Fixed
- Slash command input now shown as user message in chat — commands like
/help,/skills,/statuspreviously produced a response with no visible user input above it, making the conversation appear to start from nowhere. Added anoEchoflag to action-only commands (/clear,/new,/stop, etc.) and echo the user's input as a message bubble for commands that produce a chat response. User message is pushed BEFORE the handler runs to ensure correct ordering inS.messages. Closes #840. (#841)
[v0.50.145] — 2026-04-22
Fixed
- Slash command dropdown scrolls to keep highlighted item visible — pressing ↓/↑
to navigate the autocomplete list no longer lets the selected item move out of the
visible dropdown area. Added
scrollIntoView({block:'nearest'})after updating the selected class innavigateCmdDropdown(). Closes #838. (#839)
[v0.50.141] — 2026-04-22
Fixed
- Session list appears empty after browser reload / version update — Chrome's
bfcache was restoring a prior search query into
#sessionSearchon page restore, causingrenderSessionListFromCache()to silently filter out all sessions (including newly created ones). Addedautocomplete="off"to the search input and an explicit value-clear at boot before the first render. Closes #822. (#830)
[v0.50.140] — 2026-04-22
Fixed
- Gateway SSE sync failures now surface to the user — when the gateway watcher thread is not running, the browser now shows a toast notification and automatically falls back to 30-second polling for session sync. Previously this failed silently with no feedback. (#828, absorbs PR #826 by @cloudyun888, fixes #635)
_gateway_sse_probe_payloadnow checkswatcher._thread.is_alive()rather than justwatcher is not None, so a watcher instance with a dead poll thread correctly reports unavailable and triggers the polling fallback.- Probe fetch network errors now also activate the polling fallback as a safe default rather than silently swallowing the failure.
[v0.50.139] — 2026-04-22
Fixed
- Default workspace persists after session delete — the blank new-chat page now shows the configured default workspace even after creating and deleting sessions. Root cause:
newSession()consumedS._profileDefaultWorkspacefor a one-shot profile-switch semantic, leaving it null on all subsequent returns to blank state. Fix: introducedS._profileSwitchWorkspaceas a dedicated one-shot flag for profile switches;S._profileDefaultWorkspaceis now persistent from boot throughout the session lifecycle. Workspace chip,promptNewFile,promptNewFolder, andswitchToWorkspaceall continue to work correctly. Closes #823. (#824)
[v0.50.138] — 2026-04-22
Fixed
- Streaming: response no longer renders twice or leaves thinking block below the answer — two race conditions in
attachLiveStreamfixed. (A) A trailingtoken/reasoningevent could queue arequestAnimationFramethat fired afterdonehad already calledrenderMessages(), inserting a duplicate live-turn wrapper below the settled response. Fixed via_streamFinalizedflag +cancelAnimationFramein all terminal handlers (done,apperror,cancel,_handleStreamError). (B) A proposed accumulator-reset on SSE reconnect was reverted — the server uses a one-shot queue and does not replay events; the reset would have wiped pre-drop response content. Bug A's fix alone resolves all three reported symptoms (double render, thinking card below answer, stuck cursor). (#821, closes #631) - Blank new-chat page now shows default workspace and allows workspace actions —
syncWorkspaceDisplays()usesS._profileDefaultWorkspaceas fallback when no session is active; the workspace chip is now enabled on the blank page;promptNewFile,promptNewFolder,switchToWorkspace, andpromptWorkspacePathall auto-create a session bound to the default workspace when called on the blank page, rather than silently returning. Boot.js hydratesS._profileDefaultWorkspacefrom/api/settings.default_workspacebefore any session is created. (#821, closes #804)
[v0.50.135] — 2026-04-22
Fixed
- BYOK/custom provider models now appear in the WebUI model dropdown — three root causes fixed. (1) Provider aliases like
z.ai,x.ai,google,grok,claude,aws-bedrock,dashscope, and ~25 others were not normalized to their internal catalog slugs, causing the provider to miss_PROVIDER_MODELSlookup and show an empty dropdown while the TUI worked. (2) The fix works even withouthermes-agentonsys.path(CI, minimal installs) via an inlined_PROVIDER_ALIASEStable inapi/config.py— the previoustry/except ImportErrorwas silently swallowing the failure. (3)custom_providersentries now appear in the live model enrichment path.provider_idon every group makes optgroup matching deterministic. Closes #815. (#817)
[v0.50.134] — 2026-04-21
Fixed
- Update banner: conflict/diverged recovery path + server self-restart after update — three failure modes resolved. (1)
Update failed (agent): Repository has unresolved merge conflictswas a dead-end with no recovery path; the error now includes an actionablegit checkout . && git pull --ff-onlycommand, a persistent inline display (not a fleeting toast), and a Force update button that executes the reset via the newPOST /api/updates/forceendpoint. (2) After a successful update, the server now self-restarts viaos.execv(2 s delay), eliminating the stale-sys.modulesbug that broke custom provider chat on the next request. (3) When both webui and agent updates are pending, the restart now correctly waits for the second update to complete before re-executing (_apply_lockcoordination), preventing the mid-pull kill race. Closes #813, #814. (#816)
[v0.50.133] — 2026-04-21
Added
/reasoning showand/reasoning hideslash commands — toggle thinking/reasoning block visibility directly from the chat composer, matching the Hermes CLI/TUI parity./reasoning showreveals all thinking cards (live and historical) and persists the preference;/reasoning hidecollapses them./reasoningwith no args shows current state. Theshow|hideoptions now appear in autocomplete alongside the existinglow|medium|higheffort levels. Theshow_thinkingsetting is persisted via/api/settingsso the preference survives page reloads. Closes #461 (partial — effort level routing to agent is a follow-up). (#812)
[v0.50.132] — 2026-04-21
Fixed
- Periodic session checkpoint during long-running agent tasks — messages accumulated during multi-step research or coding tasks were silently lost if the server restarted mid-run. The root cause:
Session.save()was only called afteragent.run_conversation()completed. The fix adds a daemon thread that saves the session every 15 seconds whenever theon_toolcallback signals a completed tool call — the first reliable mid-run signal that real progress has been made (the agent works on an internal copy ofs.messages, so watching message-count would never trigger).Session.save()gains askip_index=Trueflag so checkpoints skip the expensive index rebuild; the finals.save()at task completion still rebuilds it. On a server restart the user's message and turn bookkeeping remain on disk — worst case: up to 15 seconds of tool-call progress lost rather than the entire conversation turn. Closes #765. Absorbed and corrected from PR #809 by @bergeouss. (#810)
[v0.50.131] — 2026-04-21
Fixed
- Workspace pane now respects the app theme — seven hardcoded dark-mode
rgba(255,255,255,...)colors in the workspace panel CSS have been replaced with theme-aware CSS variables (--hover-bg,--border2,--code-inline-bg). The file list hover, panel icon buttons, preview table rows, and the preview edit textarea now all update correctly when switching between light and dark themes. Reported in #786. (#807)
[v0.50.130] — 2026-04-21
Fixed
- New sessions now appear immediately in the sidebar — the zero-message Untitled filter now exempts sessions younger than 60 seconds, so clicking New Chat shows the session right away instead of waiting for the first message. Sessions older than 60 seconds that are still Untitled with 0 messages continue to be suppressed (ghost sessions from test runs / accidental page reloads). Addresses Bug A only of #789; Bug B (SSE refetch resetting sidebar mid-interaction) is a separate fix. (#806)
[v0.50.129] — 2026-04-21
Fixed
- Profile isolation: complete fix via cookie + thread-local context — PR #800 (v0.50.127) only fixed
POST /api/session/new.GET /api/profile/activestill read the process-level_active_profileglobal, so a page refresh while another client had a different profile active would corruptS.activeProfilein JS, defeating the session-creation fix on the next new chat. This release completes the isolation: profile switches now set ahermes_profilecookie (HttpOnly, SameSite=Lax) and never mutate the process global. Every request handler reads the cookie into a thread-local; all server functions (get_active_profile_name(),get_active_hermes_home(),list_profiles_api(), memory endpoints, model loading) automatically see the per-client profile.switch_profile()gains aprocess_widekwarg — the HTTP route passesFalse, keeping the global clean; CLI callers default toTrue(unchanged behaviour). Absorbed from PR #803 by @bergeouss with correctness fixes reviewed by Opus. (#805)
[v0.50.128] — 2026-04-21
Fixed
"no longer mangles to&quot;inside code blocks — the autolink pass inrenderMd()was operating inside<pre><code>blocks because they weren't stashed before the pass ran. When a code block contained a URL adjacent to"(the HTML-escaped form of"), the autolink regex captured the entity suffix andesc()double-encoded it, producing&quot;in the rendered HTML and copy buffer. Fixed by adding<pre>blocks to_al_stashso the autolink regex never touches code-block content. Reported and fixed by @starship-s. (#801)
[v0.50.127] — 2026-04-21
Fixed
- Profile isolation: switching profiles in one browser client no longer affects concurrent clients —
api/profiles.pystored_active_profileas a process-level global;switch_profile()mutated it for the whole server, so a second user switching profiles would clobber new-session creation for all other active tabs. The fix: (1)get_hermes_home_for_profile(name)— a pure path resolver that reads only the filesystem, validates the profile name against the existing_PROFILE_ID_REpattern (rejects path traversal), and never mutatesos.environor module state; (2)new_session()now accepts an explicitprofileparam passed from the client'sS.activeProfilein the POST body, short-circuiting the process global; (3) the streaming handler resolvesHERMES_HOMEfrom the per-sessions.profileinstead of the shared global. Reported in #798. (#800)
[v0.50.126] — 2026-04-21
Fixed
- Onboarding now recognizes
credential_poolOAuth auth for openai-codex — the readiness check inapi/onboarding.pyonly looked at the legacyproviders[provider]key inauth.json. Hermes runtime resolves OAuth tokens fromcredential_pool[provider](device-code / OAuth flows), so WebUI could report "not ready" while the runtime chatted successfully. The check now covers both storage locations with a fail-closed helper. Adds three regression tests. Reported in #796, fixed by @davidsben. (#797)
[v0.50.125] — 2026-04-21
Fixed
python3 bootstrap.pynow honours.envsettings — running bootstrap.py directly (the primary documented entry point) previously ignoredHERMES_WEBUI_HOST,HERMES_WEBUI_PORT, and other repo.envsettings becausestart.sh'ssource .envstep was skipped. bootstrap.py now loadsREPO_ROOT/.envitself before reading any env-var defaults, making the two launch paths identical. Reported in #730 by @leap233. (#791)
[v0.50.124] — 2026-04-21
Fixed
- Settings version badge now shows the real running version — the badge in the Settings → System panel was hardcoded to
v0.50.87(36 releases behind) and the HTTPServer:header saidHermesWebUI/0.50.38(85 behind). Both are now resolved dynamically at server startup fromgit describe --tags --always --dirty. Docker images (where.gitis excluded) receive the correct tag via a build-timeARG HERMES_VERSIONwritten toapi/_version.py.COPYnow uses--chown=hermeswebuitoo:hermeswebuitooso the write succeeds under the unprivileged container user. No manual "update the badge" step is needed going forward — tagging is sufficient. Version file parsing uses regex instead ofexec()for supply-chain safety. (#790, #793)
[v0.50.123] — 2026-04-21
Fixed
- Default model change surfaced stale value after model-list TTL cache landed —
set_hermes_default_model()now explicitly invalidates_available_models_cacheafterreload_config(). The 60s TTL cache introduced in v0.50.121 (#780) only invalidates on config-file mtime change, butreload_config()resyncs_cfg_mtimebeforeget_available_models()runs — so the mtime check never fires and the POST response (plus downstream reads within the TTL window) returned the previous model until the cache expired. Root cause of thetest_default_model_updates_hermes_configCI flake as well. (#788) - Test teardown restores conftest default deterministically —
test_default_model_updates_hermes_confignow restores to the conftest-injectedTEST_DEFAULT_MODEL(viatests/_pytest_port.py) instead of reading the pre-test value from/api/models, so teardown is stable regardless of ordering. Also updatesTESTING.mdautomated-test count to 1578. (#788)
[v0.50.122] — 2026-04-21
Fixed
- Duplicate X button in workspace panel header on mobile — at viewport widths ≤900px the desktop close-preview button (
.close-preview/btnClearPreview) is now hidden via CSS, leaving only the mobile close button (.mobile-close-btn) visible. Previously both buttons appeared side-by-side when the window was resized below the 900px breakpoint. (#781)
[v0.50.121] — 2026-04-20
Performance
- Model list no longer re-scans on every session load —
get_available_models()now caches its result for 60 seconds (configurable via_AVAILABLE_MODELS_CACHE_TTL). Config file changes (mtime) invalidate the cache immediately. This eliminates the ~4s AWS IMDS timeout that blocked the model dropdown on every page load for users on EC2 without an IAM role. Thread-safe via a dedicated lock; callers receive acopy.deepcopy()so mutations don't pollute the cache. (credit: @starship-s) - Session saves no longer trigger a full O(n) index rebuild —
_write_session_index()now does an incremental read-patch-write of the existing index JSON when called fromSession.save(), rather than re-scanning every session file on disk. Falls back to a full rebuild when the index is missing or corrupt. Atomic write via.tmp+os.replace(). At 100+ sessions this is a meaningful speedup. (credit: @starship-s)
[v0.50.120] — 2026-04-20
Fixed
- Cancelled sessions no longer get stuck —
cancel_stream()now eagerly pops stream state (STREAMS,CANCEL_FLAGS,AGENT_INSTANCES) and clearssession.active_stream_idimmediately after signalling cancel. Previously, the 409 "session already has an active stream" guard would block all new chat requests until the agent thread'sfinallyblock ran — which never happens when the thread is blocked in a C-level syscall on a bad tool call. Session cleanup runs outsideSTREAMS_LOCKto preserve lock ordering and avoid deadlock. (Fixes #653, credit: @bergeouss)
[v0.50.119] — 2026-04-20
Fixed
- Older hermes-agent builds no longer crash on startup — the WebUI now checks which params
AIAgent.__init__actually accepts (viainspect.signature) before constructing the agent. The four params added in newer builds (api_mode,acp_command,acp_args,credential_pool) are passed only when present, so older installs degrade gracefully instead of throwingTypeError. (#772)
[v0.50.118] — 2026-04-20
Fixed
- CLI sessions: silent failure now logged —
get_cli_sessions()no longer swallows DB errors silently. Ifstate.dbis missing thesourcecolumn (older hermes-agent) or has any other schema/lock issue, a warning is now logged with the DB path and a hint to upgrade hermes-agent. This makes "Show CLI sessions in sidebar has no effect" diagnosable from the server log instead of requiring code archaeology. (#634)
[v0.50.117] — 2026-04-20
Fixed
- Queued messages survive page refresh — when a follow-up message is submitted while the agent is busy, the queue is now persisted to
sessionStorage. On reload, if the agent is still running the queue is silently restored and will drain normally. If the agent has finished, the first queued message is restored into the composer as a draft with a toast notification ("Queued message restored — review and send when ready"), preventing accidental auto-send. Stale entries (created before the last assistant response) are automatically discarded. (#660)
[v0.50.116] — 2026-04-20
Fixed
- Session errors survive page reload — provider quota exhaustion, rate limit, auth, and agent errors are now persisted to the session file as a special error message. Reloading the page after an error no longer shows a blank conversation. Error messages are excluded from the next API call's conversation history so the LLM never sees its own error as prior context. (#739)
- Quota/credit exhaustion shows a distinct error — "Out of credits" now appears instead of the generic "No response received" message when a Codex or other provider account runs out of credits. Both the silent-failure path and the exception path now classify
insufficient_credits/quota_exceededseparately from rate limits, with a targeted hint to top up the balance or switch providers. (#739) - Context compaction no longer hangs the session — when
run_conversation()rotates the session_id during context compaction,stream_endnow uses the original session_id (captured before the run), matching what the client captured inactiveSid. Previously the mismatch caused the EventSource to stay open, trigger a reconnect loop, and show "Connection lost." The same fix also corrects thetitleSSE event. (#652, #653)
[v0.50.115] — 2026-04-20
Removed
- Chat bubble layout setting removed — the opt-in
bubble_layouttoggle (issue #336) is removed end-to-end: the Settings checkbox, all related CSS (.bubble-layoutselectors), the config.py default/bool-key entries, the boot.js/panels.js class toggles, and all locale strings across 6 languages. Stalebubble_layoutvalues in existingsettings.jsonfiles are silently dropped on load via the legacy-drop-keys migration path. (Fixes #760, credit: @aronprins)
[v0.50.114] — 2026-04-20
Fixed
- Default model now reads from Hermes config.yaml — removes the split-brain state where WebUI Settings and the Hermes runtime/CLI/gateway could have different default models.
default_modelis no longer persisted insettings.json; it is read from and written toconfig.yamlvia a newPOST /api/default-modelendpoint. Existing saveddefault_modelvalues insettings.jsonare silently migrated away on first load. Saving Settings now calls/api/default-modelwhen the model changed, with error handling so a config.yaml write failure doesn't leave the UI in a broken state. (#761, credit: @aronprins)
[v0.50.113] — 2026-04-20
Fixed
- Slash autocomplete now keeps command completion flowing into sub-arguments — sub-argument-only commands like
/reasoningnow appear in the first suggestion list, the current dropdown selection is visibly highlighted while navigating with arrow keys, and accepting a top-level command like/reasoningimmediately opens the second-level suggestions instead of requiring an extra space press. (Fixes #632, credit: @franksong2702)
[v0.50.112] — 2026-04-20
Added
- Sidebar density mode for the session list — new Settings option toggles the left session list between a compact default and a detailed view that shows message count and model. Profile names only appear in detailed mode when "Show active profile only" is disabled. (#673)
[v0.50.111] — 2026-04-20
Fixed
- Dark-mode user bubbles no longer use a glaring bright accent fill —
:root.darknow overrides--user-bubble-bg/--user-bubble-bordertovar(--accent-bg-strong)(a 15% tint), keeping the bubble visually subdued in dark skins. The 6 per-skin--user-bubble-texthacks are removed; text color falls back tovar(--text). Edit-area box-shadow now uses the shared--focus-ringtoken. (credit: @aronprins) - Thinking card header is now collapsible — the main
_thinkingMarkup()function now includesonclicktoggle and the chevron affordance, matching the compression reference card pattern. The header hasdisplay:flexfor proper icon/label/chevron alignment.
[v0.50.110] — 2026-04-20
Fixed
- Message footer metadata is now consistent across user and assistant turns — timestamps are available on both sides, but footer chrome stays hidden until hover instead of being always visible on assistant messages. The last assistant turn keeps cumulative
in/out/costusage visible, then reveals timestamp and actions inline on hover. Existing timestamps for unchanged historical messages are also preserved during transcript rebuilds, so older turns no longer get re-stamped to the newest reply time. (Fixes #680, credit: @franksong2702)
[v0.50.109] — 2026-04-20
Fixed
- Named custom provider test isolation —
_models_with_cfg()intests/test_custom_provider_display_name.pynow pins_cfg_mtimebefore callingget_available_models(), preventing the mtime-guard inside that function from firingreload_config()and silently discarding the patchedconfig.cfg. This fixes an ordering-dependent test failure where any test that wroteconfig.yamlbefore this test ran would causeget_available_models()to return the real OpenRouter model list instead of the patched Agent37 group. (Fixes #754)
[v0.50.108] — 2026-04-20
Fixed
- Kimi K2.5 added to Kimi/Moonshot provider model list —
kimi-k2.5was present inhermes_clibut missing from the WebUI'sapi/config.pykimi-coding provider, making it unavailable in the model selector. (Fixes #740)
[v0.50.107] — 2026-04-20
Added
- Three-container UID/GID alignment guide in README — new subsection explains why UIDs must match across containers sharing a bind-mounted volume, documents the variable name asymmetry (
HERMES_UID/HERMES_GIDfor the agent image vsWANTED_UID/WANTED_GIDfor the WebUI image), gives the recommended.envsetup for standard Linux and NAS/Unraid deployments, provides the one-timechownfix for existing installs, and notes that the dashboard volume must be read-write. (Fixes #645)
Fixed
HERMES_UID/HERMES_GIDforwarded to agent and dashboard containers —docker-compose.three-container.ymlnow declaresHERMES_UID=${HERMES_UID:-10000}andHERMES_GID=${HERMES_GID:-10000}in the environment blocks forhermes-agentandhermes-dashboard, making the documented.envrecipe functional.
[v0.50.106] — 2026-04-20
Fixed
PermissionErrorin auth signing key no longer crashes every HTTP request —key_file.exists()inapi/auth.py's_signing_key()was called outside the try/except block. In three-container bind-mount setups where the agent container initialises the state directory under a different UID,pathlib.Path.exists()raisesPermissionError, which escaped up throughis_auth_enabled()→check_auth()and crashed every HTTP request with HTTP 500. Theexists()call is now inside the try block soPermissionErroris caught and falls back to an in-memory key. (PR #625)
[v0.50.105] — 2026-04-20
Fixed
- Profile deletion warning now leads with destructive impact — the confirmation dialog now reads: "All sessions, config, skills, and memory for this profile will be permanently deleted. This cannot be undone." Updated across all 6 supported locales. (Fixes #637)
[v0.50.104] — 2026-04-20
Fixed
- Agent image URLs rewritten to actual server base — when an agent emits a
MEDIA:http://localhost:8787/...URL, the WebUI now rewrites thelocalhost/127.0.0.1host to the page'sdocument.baseURIbefore inserting it as an<img src>. Fixes broken images for remote users (VPN, Docker, deployed servers) and preserves subpath mounts (e.g./hermes/). (Fixes #642)
[v0.50.103] — 2026-04-20
Fixed
- Windows
.envencoding fix —write_text()calls inapi/profiles.pywere missingencoding='utf-8', causing failures on Windows systems with non-UTF-8 locale encodings. All file I/O inapi/now explicitly specifiesencoding='utf-8'. (Fixes #741)
[v0.50.102] — 2026-04-20
Fixed
- Code blocks no longer lose newlines when not preceded by a blank line —
renderMd()now stashes<pre>blocks (including language-labelled wrappers), mermaid diagrams, and katex blocks before the paragraph-splitting pass, then restores them. Previously, if a fenced code block was not separated from surrounding text by a blank line, all\ninside it were replaced with<br>, collapsing the entire block to one line. (Fixes #745)
[v0.50.101] — 2026-04-20
Fixed
- Session model normalization: null/empty model no longer triggers index rebuild — sessions with no stored model (
model: nullor missing) now return the provider default without writing to disk. Previously a spurioussession.save()(and full session index rebuild) could fire for any such session. (#751 follow-up)
[v0.50.100] — 2026-04-20
Fixed
- Session model normalization: unknown provider prefixes now pass through — custom/unlisted model prefixes (e.g.
custom-provider/my-model) are no longer incorrectly stripped when switching providers. Only well-known provider prefixes (gpt-,claude-,gemini-, etc.) are normalized. Regression introduced in v0.50.99. (#751)
[v0.50.99] — 2026-04-20
Fixed
- Stale session models normalized after provider switch — sessions that still reference a model from a previous provider (e.g. a
gemini-*model after switching to OpenAI Codex) are silently corrected to the current provider's default on load, preventing startup failures. (Closes #748, credit: @likawa3b)
[v0.50.98] — 2026-04-20
Fixed
- Slash command autocomplete constrained to composer width — the
/command dropdown is now positioned inside the composer box, so suggestions stay visually anchored to the input area rather than expanding across the full chat panel. (Closes #633, credit: @franksong2702)
[v0.50.97] — 2026-04-20
Fixed
- Only the latest user message can be edited — older user turns no longer show the pencil/edit affordance. This avoids implying that historical turns can be lightly edited when the actual action truncates the session and restarts the conversation from that point. (Closes #744)
- Message footer metadata is now consistent across user and assistant turns — timestamps are available on both sides using the existing
_ts/timestampfields, but footer chrome now stays hidden until hover instead of being always visible on assistant messages. The last assistant turn keeps cumulativein/out/costusage visible, then reveals timestamp and actions inline on hover so the footer does not grow an extra row. Existing timestamps for unchanged historical messages are also preserved during transcript rebuilds, so older turns no longer get re-stamped to the newest reply time.
[v0.50.96] — 2026-04-19
Added
- Three-container Docker Compose reference config — new
docker-compose.three-container.ymladds an agent + dashboard + WebUI configuration on a sharedhermes-netbridge, with memory/CPU limits and localhost-only port bindings by default.
Fixed
- Two-container compose: gateway port now exposed —
127.0.0.1:8642:8642added so the gateway is reachable from the host for debugging. Explicitcommand: gateway runreplaces entrypoint defaults. - Workspace path expansion —
${HERMES_WORKSPACE:-~/workspace}uses tilde in the default value, which Docker Compose correctly expands.docker-compose.ymlalso fixed to use${HERMES_WORKSPACE:-${HOME}/workspace}instead of nesting workspace inside the hermes home dir. HERMES_WEBUI_STATE_DIRdefault corrected —webui-mvp→webui, matching the current default inconfig.py. Prevents silent state directory split for new deployments. (PR #708)
[v0.50.95] — 2026-04-19
Added
- Full Russian (ru-RU) localization — 389/389 English keys covered, Slavic plural forms correctly implemented, native Cyrillic characters throughout. Login page Russian added. Russian locale now leads all non-English locales on key coverage. (PR #713, credit: @DrMaks22 and @renheqiang)
[v0.50.92] — 2026-04-19
Fixed
- XML tool-call syntax no longer leaks into chat bubbles —
<function_calls>blocks stripped server-side in the streaming pipeline and client-side in both the live stream and history render. Fixes the default DeepSeek profile showing raw XML on starter prompts. (#702) - Workspace file panel shows an empty-state message instead of a blank pane when no workspace is configured or the directory is empty. (#703)
- Notification settings description uses "app" instead of "tab" — more accurate for native Mac app users. (#704) (PR #712)
[v0.50.95] — 2026-04-19
Fixed
- Assistant messages now show footer timestamps, and older messages show a fuller date+time — assistant response segments now render the same footer timestamp affordance as user messages, using the existing message
_ts/timestampfields already stamped by the WebUI. Messages from today still show a compact time-only label, while older messages now show a fuller date+time string directly in the footer for better readability when reviewing past sessions.
[v0.50.94] — 2026-04-19
Fixed
- Mic toggle is now race-safe and works over Tailscale — rapid click/toggle no longer leaves recording in inconsistent state (
_isRecordingflag with proper reset in all paths).recognition.start()is now correctly called (was previously only present in a comment string, so SpeechRecognition never started and the Tailscale fallback never fired). Falls back toMediaRecorderwhenspeech.googleapis.comis unreachable. Browser capability preference persisted inlocalStorageacross reloads. (PR #683 by @MatzAgent)
[v0.50.93] — 2026-04-19
Fixed
- Gateway message sync no longer corrupts the active session on slow networks — the
sessions_changedSSE handler now captures the active session ID before the asyncimport_clifetch and validates it in.then(), preventing session-switch races from overwriting the wrong conversation. Addedis_cli_sessionguard so the handler only fires for CLI-originated sessions. The backend import path now also verifies that existing messages are a strict prefix of the fresh CLI messages before overwriting, preventing silent data loss on hybrid WebUI+CLI sessions. (PR #676 by @yunyunyunyun-yun)
[v0.50.91] — 2026-04-19
Added
- Slash command parity with hermes-agent —
/retry,/undo,/stop,/title,/status,/voicecommands now work in the Web UI, matching gateway behaviour. NewGET /api/commandsendpoint andapi/session_ops.pybackend. (PR #618 by @renheqiang) - Skills appear in
/autocomplete — the composer slash-command dropdown now surfaces Hermes skills from/api/skills. Skill entries show aSkillbadge and are ranked below built-ins on collisions. (PR #701 by @franksong2702)
[v0.50.87] — 2026-04-18
Fixed
- Streaming scroll override (#677) — auto-scroll no longer hijacks your position while the AI is responding.
renderMessages()andappendThinking()now callscrollIfPinned()during an active stream instead ofscrollToBottom(), so scrolling up to read earlier content works correctly. Scroll re-pin threshold widened from 80px to 150px to avoid hair-trigger re-pinning on fast mouse wheels. A floating ↓ button appears at the bottom-right of the message area when you scroll up, giving a one-click way to jump back to live output. - Gemini 3.x model IDs updated (#669) — all provider model lists (
gemini,google, OpenRouter fallback, GitHub Copilot, OpenCode Zen, Nous) now include the correct Gemini 3.1 Pro Preview, Gemini 3 Flash Preview, and Gemini 3.1 Flash Lite Preview model IDs alongside stable Gemini 2.5 models. The missinggemini-3.1-flash-lite-preview(which causedAPI_KEY_INVALIDerrors) is now present.GEMINI_API_KEYenv var now also triggers native gemini provider detection. - Read-only workspace mount no longer crashes Docker startup (#670) —
docker_init.bashnow checks[ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]before attemptingchownor write-test on the workspace directory.:robind-mounts are silently accepted with a log message instead of callingerror_exit. - UID/GID auto-detection now works in two-container setups (#668) —
docker_init.bashnow probes/home/hermeswebui/.hermesand$HERMES_HOME(shared hermes-home volume) before falling back to/workspace. In Zeabur and Docker Compose two-container deployments where the hermes-agent container initializes the shared volume first, the WebUI now correctly inherits its UID/GID without manualWANTED_UIDconfiguration.
[v0.50.86] — 2026-04-18
Added
- Searchable model picker — the model dropdown now has a live search input at the top. Type any part of a model name or ID to filter the list instantly; provider group headers (Anthropic, OpenAI, OpenRouter, etc.) remain visible in filtered results. Includes a clear button, Escape-to-close support, and a "No models found" empty state. i18n strings added for English, Spanish, and zh-CN. (PR #659 by @mmartial)
[v0.50.90] — 2026-04-19
Fixed
/compressreference card now shows full handoff immediately after compression — the context compaction card no longer shows only the short 3-line API summary right after/compresscompletes. The UI now prefers the persisted compaction message (full handoff) over the raw API response, matching what is shown after a page reload. (PR #699 by @franksong2702)
[v0.50.89] — 2026-04-19
Fixed
- Explicit UTF-8 encoding on all config/profile reads —
Path.read_text()calls inapi/config.pyandapi/profiles.pynow always specifyencoding="utf-8". On Windows systems with a non-UTF-8 default locale (e.g. GBK on Chinese Windows, Shift_JIS on Japanese Windows), omitting the encoding argument caused silent config loading failures. (PR #700 by @woaijiadanoo)
[v0.50.88] — 2026-04-19
Fixed
- System Preferences model dropdown no longer misattributes the default model to unrelated providers — the
/api/modelsbuilder no longer injects the globaldefault_modelinto unknown provider groups such asAlibabaorMinimax-Cn. When a provider has no real model catalog of its own, it is now omitted from the dropdown instead of showing a misleading placeholder likegpt-5.4-mini. If the active provider still needs a default fallback, it is shown in a separateDefaultgroup rather than being mixed into another provider's models.
[v0.50.85] — 2026-04-18
Fixed
_provider_oauth_authenticated()now respects thehermes_homeparameter — the function had a CLI fast path (hermes_cli.auth.get_auth_status()) that ignored the caller-suppliedhermes_homeand read from the real system home. On machines whereopenai-codex(or another OAuth provider) was genuinely authenticated, this caused three test assertions to returnTrueinstead ofFalse, regardless of the isolatedtmp_paththe test passed in. Removed the CLI fast path; the function now reads exclusively fromhermes_home/auth.json, which is both the correct scoped behavior and what the docstring described. No functional change for production (the auth.json path was already the complete fallback). (Fixes pre-existing test_sprint34 failures)
[v0.50.84] — 2026-04-18
Fixed
- MiniMax M2.7 now appears in the model dropdown for OpenRouter users —
MiniMax-M2.7andMiniMax-M2.7-highspeedwere present in_PROVIDER_MODELS['minimax']but absent from_FALLBACK_MODELS, so OpenRouter users (who see the fallback list) never saw them. Both models added to the fallback list under theMiniMaxprovider label. MINIMAX_API_KEYenv var now triggers MiniMax detection — the env scan tuple inget_available_models()was missingMINIMAX_API_KEYandMINIMAX_CN_API_KEY, so users who set those vars directly inos.environ(rather than in~/.hermes/.env) did not see the MiniMax provider in the dropdown. Both keys now scanned. (PR #650 by @octo-patch)
[v0.50.83] — 2026-04-18
Fixed
- Provider models from
config.yamlnow appear in the model dropdown — users who configured custom providers inconfig.yamlwith an explicitmodels:list saw the hardcoded_PROVIDER_MODELSfallback instead of their configured models. The fix extends the model-list builder to checkcfg.providers[pid].modelsand use it when present, supporting both dict format (models: {model-id: {context_length: ...}}) and list format (models: [model-id, ...]). Providers only inconfig.yaml(not in_PROVIDER_MODELS) are now included in the dropdown instead of being silently skipped. (PR #644 by @ccqqlo)
[v0.50.82] — 2026-04-18
Added
/compresscommand with optional focus topic — manual session compression runs as a real API call viaPOST /api/session/compress, replacing the old agent-message-based/compact. Accepts an optional focus topic (/compress summarize code changes) that guides what the compression preserves. The compression flow is shown as three transcript-inline cards: a command card (gold), a running card (blue with animated dots), and a collapsible green success card showing the message-count delta and token savings. A reference card renders the full context compaction summary./compactcontinues to work as an alias.focus_topiccapped at 500 chars for defense-in-depth. Fallback token estimation uses word-count approximation when model metadata helpers are unavailable — intentional for resilience. (Closes #469, PR #619 by @franksong2702)
[v0.50.81] — 2026-04-18
Fixed
- Auto-title extraction improved for tool-heavy first turns — sessions where the agent's first response involved tool calls (e.g. memory lookups, file reads) were generating poor titles because the title extractor skipped all assistant messages with
tool_calls, even when those messages contained substantive visible text. The extractor now picks the first pure (non-tool-call) assistant reply as the title source, using_looks_invalid_generated_title()to distinguish meta-reasoning preambles from real agentic replies. Also fixes_is_provisional_title()to normalize whitespace before comparing, so CJK text truncated at 64 characters correctly re-triggers title updates. (Closes #639, PR #640 by @franksong2702)
[v0.50.80] — 2026-04-18
Fixed
- Clicking a skill no longer silently loads content into a hidden panel —
openSkill()now callsensureWorkspacePreviewVisible()so the workspace panel auto-opens when you click a skill in the Skills tab. (Closes #643) - Long thinking/reasoning traces now scroll instead of being clipped — the thinking card body now uses
overflow-y: autowhen open, so long traces are fully readable. (Closes #638) - Sidebar nav icon hit targets are now correctly aligned — added
display:flex; align-items:center; justify-content:centerto.nav-tabso clicking the icon itself (not below it) activates the tab. (Closes #636) - Safari iOS input auto-zoom fixed — bumped
textarea#msgbase font-size from 14px to 16px, which prevents Safari from zooming the viewport on input focus (Safari zooms when font-size < 16px). Visual difference is negligible. (Closes #630)
[v0.50.79] — 2026-04-17
Fixed
- Default model no longer shows as "(unavailable)" for non-OpenAI users — changed the hardcoded fallback
DEFAULT_MODELfromopenai/gpt-5.4-minito""(empty). When no default model is configured, the WebUI now defers to the active provider's own default instead of pre-selecting an OpenAI model that most providers don't have. Users who want a specific default can still setHERMES_WEBUI_DEFAULT_MODELenv var or pick a model in Preferences. (Closes #646)
[v0.50.78] — 2026-04-17
Fixed
- Gemma 4 thinking tokens no longer shown raw in chat — added
<|turn|>thinking\n...<turn|>to the streaming think-token parser instatic/messages.jsand_strip_thinking_markup()inapi/streaming.py. Previously Gemma 4's reasoning output appeared as raw text prepended to the answer. (Closes #607)
[v0.50.77] — 2026-04-17
Changed
- Color scheme system replaced with theme + skin axes — the old monolithic theme list (
dark,slate,solarized,monokai,nord,oled,light) is split into two orthogonal axes: theme (light/dark/system) and skin (accent palette: Default gold, Ares red, Mono gray, Slate blue-gray, Poseidon ocean blue, Sisyphus purple, Charizard orange). Users can now mix any theme with any skin via the new Appearance settings tab. Internally,.darkclass on<html>replacesdata-theme; skin usesdata-skinattribute and overrides only 5 accent CSS vars per skin, eliminating ~200 lines of duplicated palette overrides. (PR #627 by @aronprins)
Migration notes
- Legacy theme names are silently migrated on first load to the closest theme + skin pair:
slate → dark+slate,solarized → dark+poseidon,monokai → dark+sisyphus,nord → dark+slate,oled → dark+default. Both backend (api/config.py::_normalize_appearance) and frontend (static/boot.js::_normalizeAppearance) apply the same mapping. - Custom themes set via
data-themeCSS overrides will reset todark + defaulton first load. The pre-PRthemesetting was open-ended ("no enum gate -- allows custom themes"); the new system enumerates valid values. Users who maintained custom CSS will need to re-apply via a skin choice or by overriding skin variables (--accent,--accent-hover,--accent-bg,--accent-bg-strong,--accent-text).
Fixed
- Send button stays active after clearing composer text — input listener now correctly toggles disabled state. (PR #627)
- Composer workspace/model label flash on page load — chips now wait for
_bootReadybefore populating, eliminating the placeholder-then-real-value flicker. (PR #627) - Topbar border invisible in light mode — added
:root:not(.dark)border override. (PR #627) - User message bubble text contrast — accent-colored bubbles now use skin-aware text colors meeting WCAG AA (Poseidon dark improved from 2.8 → 6.5 ratio). (PR #627)
- Settings skin persistence race condition — save now waits for server confirmation before applying. (PR #627)
[v0.50.76] — 2026-04-17
Fixed
- CSP blocked external images in chat —
img-srcin the Content Security Policy was restricted to'self'anddata:, causing the browser to block any external image URLs (e.g. from Wikipedia, GitHub, or other HTTPS sources) that the agent rendered in a response. Expanded toimg-src 'self' data: https: blob:so external images load correctly. (Closes #608)
[v0.50.75] — 2026-04-17
Fixed
- Test isolation:
pytest tests/was overwriting~/.hermes/.envwith test placeholder keys — two unit tests intest_onboarding_existing_config.pycalledapply_onboarding_setup()in-process without mocking_get_active_hermes_home, so every test run wroteOPENROUTER_API_KEY=test-key-fresh(ortest-key-confirm) to the production.env. Also addedHERMES_BASE_HOMEto the test server subprocess env (hard-locks profile resolution inside the server to the isolated temp state dir) and stripped real provider keys from the inherited subprocess environment. (PR #620)
[v0.50.71] — 2026-04-16
Fixed
- Docker:
HERMES_WEBUI_DEFAULT_WORKSPACEwas silently overridden bysettings.json— the startup block inapi/config.pyunconditionally restored the persisteddefault_workspace, so any container that had previously writtensettings.jsonwould shadow the env var on the next start. The env var now wins when explicitly set, matching the documented priority order. (Closes #609, PR #610) - Docker: workspace trust validation rejected subdirectories of
DEFAULT_WORKSPACE—resolve_trusted_workspace()only trusted paths underPath.home()or in the saved list; subpaths of a Docker volume mount like/data/workspace/myprojectfailed with "outside the user home directory". Added a third trust condition for paths under the boot-timeDEFAULT_WORKSPACE, which was already validated at startup. (Closes #609, PR #610)
[v0.50.70] — 2026-04-16
Changed
- Chat transcript redesigned — unified
--msg-rail/--msg-maxCSS variables align all message elements on one column. User turns render as per-theme tinted cards. Thinking cards are bordered panels with gold rule. Inline code inherits--strong. Action toolbar fades in on hover. Error-prefixed assistant rows get[data-error="1"]red-accent card treatment. Day-change.msg-date-sepseparators added. Transcript fades to transparent behind composer. (PR #587 by @aronprins) - Approval and clarify cards as composer flyouts — cards slide up from behind the composer top edge rather than floating as disconnected banners.
overflow:hiddenouter +translateYinner animation clips travel.focus({preventScroll:true})prevents autoscrolling. (PR #587 by @aronprins)
Fixed
- Streaming lifecycle stabilised — DOM order stays
user → thinking → tool cards → responsewith no mid-stream jump. Live tool cards inserted inline before the live assistant row. Ghost empty assistant header suppressed on pure-tool turns. (PR #587 by @aronprins) - Session reload persistence hardened — last-turn reasoning attached before
s.save(), so hard-refresh right after a response preserves the thinking trace.role=toolrows preserved inS.messages. CLI-session tool-result fallback parses output envelopes and attaches snippets to matching cards. (PR #587 by @aronprins) - Workspace panel first-paint flash fixed —
[data-workspace-panel]attribute set at document parse time via inline script. (PR #587 by @aronprins)
Added
docs/ui-ux/index.html— static inventory of every message-area element loading livestatic/style.css. (PR #587 by @aronprins)docs/ui-ux/two-stage-proposal.html— proposal page for the two-stage plan/execute flow (#536). (PR #587 by @aronprins)
[v0.50.69] — 2026-04-16
Fixed
- Docker: workspace file browser no longer appears empty on macOS —
docker_init.bashnow auto-detects the correctWANTED_UIDandWANTED_GIDfrom the mounted/workspacedirectory at startup. On macOS, host UIDs start at 501 (not 1000), so the default value of 1024 caused the container user to run as a different UID than the files, making the workspace appear empty. The auto-detect readsstat -c '%u'on/workspaceand uses it when no explicitWANTED_UIDis set — falling back to 1024 if the path doesn't exist or returns 0 (root). SettingWANTED_UIDexplicitly in a.envfile still takes full precedence. (Closes #569) - Session message count inconsistency resolved — the topbar already correctly shows only visible messages (excluding
role='tool'tool-call entries). The sidebar previously showed rawmessage_countwhich included tool messages, but PR #584 removed that display entirely — there is no longer any count displayed in the sidebar. No code change needed; documenting with regression tests. (Closes #579)
[v0.50.68] — 2026-04-16
Fixed
- Light theme: add/rename folder dialogs now use correct light colors —
.app-dialog,.app-dialog-input,.app-dialog-btn,.app-dialog-close, and.file-rename-inputhad hardcoded dark-mode backgrounds with no light-theme overrides. Dialog backgrounds, borders, and inputs now adapt correctly to the light theme. (Closes #594) - Workspace panel no longer snaps open then immediately closed — on page load,
boot.jswas restoring the panel open/closed state fromlocalStoragebefore knowing whether the loaded session has a workspace.syncWorkspacePanelState()then snapped it closed, causing a visible jank. The restore is now deferred until afterloadSession()and only applied when the session actually has a workspace. (Closes #576) - Model dropdown reflects CLI model changes without server restart —
/api/modelswas returning a startup-cached snapshot ofconfig.yaml. The fix adds a mtime-based reload check: ifconfig.yamlhas changed on disk since last read, the cache is refreshed before building the model list. Page refresh now picks up CLI model changes immediately. (Closes #585) - Docker Compose: macOS users guided on UID/GID setup — the
docker-compose.ymlcomment forWANTED_UID/WANTED_GIDnow explicitly notes that macOS UIDs start at 501 (not 1000) and tells users to runid -u/id -g. Also clarifies that the default${HOME}/.hermesvolume mount works on both macOS and Linux. (Closes #567) - Voice transcription already shows "Transcribing…" spinner — issue #590 noted that no feedback was shown between pressing stop and text appearing. This was already implemented (
setComposerStatus('Transcribing…')fires before the fetch in_transcribeBlob). Confirmed and documented; closing as already fixed.
[v0.50.67] — 2026-04-16
Added
- Subpath mount support — Hermes WebUI can now be served behind a reverse proxy at any subpath (e.g.
/hermes-webui/via Tailscale Serve, nginx, or Caddy). A dynamic<base href>is injected as the first script in<head>, and all client-side URL references are converted from absolute to relative. The server-side route handlers are unchanged. No configuration needed — works transparently for both root (/) and subpath deployments. (PR #588 by @vcavichini)
[v0.50.66] — 2026-04-16
Fixed
- WebUI agent now receives full runtime route from provider resolver — previously
api_mode,acp_command,acp_args, andcredential_poolwere not forwarded intoAIAgent.__init__()in the WebUI streaming path. Users switching between Codex accounts or using credential pools found the switch worked in the CLI but not the WebUI. The fix passes all four fields from the resolved runtime into the agent constructor. (PR #582 by @suinia)
[v0.50.65] — 2026-04-16
Fixed
HERMES_WEBUI_SKIP_ONBOARDING=1now works unconditionally — previously the env var was gated onchat_ready=True, so hosting providers (e.g. Agent37) that set it but hadn't yet wired up a provider key would still see the wizard on every page load. The var is now honoured as a hard operator override regardless ofchat_ready. If you set it, the wizard is gone. (Fixes skip-onboarding regression)- Onboarding wizard can no longer overwrite config or env files when
SKIP_ONBOARDINGis set —apply_onboarding_setupnow checks the env var first and refuses to touchconfig.yamlor.envif it is set. This is a belt-and-suspenders guard: even if a stale JS bundle somehow triggers the setup endpoint whileSKIP_ONBOARDINGis active, no files are written.
[v0.50.64] — 2026-04-16
Changed
- Sidebar session items decluttered — the meta row under every session title (message count, model slug, and source-tag badge) has been removed. Each session now renders as a single line: title + relative-time bucket headers. The visible session count at a typical viewport height roughly doubles. The
source_tagfield is still populated on the session object and available for a future tooltip or filter facet.[SYSTEM:]-prefixed gateway titles fall back to"Session"rather than leaking system-prompt content. Removes_formatSourceTag(),.session-meta,cli-session,[data-source=…],_SOURCE_DISPLAY, and the associated CSS badge rules. (PR #584 by @aronprins)
[v0.50.63] — 2026-04-16
Fixed
- Onboarding wizard no longer fires for non-standard providers — providers outside the quick-setup list (
minimax-cn,deepseek,xai,gemini, etc.) were always evaluated aschat_ready=Falsebecause_provider_api_key_present()only knew the four built-in env-var names. Those users saw the wizard on every page load and riskedconfig.yamlbeing silently overwritten if the provider dropdown defaulted. The fix adds ahermes_cli.auth.get_auth_status()fallback covering every API-key provider in the full registry, and tightens the frontend guard so an unchanged unsupported-provider form never POSTs. (Fixes #572, PR #575) - MCP server toolsets now included in WebUI agent sessions — previously the WebUI read
platform_toolsets.clidirectly fromconfig.yaml, which only carries built-in toolset names. MCP server names (tidb,kyuubi, etc.) were silently dropped, so MCP tools configured via~/.hermes/config.yamlwere unavailable in chat. The fix delegates tohermes_cli.tools_config._get_platform_tools()— the same code the CLI uses — which merges all enabled MCP servers automatically. Falls back gracefully whenhermes_cliis unavailable. (PR #574 by @renheqiang)
[v0.50.62] — 2026-04-16
Fixed
- Docker startup no longer hard-exits when hermes-agent source is not mounted — previously
docker_init.bashwould callerror_exitif the agent source directory was missing, preventing the container from starting at all. Users running a minimaldocker runwithout the two-container compose setup hit this immediately. Now the script checks for the directory andpyproject.tomlfirst, prints a clear warning explaining reduced functionality, and continues startup. The WebUI already hastry/exceptfallbacks throughout for when hermes-agent is unavailable. (Fixes #570, PR #573)
[v0.50.61] — 2026-04-16
Added
- Office file attachments —
.xls,.xlsx,.doc, and.docxfiles can now be selected via the attach button. The file picker'sacceptattribute is extended to include Office MIME types, and the backend MIME map is updated so these files are served with correct content-type headers when accessed through the workspace file browser. Files are saved as binary to the workspace; the AI can reference them by name the same way it does PDFs. (PR #566 by @renheqiang)
[v0.50.60] — 2026-04-16
Changed
- Test robustness — two onboarding setup tests (
test_setup_allowed_with_confirm_overwrite,test_setup_allowed_when_no_config_exists) now skip gracefully when PyYAML is not installed in the test environment, matching the pattern already used intest_onboarding_mvp.py. No production code changed. (PR #564)
[v0.50.59] — 2026-04-16
Fixed
- False "Connection lost" message after settled stream — the UI no longer injects a fake
**Error:** Connection lostassistant message when an SSE connection drops after the stream already completed normally. The fix tracks terminal stream states (done,stream_end,cancel,apperror) and, on a disconnect, fetches/api/sessionto confirm the session is settled before silently restoring it instead of calling the error path. Real failures still go through the error path as before. (Fixes #561, PR #562 by @halmisen)
[v0.50.58] — 2026-04-16
Fixed
- Custom provider name in model dropdown — when a
custom_providersentry inconfig.yamlhas anamefield (e.g.Agent37), the model picker now shows that name as the group header instead of the genericCustomlabel. Multiple named providers each get their own group. Unnamed entries still fall back toCustom. Brings the web UI into parity with the terminal's provider display. (Fixes #557)
[v0.50.57] — 2026-04-15
Added
- Auto-generated session titles — after the first exchange, a background thread generates a concise title from the first user message and assistant reply, replacing the default first-message substring. Updates live in the UI via a new
titleSSE event. Manual renames are preserved; generation only runs once per session. Includes MiniMax token budget handling and a local heuristic fallback. (Fixes #495, PR #535 by @franksong2702)
Changed
- SSE stream termination — streams now end with
stream_endinstead ofdoneso the background title generation thread has time to emit the title update before the client disconnects.
[v0.50.55] — 2026-04-15
Fixed
- Docker honcho extra —
docker_init.bashnow installshermes-agent[honcho]sohoncho-aiis included in the venv on every fresh Docker build. Fixes"Honcho session could not be initialized."errors on rebuilt containers. (Fixes #553) - Version badge —
index.htmlversion badge corrected to v0.50.55 (was missing the bump for this release).
[v0.50.54] — 2026-04-15
Changed
- OpenRouter model list — updated to 14 current models across 7 providers. All slugs verified live against the OpenRouter catalog. Removed
o4-mini, old Gemini 2.x entries, and Llama 4. Added Claude Opus 4.6, GPT-5.4, Gemini 3.1 Pro Preview, Gemini 3 Flash Preview, DeepSeek R1, Qwen3 Coder, Qwen3.6 Plus, Grok 4.20, and Mistral Large. Both Claude 4.6 and 4.5 generations preserved. Fixedgrok-4-20→grok-4.20slug and Gemini-previewsuffixes.
[v0.50.53] — 2026-04-15
Fixed
- Custom endpoint slash model IDs — model IDs with vendor prefixes that are intrinsic (e.g.
zai-org/GLM-5.1on DeepInfra) are now preserved when routing to a custombase_urlendpoint. Previously, all prefixed IDs were stripped, causingmodel_not_founderrors on providers that require the full vendor/model format. Known provider namespaces (openai/,google/,anthropic/, etc.) are still stripped as before. (Fixes #548, PR #549 by @eba8)
[v0.50.52] — 2026-04-15
Fixed
- Simultaneous approval requests — parallel tool calls that each require approval no longer overwrite each other.
_pendingis now a list per session; each entry gets a stableapproval_id(uuid4) so/api/approval/respondcan target a specific request. The UI shows a "1 of N pending" counter when multiple approvals are queued. Backward-compatible with old agent versions and old frontend clients. Adds 14 regression tests. (Fixes #527)
[v0.50.51] — 2026-04-15
Fixed
- Orphaned tool messages — conversation histories containing
role: toolmessages with no matchingtool_call_idin a prior assistant message are now silently stripped before sending to the provider API. Fixes 400 errors from strictly-conformant providers (Mercury-2/Inception, newer OpenAI models). Adds 13 regression tests. (Fixes #534)
[v0.50.50] — 2026-04-15
Fixed
- Code block syntax highlighting — Prism theme now follows the active UI theme. Light mode uses the default Prism light theme; dark mode uses
prism-tomorrow. Theme swaps happen immediately on toggle including on first load. Addsid="prism-theme"to the Prism CSS link so JavaScript can locate and swap it. (Closes #505, PR #530 by @mariosam95)
[v0.50.49] — 2026-04-15
Fixed
- IME composition —
isComposingguard added to every Enter keydown handler so CJK/Japanese/Korean input method users never accidentally send mid-composition (fixes #531). Covers chat composer, command dropdown, session rename, project create/rename, app dialog, message edit, and workspace rename. Adds 3 regression tests. (PR #537 by @vansour)
[v0.50.48] fix: toast when model is switched during active session (#419)
Synthesized from PRs #516 (armorbreak001), #517 and #518 (cloudyun888).
When a user switches the model via the model picker while a session already has messages, a 3-second toast now reads: "Model change takes effect in your next conversation." This avoids the confusing situation where the dropdown shows the new model but the current conversation continues with the original one.
The toast fires from modelSelect.onchange in static/boot.js, after the
existing provider-mismatch warning. It checks S.messages.length > 0 (the
reliable in-memory array, always initialized by loadSession). The
showToast call is guarded with typeof for safety during boot.
Key differences from submitted PRs: placement in boot.js onchange (covers
all selection paths including chip dropdown, since selectModelFromDropdown
calls sel.onchange), and uses S.messages not S.session.messages.
4 new tests in tests/test_provider_mismatch.py::TestModelSwitchToast.
Total tests: 1272 (was 1268)
[v0.50.47] fix/feat: batch fixes — root workspace, custom providers, cron cache, system theme
Synthesized from PRs #506, #507, #508, #509, #510, #514, #515, #519, #521.
Fixes
Allow /root as a workspace path (PRs #510, #521 by @ccqqlo)
Removes /root from _BLOCKED_SYSTEM_ROOTS in api/workspace.py, so
deployments running as root (Docker, VPS) can set /root as their workspace
without a "system directory" rejection.
Guard against split on missing [Attached files:] (PR #521 by @ccqqlo)
base_text extraction in api/streaming.py now guards: msg_text.split(...)[0] if ... in msg_text else msg_text. Previously split on the empty case returned
an empty string, causing attachment-matching to silently fail on messages with
no attachments.
custom_providers models visible regardless of active provider (#515, #519 by @shruggr, @cloudyun888)
get_available_models() in api/config.py no longer discards the 'custom'
provider from detected_providers when the user has custom_providers entries
in config.yaml. Previously, switching active_provider away from 'custom'
hid all custom model definitions from the picker.
Cron skill picker cache invalidated on form open and skill save (PRs #507, #508 by @armorbreak001)
toggleCronForm() now unconditionally nulls _cronSkillsCache before fetching,
so skills created in the same session appear immediately. submitSkillSave() also
nulls _cronSkillsCache after a successful write, mirroring the existing
_skillsData = null pattern. Fixes #502.
Features
System (auto) theme following OS prefers-color-scheme (#504 / PRs #506, #509, #514 by @armorbreak001, @cloudyun888)
New "System (auto)" option in the theme picker follows the OS dark/light preference
via window.matchMedia. Changes:
static/boot.js:_applyTheme(name)helper resolves 'system' via matchMedia, setsdata-theme, and registers a MQ change listener for live OS tracking.loadSettings()calls_applyTheme()instead of direct assignment.static/index.html: flicker-prevention script resolves 'system' before first paint. Adds "System (auto)" as first theme option. onchange calls_applyTheme().static/commands.js: adds 'system' to valid/themenames.static/panels.js:_settingsThemeOnOpenreads from localStorage (preserves 'system' string)._revertSettingsPreviewcalls_applyTheme().static/i18n.js: cmd_theme description lists 'system' first in all 5 locales.
Tests
22 new tests in tests/test_batch_fixes.py.
Total tests: 1268 (was 1246)
[v0.50.46] feat: clarify dialog flow and refresh recovery (#520)
Adds a full clarify dialog UX for interactive agent questions — modeled after the approval card but for free-form clarification prompts.
Backend
New api/clarify.py module with a per-session pending queue backed by
threading.Event unblocking, gateway notify callbacks, duplicate deduplication
while unresolved, and resolve/clear helpers.
Three new HTTP endpoints in api/routes.py:
GET /api/clarify/pending— poll for pending clarify promptPOST /api/clarify/respond— resolve the pending promptGET /api/clarify/inject_test— loopback-only, for automated tests
api/streaming.py wires clarify_callback into AIAgent.run_conversation().
Emits clarify SSE events; blocks the tool flow until the user responds, times
out (120s), or the stream is cancelled. Also adds a 409 guard on chat/start so
page-refresh races return the active stream id instead of starting a duplicate.
Frontend
static/messages.js: clarify card with numbered choices, Other button, and
free-text input. Composer is locked while clarify is active. DOM self-heals if
the card node is removed during a rerender. SSE clarify event listener plus
1.5s fallback polling. Session switch and reconnect start/stop clarify polling.
409 conflict flow reattaches to the active stream and queues the user message.
CLARIFY_MIN_VISIBLE_MS = 30000 timer dedup mirrors the approval card pattern.
static/ui.js: lockComposerForClarify() / unlockComposerForClarify() with
saved-state restore. updateSendBtn() respects the disabled state.
static/sessions.js: loadSession() starts/stops clarify polling on switch
and inflight reattach.
static/index.html / static/style.css: clarify card markup with ARIA roles
and full responsive/mobile styles.
static/i18n.js: 6 new keys in all 5 locales (en, es, de, zh-Hans, zh-Hant).
Tests
tests/test_clarify_unblock.py: 14 new tests covering queue resolution, notify callbacks, clear-on-cancel, and all three HTTP endpoints.tests/test_sprint30.py: 31 new clarify tests (HTML markup, CSS classes, i18n keys, messages.js functions, streaming registration flags).tests/test_sprint36.py: expand search window forsetBusycheck after additionalstopClarifyPolling()calls push it past the old 800-char limit.
Total tests: 1246 (was 1209)
Co-authored-by: franksong2702
[v0.50.45] fix: suppress N/A source_tag in session list (#429)
Feishu and WeChat sessions (and any session with an unrecognised or legacy
source value in hermes-agent's state.db) were showing "N/A" or raw tag
strings in the session list sidebar.
Three fixes in static/sessions.js:
-
_formatSourceTag()now returnsnullfor unrecognised tags instead of the raw string. Known platforms (telegram, discord, slack, feishu, weixin, cli) still display their human-readable label. Unknown/legacy values are silently suppressed. -
The
metaBitspush is guarded: stores the result in_stLabeland only pushes if it is non-null. Preventsnullor unrecognised platform names from appearing in the session metadata line. -
The
[SYSTEM:]title fallback now uses_SOURCE_DISPLAY[s.source_tag] || 'Gateway'— the raws.source_tagmiddle term is removed so a session whose source is "N/A" does not use that as its visible title.
No backend changes. The upstream issue (hermes-agent not reliably setting
source for older Feishu/WeChat sessions) is tracked separately.
7 new tests in tests/test_issue429.py. Updated 1 existing test in
tests/test_sprint40_ui_polish.py to match the new guarded push pattern.
- Total tests: 1202 (was 1195)
[v0.50.44] fix: code-in-table CSS sizing + markdown image rendering (#486, #487)
CSS: inline code inside table cells (fixes #486)
Inline `code` spans inside <td> and <th> cells were rendering too
large relative to the cell height — the .msg-body code rule sets 12.5px
which sits awkward against the table's 12px base font.
Fix: added two targeted rules in static/style.css:
.msg-body td code,.msg-body th code { font-size:0.85em; padding:1px 4px; vertical-align:baseline; }
.preview-md td code,.preview-md th code { font-size:0.85em; padding:1px 4px; vertical-align:baseline; }
Covers both the chat message surface (.msg-body) and the markdown preview
panel (.preview-md).
JS renderer:  image syntax (fixes #487)
Standard markdown image syntax was not handled by renderMd(). The ! was
left as a stray character and [alt](url) was consumed by the link pass,
producing ! <a href="url">alt</a> instead of an <img>.
Fix: added an image pass to both inlineMd() (for images in table cells,
list items, blockquotes, headings) and the outer renderMd() pipeline (for
images in plain paragraphs):
- Regex:
— onlyhttp://andhttps://URIs accepted;javascript:anddata:URIs cannot match. - Alt text passes through
esc()— XSS-safe. - URL double-quotes percent-encoded to
%22— attribute breakout prevented. - Reuses
.msg-media-imgclass — same click-to-zoom and max-width styling as agent-emittedMEDIA:images. imgadded toSAFE_TAGSallowlist so the generated<img>is not escaped.- In
inlineMd(): image pass runs while the_code_stashis still active, soinside a backtick span stays protected and is never rendered as an image. A new_img_stash(\x00G) protects rendered<img>tags from the autolink pass touchingsrc=values.
Tests
45 new tests in tests/test_issue486_487.py:
-
13 CSS source checks and rendering tests for #486
-
22 JS source checks and rendering tests for #487
-
10 combination edge cases (code + image + link all in same table)
-
Total tests: 1195 (was 1150)
[v0.50.43] fix: markdown link rendering + KaTeX CSP fonts
Markdown link rendering — renderMd() in static/ui.js (PR #475, fixes #470)
Three related bugs fixed:
-
Double-linking via autolink pass —
[label](url)was converted to<a href="...">, then the bare-URL autolink pass re-matched the URL sitting insidehref="..."and wrapped it in a second<a>tag. Fixed with three stash/restore layers:\x00L(inlineMd labeled links),\x00A(existing<a>tags before outer link pass),\x00B(existing<a>tags before autolink pass). -
esc()onhrefvalues corrupts query strings —esc()is HTML-entity encoding; applying it to URLs converted&→&in query strings. Removedesc()from href values in all three locations. Display text (link labels) still usesesc()for XSS safety."in URLs replaced with%22(URL encoding) to close the attribute-injection vector identified during review. -
Backtick code spans inside
**bold**rendered as<code>—esc()was applied to code spans after bold/italic processing. Added\x00Cstash to protect backtick spans ininlineMd()before bold/italic regex runs.
Security audit: javascript: injection blocked by https?:// prefix requirement. " attribute breakout fixed by .replace(/"/g, '%22'). Label/display text still HTML-escaped.
24 tests in tests/test_issue470.py.
KaTeX CSP font-src (fixes #477)
api/helpers.py CSP font-src now includes https://cdn.jsdelivr.net so KaTeX math rendering fonts load correctly. Previously ~50 CSP font-blocking errors appeared in the console on any page with math content. The CDN was already allowed in script-src and style-src for KaTeX JS/CSS — this extends the same allowance to fonts.
3 tests in tests/test_issue477.py.
- Total tests: 1150 (was 1130)
[v0.50.42] fix: session display + model UX polish (sprint 42)
Context indicator always shows latest usage (PR #471, fixes #437)
The context ring/indicator in the composer footer was reading token counts and cost
from the stored session snapshot with || — meaning stale non-zero values from
previous turns always won over a fresh 0 from the current turn. Replaced all six
field merges with a _pick(latest, stored, dflt) helper that correctly prefers the
latest usage when it's a real value (including 0).
System prompt no longer leaks as gateway session title (PR #472, fixes #441)
Telegram, Discord, and CLI gateway sessions inject a system message before any user
turn. When the session title is set from this message, the sidebar shows
[SYSTEM: The user has inv... instead of a meaningful name. Added a guard in
_renderOneSession(): if cleanTitle starts with [SYSTEM:, replace it with the
platform display name (Telegram session, Discord session, etc.).
Thinking/reasoning panel persists across page reload (PR #473, fixes #427) The full chain-of-thought from Claude, Gemini, and DeepSeek thinking models was lost after streaming completed and on every page reload. Two-part fix:
api/streaming.py:on_reasoning()now accumulates_reasoning_text; before the session is serialised at stream end,_reasoning_textis injected into the last assistant message so it's stored in the session JSONstatic/messages.js: in thedoneSSE handler,reasoningTextis also patched onto the last assistant message as a belt-and-suspenders client-side fallback
Custom model ID input in model picker (PR #474, fixes #444)
Users who need a model not in the curated list (~30 models) can now type any model
ID directly in the dropdown. A text input at the bottom of the model picker lets
users enter any string (e.g. openai/gpt-5.4, deepseek/deepseek-r2, or any
provider-prefixed ID) and press Enter or click + to use it immediately.
i18n keys added to en, es, zh.
- Total tests: 1130 (was 1117)
[v0.50.41] feat(ui): render MEDIA: images inline in web UI chat (fixes #450)
When the agent outputs MEDIA:<path> tokens — screenshots from the browser tool,
generated images, vision outputs — the web UI now renders them inline in the chat,
the same way Claude.ai handles images. No more relaying screenshots through Telegram.
How it works:
- Local image path (
MEDIA:/tmp/screenshot.png): rendered as<img>via/api/media?path=... - HTTP(S) URL to image (
MEDIA:https://example.com/img.png):<img>directly from the URL - Non-image file (
MEDIA:/tmp/report.pdf): styled download link (📎 filename) - Click any inline image to toggle full-size zoom
New endpoint — GET /api/media?path=<encoded-path>:
- Path allowlist:
~/.hermes/,/tmp/, active workspace — covers all agent output locations - Auth-gated: requires valid session cookie when auth is enabled
- Inline image MIME types: PNG, JPEG, GIF, WebP, BMP
- SVG always served as download attachment (XSS prevention)
- RFC 5987-compliant
Content-Dispositionheaders (handles Unicode filenames) Cache-Control: private, max-age=3600
Security:
- Original version had
~(entire home dir) as an allowed root — fixed by independent reviewer - Restricted to
~/.hermes/,/tmp/, and active workspace only Path.resolve()+commonpathchecks prevent symlink traversal
Changes:
-
api/routes.py:_handle_media()handler +/api/mediaroute -
static/ui.js:MEDIA:stash inrenderMd()(runs beforefence_stash, stash token\x00D) -
static/style.css:.msg-media-img(480px max-width, zoom-on-click),.msg-media-link -
tests/test_media_inline.py: 19 new tests (static analysis + integration) -
Total tests: 1117 (was 1098)
[v0.50.40] feat: session UI polish + parallel test isolation
Session sidebar improvements:
static/sessions.js+style.css: Hide session timestamps to give titles full available width — no more title truncation from inline timestamps (PR #449)static/style.css: Active session title now usesvar(--gold)theme variable instead of hardcoded#e8a030— adapts correctly across all 7 themes (PR #451, fixes #440)api/models.py+api/gateway_watcher.py: ReturnNoneinstead of the string'unknown'for missing gateway session model — Telegram sessions no longer showtelegram · unknown(PR #452, fixes #443)static/style.css+static/sessions.js: Mute Telegram badge from saturated#0088cctorgba(0, 136, 204, 0.55). Add_formatSourceTag()helper mapping platform IDs to display names (telegram→via Telegram) (PR #453, fixes #442)
Bug fixes:
api/config.pyresolve_model_provider(): Strip provider prefix from model ID when a custombase_urlis configured (openai/gpt-5.4→gpt-5.4) — fixes broken chats after switching to a custom endpoint (PR #454, fixes #433)static/panels.jsswitchToProfile(): Apply profile default workspace to new session created during profile switch — workspace chip no longer shows "No active workspace" after switching profiles mid-conversation (PR #455, fixes #424)
Test infrastructure:
-
tests/conftest.py+tests/_pytest_port.py(new): Auto-derive unique port and state dir per worktree from repo path hash (range 20000-29999). Running pytest in two worktrees simultaneously no longer causes port conflicts. All 43 test files updated from hardcodedBASE = "http://127.0.0.1:8788"tofrom tests._pytest_port import BASE(PR #456) -
Total tests: 1098 (was 1078)
[v0.50.39] fix: orphan gateway sessions + first-password-enablement session continuity
Two bug fixes:
PR #423 — Fix orphan gateway sessions in sidebar (@aronprins, fix by maintainer)
gateway_watcher.py's _get_agent_sessions_from_db() was missing the
HAVING COUNT(m.id) > 0 clause that get_cli_sessions() already had. Sessions
with no messages (e.g. created then abandoned before any turns) would appear in the
sidebar via the SSE watcher stream even after the initial page load filtered them out.
One-line SQL fix applied to both query paths.
PR #434 — First-password-enablement session continuity (@SaulgoodMan-C)
When a user enables a password for the first time via POST /api/settings,
the current browser session was being terminated — requiring the user to log in
again immediately after setting their password. Fix: the response now includes
auth_enabled, logged_in, and auth_just_enabled fields, and issues a
hermes_session cookie when auth is first enabled, so the browser remains logged in.
Also: legacy assistant_language key is now dropped from settings on next save.
New i18n keys for password replacement/keep-existing states (en, es, de, zh, zh-Hant).
-
api/config.py:_SETTINGS_LEGACY_DROP_KEYSremovesassistant_languageon load -
api/routes.py: first-password-enable session continuity withauth_just_enabledflag -
static/panels.js:_setSettingsAuthButtonsVisible()+_applySavedSettingsUi()helpers -
static/i18n.js: password state i18n keys across 5 locales -
tests/test_sprint45.py: 3 new integration tests (auth continuity + legacy key cleanup) -
Total tests: 1078 (was 1075)
[v0.50.38] feat: mobile nav cleanup, Prism syntax highlighting, zh-CN/zh-Hant i18n
Three community contributions combined:
PR #425 — Remove mobile bottom nav (@aronprins)
The fixed iOS-style bottom navigation bar on phones has been removed. The sidebar drawer
tabs already handle all navigation — the bottom nav was redundant and consumed ~56px of
vertical chat space. test_mobile_layout.py updated with test_mobile_bottom_nav_removed()
and new sidebar nav coverage tests.
PR #426 — Prism syntax highlighting with light + dark theme token colors (@GiggleSamurai)
Fenced code blocks now emit class="language-{lang}" on <code> elements, enabling Prism's
autoloader to apply token-level syntax highlighting. Added 36-line :root[data-theme="light"]
token color overrides scoped to light theme only; dark/dim/monokai/nord themes unaffected.
Background guard uses var(--code-bg) !important to prevent Prism's dark background from
overriding theme variables. 2 new regression tests in test_issue_code_syntax_highlight.py.
PR #428 — zh-CN/zh-Hant i18n hardening (@vansour)
Pluggable resolvePreferredLocale() function with smart zh-CN/zh-SG/zh-TW/zh-HK variant
mapping. Full zh-Simplified and zh-Traditional locale blocks added to i18n.js. Login page
locale routing updated in api/routes.py (_resolve_login_locale_key() helper). Hardcoded
strings in panels.js cron UI extracted to i18n keys. 3 new test files:
test_chinese_locale.py, test_language_precedence.py, test_login_locale.py.
- Total tests: 1075 (was 1063)
[v0.50.37] fix(onboarding): skip wizard when Hermes is already configured
Fixes #420 — existing Hermes users with a valid config.yaml were shown the first-run
onboarding wizard on every WebUI load because the only completion gate was
settings.onboarding_completed in the WebUI's own settings file. Users who configured
Hermes via the CLI before the WebUI existed had no such flag, so the wizard always fired
and could silently overwrite their working config.
Changes:
api/onboarding.pyget_onboarding_status(): auto-complete whenconfig.yamlexists ANDchat_ready=True. Existing configured users are never shown the wizard.api/onboarding.pyapply_onboarding_setup(): refuse to overwrite an existingconfig.yamlwithoutconfirm_overwrite=Truein the request body. Returns{error: "config_exists", requires_confirm: true}for the frontend to handle.static/index.html: "Skip setup" button added to wizard footer — users are never trapped in the wizard.static/onboarding.js:skipOnboarding()calls/api/onboarding/completewithout modifying config, then closes the overlay.static/boot.js: Escape key now dismisses the onboarding overlay.static/i18n.js:onboarding_skip/onboarding_skippedkeys added to en + es locales.tests/test_onboarding_existing_config.py: 8 new unit tests covering gate logic and overwrite guard.
- Total tests: 1063 (was 1055)
[v0.50.36] fix: workspace list cleaner — allow own-profile paths, remove brittle string filter
Two bugs in _clean_workspace_list() caused workspace additions to silently disappear on the next load_workspaces() call, breaking test_workspace_add_no_duplicate and test_workspace_rename (and potentially causing real-world workspace list corruption):
Bug 1 — Brittle string filter removed: if 'test-workspace' in path or 'webui-mvp-test' in path: continue dropped any workspace path containing those substrings. In the test server, TEST_WORKSPACE is ~/.hermes/profiles/webui/webui-mvp-test/test-workspace, so every workspace added during tests was silently discarded on the next load_workspaces() call. The p.is_dir() check already handles genuinely non-existent paths — the string filter was redundant and harmful.
Bug 2 — Cross-profile filter was too broad: if p is under ~/.hermes/profiles/: skip was designed to block cross-profile workspace leakage, but it also removed paths under the current profile's own directory (e.g. ~/.hermes/profiles/webui/...). Fixed: now only skips paths under profiles/ that are NOT under the current profile's own hermes_home.
api/workspace.py: remove string-match filter; fix cross-profile check to allow own-profile paths- All 1055 tests now pass (was 1053 pass + 2 fail)
[v0.50.35] fix: workspace trust boundary — cross-platform, multi-workspace support
v0.50.34's workspace trust check was too restrictive: it required all workspaces to be under DEFAULT_WORKSPACE (/home/hermes/workspace), which blocked every profile-specific workspace (~/CodePath, ~/hermes-webui-public, ~/WebUI, ~/Camanji, etc.) and prevented switching between workspaces at all.
Replaced with a three-layer model that works cross-platform and supports multiple workspaces per profile:
- Blocklist —
/etc,/usr,/var,/bin,/sbin,/boot,/proc,/sys,/dev,/root,/lib,/lib64,/opt/homebrewalways rejected, closing the original CVSS 8.8 vulnerability - Home-directory check — any path under
Path.home()is trusted;Path.home()is cross-platform (~/...on Linux/macOS,C:\\Users\\...on Windows); allows all profile workspaces simultaneously since they don't need to share a single ancestor - Saved-workspace escape hatch — paths already in the profile's saved workspace list are trusted regardless of location, covering self-hosted deployments with workspaces outside home (
/data/projects,/opt/workspace, etc.)
api/workspace.py: rewrittenresolve_trusted_workspace()with the three-layer modeltests/test_sprint3.py: updated error-message assertions from"trusted workspace root"→"outside"(covers both old and new error strings)- 1053 tests total (unchanged)
[v0.50.34] fix(workspace): restrict session workspaces to trusted roots [SECURITY] (#415)
Session creation, update, chat-start, and workspace-add endpoints accepted arbitrary caller-supplied workspace paths. An authenticated caller could repoint a session to any directory the process could access, then use normal file read/write APIs to operate on attacker-chosen locations. CVSS 8.8 High (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H).
api/workspace.py: newresolve_trusted_workspace(path)helper — resolves path, checks existence + is_dir, enforcespath.relative_to(_BOOT_DEFAULT_WORKSPACE)containment; requests outside the WebUI workspace root fail with 400api/routes.py: applyresolve_trusted_workspace()to all four entry points —POST /api/session/new,POST /api/session/update,POST /api/chat/start(workspace override),POST /api/workspaces/addtests/test_sprint3.py,tests/test_sprint5.py: regression tests for rejected outside-root paths on all four entry points; existing workspace tests updated to use trusted child directoriestests/test_sprint1.py,tests/test_sprint4.py,tests/test_sprint13.py: aligned to new trusted-root contract- Fix: use
_BOOT_DEFAULT_WORKSPACE(respectsHERMES_WEBUI_DEFAULT_WORKSPACEenv for test isolation) rather than_profile_default_workspace()(reads agent terminal.cwd which may differ) - Original PR by @Hinotoi-agent (cherry-picked; branch was 6 commits behind master)
- 1053 tests total (up from 1051; 2 pre-existing test_sprint5 isolation failures on master, not introduced by this PR)
[v0.50.33] fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview (#413)
Bug 1 — Duplicate X on desktop: #btnClearPreview (the X icon) was always visible regardless of panel state, so desktop browse mode showed both the chevron collapse button and the X simultaneously. Fixed in syncWorkspacePanelUI(): on non-compact (desktop) viewports, clearBtn.style.display is set to none when no file preview is open, and cleared (shown) when a preview is active.
Bug 2 — Mobile X collapsed the whole panel instead of dismissing the file: .mobile-close-btn was wired to closeWorkspacePanel() directly, bypassing the two-step close logic. Fixed by changing onclick to handleWorkspaceClose(), which calls clearPreview() first if a file is open, and falls through to closeWorkspacePanel() otherwise.
Also: widened the test_server_delete_invalidates_index window from 600 → 1200 chars to accommodate the session_id validation guards added in v0.50.32 (#412).
static/boot.js:syncWorkspacePanelUI()setsclearBtn.style.displaybased onhasPreviewwhen!isCompactstatic/index.html:.mobile-close-btnonclick changed fromcloseWorkspacePanel()tohandleWorkspaceClose()tests/test_sprint44.py: 10 new regression tests covering both fixestests/test_mobile_layout.py: updated to accepthandleWorkspaceClose()as valid onclicktests/test_regressions.py: widened delete handler window to 1200 chars- 1051 tests total (up from 1041)
[v0.50.32] fix(sessions): validate session_id before deleting session files [SECURITY] (#409)
/api/session/delete accepted arbitrary session_id values from the request body and built the delete path directly as SESSION_DIR / f"{sid}.json". Because pathlib discards the prefix when sid is an absolute path, an attacker could supply /tmp/victim and cause the server to unlink victim.json outside the session store. Traversal-style values (../../etc/target) were also accepted. CVSS 8.1 High (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H).
api/routes.py: validatesession_idagainst[0-9a-z_]+allowlist (coversuuid4().hex[:12]WebUI IDs andYYYYMMDD_HHMMSS_hexCLI IDs) before path construction; resolve candidate path and enforcepath.relative_to(SESSION_DIR)containment before unlinking; only invalidate session index on successful deletion path, not on rejected requeststests/test_sprint3.py: 2 new regression tests — absolute-path payload rejected and file preserved, traversal payload rejected and file preserved- Original PR by @Hinotoi-agent (cherry-picked; branch was 4 commits behind master)
- 1041 tests total (up from 1039)
[v0.50.31] fix: delegate all live model fetching to agent's provider_model_ids()
_handle_live_models() in api/routes.py previously maintained its own per-provider fetch logic and returned not_supported for Anthropic, Google, and Gemini. Now it delegates entirely to the agent's hermes_cli.models.provider_model_ids() — the single authoritative resolver — and _fetchLiveModels() in ui.js no longer skips any provider.
What each provider now returns (live data where credentials are present, static fallback otherwise):
anthropic— live fromapi.anthropic.com/v1/models(API key or OAuth token with correct beta headers)copilot— live fromapi.githubcopilot.com/modelswith required Copilot headersopenai-codex— Codex OAuth endpoint →~/.codex/cache →DEFAULT_CODEX_MODELSnous— live from Nous inference portaldeepseek,kimi-coding— generic OpenAI-compat/v1/modelsopencode-zen,opencode-go— OpenCode live catalogopenrouter— curated static list (live returns 300+ which floods the picker)google,gemini,zai,minimax— static list (non-standard or Anthropic-compat endpoints)- All others — graceful static fallback from
_PROVIDER_MODELS
The hardcoded lists in _PROVIDER_MODELS remain as credential-missing / network-unavailable fallbacks. api/routes.py shrank by ~100 lines. Updated 2 tests to reflect the improved behavior.
- 1039 tests total (up from 1038)
[v0.50.30] fix: openai-codex live model fetch routes through agent's get_codex_model_ids()
_handle_live_models() was grouping openai-codex with openai and sending GET https://api.openai.com/v1/models — which returns 403 because Codex auth is OAuth-based via chatgpt.com, not a standard API key. The live fetch silently failed, so users only ever saw the hardcoded static list.
api/routes.py: dedicated early-return branch foropenai-codexthat callshermes_cli.codex_models.get_codex_model_ids()— the same resolver the agent CLI uses. Resolution order: live Codex API (if OAuth token available, hitschatgpt.com/backend-api/codex/models) →~/.codex/local cache (written by the Codex CLI) →DEFAULT_CODEX_MODELShardcoded fallback. Users with a valid Codex session now get their exact subscription model list including any models not in the hardcoded list.api/routes.py: improved label generation for Codex model IDs (e.g.gpt-5.4-mini→GPT 5.4 Mini)tests/test_opencode_providers.py: structural regression test verifying the dedicatedopenai-codexbranch exists and callsget_codex_model_ids()- 1038 tests total (up from 1037)
[v0.50.29] fix: correct tool call card rendering on session load after context compaction (closes #401) (#402)
static/sessions.js: replace the flat B9 filter inloadSession()with a full sanitization pass that buildsorigIdxToSanitizedIdx— eachsession.tool_calls[].assistant_msg_idxis remapped to the new sanitized-array position as messages are filtered; for tool calls whose empty-assistant host was filtered out, they attach to the nearest prior kept assistantstatic/sessions.js: setS.toolCalls=[]instead of pre-filling from session-leveltool_calls— this letsrenderMessages()use its fallback derivation from per-messagetool_calls(which already carry correct indices into the sanitized message array); the fix eliminates the "200+ tool cards all on the wrong message" symptom on context-compacted session loadtests/test_issue401.py: 8 regression tests — 4 static structural checks and 4 behavioural Node.js tests covering index remapping, multiple consecutive empty assistants, no-filtering pass-through, andtool-role message exclusion- Original PR by @franksong2702 (cherry-picked onto master; branch was 31 commits behind)
- 1037 tests total (up from 1029)
[v0.50.28] fix: expand openai-codex model catalog to match DEFAULT_CODEX_MODELS
_PROVIDER_MODELS["openai-codex"] only listed codex-mini-latest, so profiles using the openai-codex provider (e.g. a CodePath profile with default: gpt-5.4) showed only one entry in the model dropdown. Updated to mirror the agent's authoritative DEFAULT_CODEX_MODELS list: gpt-5.4, gpt-5.4-mini, gpt-5.3-codex, gpt-5.2-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, codex-mini-latest. Added 2 regression tests.
- 1029 tests total (up from 1027)
[v0.50.27] feat: relative time labels in session sidebar (#394)
static/sessions.js: new_sessionCalendarBoundaries()(DST-safe vianew Date(y,m,d)construction),_localDayOrdinal(),_formatSessionDate()(includes year for dates from prior years);_formatRelativeSessionTime()now uses calendar midnight boundaries consistent with_sessionTimeBucketLabel()— no more label/bucket mismatch; all relative time strings callt()for localization; meta row only appended when non-empty (removes redundant group-header fallback); deadONE_DAYconstant removedstatic/style.css: addsession-item.active .session-title{color:#1a5a8a}to light-theme block (fixes active title color in light mode)static/i18n.js: 11 new i18n keys (session_time_*) in both English and Spanish locale blocks; callable keys use arrow-function pattern consistent with existingn_messagestests/test_session_sidebar_relative_time.py: 5 tests — structural presence checks, behavioral Node.js tests via subprocess (yesterday/week boundary correctness,just nowthreshold, year-in-date for old sessions, full i18n key coverage for en+es)- Original PR by @Jordan-SkyLF (two-pass review: blocking issues fixed in second commit)
- 1027 tests total (up from 1022)
[v0.50.26] fix(sessions): redact sensitive titles in session list and search responses [SECURITY] (#400)
api/routes.py: apply_redact_text()to session titles in all four response paths —/api/sessionsmerged list,/api/sessions/searchempty-q, title-match, and content-match; usedict(s)copy before mutating to avoid corrupting the in-memory session cachetests/test_session_summary_redaction.py: 2 integration tests verifyingsk-prefixed secrets in session titles are redacted from both list and search endpoint responses- Original PR by @Hinotoi-agent (note: fix commit had a display artifact —
sk-prefix was visually rendered as***in terminal output but the actual bytes were correct and the token was recognized by the redaction engine) - 1022 tests total (up from 1020)
[v0.50.25] Multi-PR batch: mobile scroll, import timestamps, profile security, mic fallback
fix: restore mobile chat scrolling and drawer close (#397)
static/style.css:min-height:0on.layoutand.main(flex shrink chain fix);-webkit-overflow-scrolling:touch,touch-action:pan-y,overscroll-behavior-y:containon.messagesstatic/boot.js: callcloseMobileSidebar()on new-conversation button and Ctrl+K shortcut so the transcript is visible immediately after starting a chattests/test_mobile_layout.py: 41 new lines covering CSS fixes and both JS call sites- Original PR by @Jordan-SkyLF
fix: preserve imported session timestamps (#395)
api/models.py:Session.save(touch_updated_at=True)— new flag;import_cli_session()acceptscreated_at/updated_atkwargs and saves withtouch_updated_at=Falseapi/routes.py: extractcreated_at/updated_atfromget_cli_sessions()metadata and forward to import; post-import save also usestouch_updated_at=Falsetests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly; also fix session file cleanup in test finally block- Original PR by @Jordan-SkyLF
fix(profiles): block path traversal in profile switch and delete flows (#399) [SECURITY]
api/profiles.py: new_resolve_named_profile_home(name)— validates name via^[a-z0-9][a-z0-9_-]{0,63}$regex then enforces path containment viacandidate.resolve().relative_to(profiles_root); use inswitch_profile()api/profiles.py: add_validate_profile_name()call todelete_profile_api()entryapi/routes.py: add_validate_profile_name()at HTTP handler level for both/api/profile/switchand/api/profile/deletetests/test_profile_path_security.py: 3 new tests — traversal rejected, valid name passes (cherry-picked from @Hinotoi-agent's PR, which was 62 commits behind master)
feat: add desktop microphone transcription fallback (#396)
-
static/boot.js: detect_canRecordAudio; keep mic button enabled when MediaRecorder available even without SpeechRecognition; full MediaRecorder recording →/api/transcribefallback path with proper cleanup and error handling -
api/upload.py: addtranscribe_audio()helper — temp file, calls transcription_tools, always cleans up -
api/routes.py: add/api/transcribePOST handler — CSRF-protected, auth-gated, 20MB limit -
api/helpers.py: changePermissions-Policymicrophone=()→microphone=(self)(required for getUserMedia) -
tests/test_voice_transcribe_endpoint.py: 87 new lines (3 tests with mocked transcription) -
tests/test_sprint19.py: regression guard for microphone Permissions-Policy -
tests/test_sprint20.py: 3 updated tests for new fallback capability checks -
Original PR by @Jordan-SkyLF
-
1020 tests total (up from 1003)
[v0.50.24] feat: opt-in chat bubble layout (closes #336)
api/config.py: Addbubble_layoutbool to_SETTINGS_DEFAULTS(defaultFalse) and_SETTINGS_BOOL_KEYS— new setting is opt-in, server-persisted, and coerced to bool on savestatic/style.css: 11 lines of CSS-only bubble layout — user rowsalign-self:flex-end/ max-width 75%, assistant rowsflex-start, all gated onbody.bubble-layoutclass so the default full-width canvas is untouched; 700px responsive rule widens to 92%static/boot.js: Applybody.bubble-layoutclass from settings on page load; explicitly remove the class in the catch path so the feature stays off on API failurestatic/panels.js: Load checkbox state inloadSettingsPanel; writebody.bubble_layoutinsaveSettingsand immediately togglebody.bubble-layoutclass for live preview without a page reloadstatic/index.html: Checkbox in the Appearance settings group, positioned between Show token usage and Show agent sessionsstatic/i18n.js: English label + description keys; Spanish translations included in the same PRtests/test_issue336.py: 22 new tests covering config registration, JS class management in boot and panels, CSS selectors, HTML structure, i18n coverage for en+es, and API round-trip (default false, persist true/false, bool coercion)- 1003 tests total (up from 981)
[v0.50.23] Add OpenCode Zen and Go provider support (fixes #362)
api/config.py: Addopencode-zenandopencode-goto_PROVIDER_DISPLAY— providers now show human-readable names in the UI instead of raw IDsapi/config.py: Add full model catalogs for both providers to_PROVIDER_MODELS— Zen (pay-as-you-go credits, 32 models) and Go (flat-rate $10/month, 7 models) now show the correct model list in the dropdown instead of falling through to the unknown-provider fallbackapi/config.py: AddOPENCODE_ZEN_API_KEY/OPENCODE_GO_API_KEYto the env-var fallback detection path — providers are correctly detected as authenticated when keys are set in.envtests/test_opencode_providers.py: 6 new tests covering display registration, model catalog registration, and env-var detection for both providers- 985 tests total (up from 979)
[v0.50.22] Onboarding unblocked for reverse proxy / SSH tunnel deployments (fixes #390)
api/routes.py: Onboarding setup endpoint now readsX-Forwarded-ForandX-Real-IPheaders before falling back to raw socket IP — reverse proxy (nginx/Caddy/Traefik) and SSH tunnel users are no longer incorrectly blocked- Added
HERMES_WEBUI_ONBOARDING_OPEN=1env var escape hatch for operators on remote servers who control network access themselves - Error message now includes the env var hint so users know how to unblock themselves
- 18 new tests covering all IP resolution paths (
TestOnboardingIPLogic,TestOnboardingSetupEndpoint)
Living document. Updated at the end of every sprint. Repository: https://github.com/nesquena/hermes-webui
[v0.50.21] Live reasoning, tool progress, and in-flight session recovery (PR #367)
- Durable inflight reload recovery (
static/ui.js,static/messages.js):saveInflightState/loadInflightState/clearInflightStatebacked bylocalStorage(hermes-webui-inflight-statekey, per-session, 10-minute TTL). Snapshots are saved on every token, tool event, and tool completion, and cleared when the run ends/errors/cancels. On a full page reload with an active stream,loadSession()hydrates from the snapshot before callingattachLiveStream(..., {reconnecting:true})— partial messages, live tool cards, and reasoning text all survive the reload. - Live reasoning cards during streaming (
static/ui.js,static/messages.js): The generic thinking spinner now upgrades to a live reasoning card when the backend streams reasoning text._thinkingMarkup(text)andupdateThinking(text)centralize the markup so the spinner and card share the same DOM slot. Works with models that emit reasoning via the agent'sreasoning_callbackortool_progress_callback. tool_completeSSE events (api/streaming.py,static/messages.js): Tool progress callback now accepts the current agent signatureon_tool(*cb_args, **cb_kwargs)— handles both the old 3-arg(name, preview, args)form and the new 4-arg(event_type, name, preview, args)form.tool.completedevents transition live tool cards from running to done cleanly.- In-flight session state stable across switches (
static/messages.js,static/sessions.js):attachLiveStreamrefactored out ofsend()into a standalone function; partial assistant text mirrored intoINFLIGHTstate on every token;data-live-assistantDOM anchor preserved acrossrenderMessages()calls so switching away and back doesn't lose or duplicate live output. - Reload recovery (
api/models.py,api/routes.py,api/streaming.py,static/sessions.js):active_stream_id,pending_user_message,pending_attachments, andpending_started_atnow persisted on the session object before streaming starts and cleared on completion (or exception)./api/sessionreturns these fields. After a page reload or session switch,loadSession()detectsactive_stream_idand callsattachLiveStream(..., {reconnecting:true})to reattach to the live SSE stream. - Session-scoped message queue (
static/ui.js,static/messages.js): GlobalMSG_QUEUEreplaced withSESSION_QUEUESkeyed by session ID. Queued follow-up messages are associated with the session they were typed in and only drained when that session becomes idle — no cross-session bleed. newSession()idle reset (static/sessions.js): SetsS.busy=false,S.activeStreamId=null, clears the cancel button, resets composer status — ensures a fresh chat is immediately usable even if another session's stream is still running.- Todos survive session reload (
static/panels.js):loadTodos()now reads fromS.session.messages(raw, includes tool-role messages) rather thanS.messages(filtered display), so todo state reconstructed from tool outputs survives reloads.- 12 new regression tests in
tests/test_regressions.py; 961 tests total (up from 949)
- 12 new regression tests in
[v0.50.20] Silent error fix, stale model cleanup, live model fetching (fixes #373, #374, #375)
Fix: Chat no longer silently swallows agent failures (fixes #373)
api/streaming.py: Afterrun_conversation()completes, the server now checks whether the agent produced any assistant reply. If not (e.g., auth error swallowed internally, model unavailable, network timeout), it emits anapperrorSSE event with a clear message and type (auth_mismatchorno_response) instead of silently emittingdone. A_token_sentflag tracks whether any streaming tokens were sent.static/messages.js: Thedonehandler has a belt-and-suspenders guard — ifdonearrives but no assistant message exists in the session (theapperrorpath should usually catch this first), an inline "No response received." message is shown. Theapperrorhandler now also recognises the newno_responsetype with a distinct label.
Cleanup: Remove stale OpenAI models from default list (fixes #374)
api/config.py:gpt-4oando3removed from_FALLBACK_MODELSand_PROVIDER_MODELS["openai"]. Both are superseded by newer models already in the list (gpt-5.4-minifor general use,o4-minifor reasoning). The Copilot provider list retainsgpt-4oas it remains available via the Copilot API.
Feature: Live model fetching from provider API (closes #375)
api/routes.py: New/api/models/live?provider=openaiendpoint. Fetches the actual model list from the provider's/v1/modelsAPI using the user's configured credentials. Includes URL scheme validation (B310), SSRF guard (private IP block), and gracefulnot_supportedresponse for providers without a standard/v1/modelsendpoint (Anthropic, Google). Response normalised to{id, label}list, filtered to chat models.static/ui.js:populateModelDropdown()now calls_fetchLiveModels()in the background after rendering the static list. Live models that aren't already in the dropdown are appended to the provider's optgroup. Results are cached per session so only one fetch per provider per page load. Skips Anthropic and Google (unsupported). Falls back to static list silently if the fetch fails.- 25 new tests in
tests/test_issues_373_374_375.py; 949 tests total (up from 924)
- 25 new tests in
[v0.50.19] Fix UnicodeEncodeError when downloading files with non-ASCII filenames (PR #378)
- Workspace file downloads no longer crash for Unicode filenames (
api/routes.py): Clicking a PDF or other file with Chinese, Japanese, Arabic, or other non-ASCII characters in its name caused aUnicodeEncodeErrorbecause Python's HTTP server requires header values to be latin-1 encodable. A new_content_disposition_value(disposition, filename)helper centralisesContent-Dispositiongeneration: it strips CR/LF (injection guard), builds an ASCII fallback for the legacyfilename=parameter (non-ASCII chars replaced with_), and preserves the full UTF-8 name infilename*=UTF-8''...per RFC 5987. Bothattachmentandinlineresponses use it.- 2 new integration tests in
tests/test_sprint29.pycovering Chinese filenames for both download and inline responses, verifying the header is latin-1 encodable andfilename*=UTF-8''is present; 924 tests total (up from 922)
- 2 new integration tests in
[v0.50.18] Recover from invalid default workspace paths (PR #366)
- WebUI no longer breaks when the configured default workspace is unavailable (
api/config.py): The workspace resolution path was refactored into three composable functions —_workspace_candidates(),_ensure_workspace_dir(), andresolve_default_workspace(). When the configured workspace (from env var, settings file, or passed path) cannot be created or accessed, the server falls back through an ordered priority list:HERMES_WEBUI_DEFAULT_WORKSPACEenv var →~/workspace(if exists) →~/work(if exists) →~/workspace(create it) →STATE_DIR/workspace. save_settings()now validates and corrects the workspace path (api/config.py): If a client posts an invalid or inaccessibledefault_workspace, the saved value is corrected to the nearest valid fallback rather than persisting an unusable path.- Startup normalizes stale workspace paths (
api/config.py): If the settings file stores a workspace that no longer exists, the server rewrites it with the resolved fallback on startup so the problem self-heals.- 7 tests in
tests/test_default_workspace_fallback.py(2 from PR + 5 added during review: fallback creation, RuntimeError on all-fail, deduplication, env var priority, unwritable path returns False); 922 tests total (up from 915)
- 7 tests in
[v0.50.17] Docker: pre-install uv at build time + fix workspace permissions (fixes #357)
- Docker containers no longer need internet access at startup (
Dockerfile):uvis now installed at image build time viaRUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh(run as root, souvlands in/usr/local/bin— accessible to all users). The init script skips the download ifuvis already on PATH (command -v uv), and falls back to downloading with a propererror_exitif it isn't. This fixes startup failures in air-gapped, firewalled, or isolated Docker networks wheregithub.comis unreachable at runtime.- Fix applied during review: the original PR installed
uvas thehermeswebuitoouser (to~hermeswebuitoo/.local/bin), which is not on thehermeswebuiruntime user'sPATH. Changed to install asrootwithUV_INSTALL_DIR=/usr/local/binsouvis in the system PATH for all users.
- Fix applied during review: the original PR installed
- Workspace directory now writable by the hermeswebui user (
docker_init.bash): The init script now usessudo mkdir -pandsudo chown hermeswebui:hermeswebuiforHERMES_WEBUI_DEFAULT_WORKSPACE. Docker auto-creates bind-mount directories asrootif they don't exist on the host, making them unwritable by the app user. Thesudo chowncorrects ownership after creation.- 15 new structural tests in
tests/test_issue357.py; 915 tests total (up from 900)
- 15 new structural tests in
[v0.50.16] Fix CSRF check failing behind reverse proxy on non-standard ports (PR #360)
- CSRF no longer rejects POST requests from reverse-proxied deployments on non-standard ports (
api/routes.py, fixes #355): When serving behind Nginx Proxy Manager or similar on a port like:8000, browsers sendOrigin: https://app.example.com:8000while the proxy forwardsHost: app.example.com(port stripped). The old string comparison failed this as cross-origin. Two changes fix it:_normalize_host_port(): properly splits host:port strings including IPv6 bracket notation ([::1]:8080)_ports_match(scheme, origin_port, allowed_port): scheme-aware port equivalence — absent port equals:80forhttp://and:443forhttps://. This prevents the previous cross-protocol confusion wherehttp://hostcould incorrectly match anhttps://host:443server (security fix applied on top of the original PR)HERMES_WEBUI_ALLOWED_ORIGINSenv var: comma-separated explicit origin allowlist for cases where port normalization alone isn't sufficient (e.g. non-standard ports like:8000where the proxy strips the port entirely). Entries without a scheme (https://) are rejected with a startup warning.
- Security fix applied during review: the original
_ports_matchtreated both port 80 and port 443 as interchangeable with "absent port", which is scheme-unaware. Anhttp://hostorigin would pass for anhttps://host:443server. Fixed by making the default-port lookup scheme-specific.- 29 new tests in
tests/test_sprint29.py(5 from PR + 24 added during review): cover scheme-aware port matching, cross-protocol rejection, unit tests for_normalize_host_portand_ports_match, allowlist validation, comma-separated origins, no-scheme allowlist warning, the bug scenario with and without the allowlist; 900 tests total (up from 871)
- 29 new tests in
[v0.50.15] KaTeX math rendering for LaTeX in chat and workspace previews (fixes #347)
- LaTeX / KaTeX math now renders in chat messages and workspace file previews (
static/ui.js,static/workspace.js,static/style.css,static/index.html): Inline math ($...$,\(...\)) and display math ($$...$$,\[...\]) are rendered via KaTeX instead of displaying as raw text. Follows the existing mermaid lazy-load pattern: delimiters are stashed before markdown processing, placeholder elements are emitted, and KaTeX JS is loaded from CDN on first use — no KaTeX JS is loaded unless math is present.$$...$$and\[...\]→ centered display math (<div class="katex-block">)$...$and\(...\)→ inline math (<span class="katex-inline">); requires non-space at$boundaries to avoid false positives on currency amounts like$5- KaTeX JS lazy-loaded from jsdelivr CDN with SRI hash; KaTeX CSS loaded eagerly in
<head>to prevent layout shift throwOnError:false— invalid LaTeX degrades to a<code>span rather than crashing the messagetrust:false— disables KaTeX commands that could execute code<span>added toSAFE_TAGSallowlist for inline math spans (tag name boundary check preserved)
- Fix: fence stash now runs before math stash (
static/ui.js): The original PR had math stash before fence stash, meaning\$x$`` inside backtick code spans was incorrectly extracted as math instead of being protected as code. Order corrected — fence_stash runs first so code spans protect their contents. - Workspace file previews now render math (
static/workspace.js): AddedrequestAnimationFrame(renderKatexBlocks)after markdown file preview renders, matching the chat message path. Without this, math placeholders appeared in previews but were never rendered.- 29 tests in
tests/test_issue347.py(18 original + 11 new covering stash ordering, workspace wiring, false-positive prevention); 870 tests total (up from 841)
- 29 tests in
[v0.50.14] Security fixes: B310 urlopen scheme validation, B324 MD5 usedforsecurity, B110 bare except logging + QuietHTTPServer (PR #354)
- B324 — MD5 no longer triggers crypto warnings (
api/gateway_watcher.py):_snapshot_hashuses MD5 only as a non-cryptographic change-detection hash. Addedusedforsecurity=Falseso systems with strict crypto policies (FIPS mode etc.) don't reject the call. - B310 — urlopen now validates URL scheme (
api/config.py,bootstrap.py): Bothget_available_models()andwait_for_health()validate that the URL scheme ishttporhttpsbefore callingurllib.request.urlopen, preventingfile://or other dangerous scheme injection. Added# nosec B310suppression after each validated call. - B110 — bare
except: passblocks replaced withlogger.debug()(12 files): Allexcept Exception: passandexcept: passblocks now log the failure at DEBUG level so operators can diagnose issues in production without changing behavior. A module-levellogger = logging.getLogger(__name__)was added to each file. QuietHTTPServer(server.py): Subclass ofThreadingHTTPServerthat overrideshandle_error()to silently dropConnectionResetError,BrokenPipeError,ConnectionAbortedError, and socket errno 32/54/104 (client disconnect races). Real errors still delegate to the default handler. Reduces log spam from SSE clients that disconnect mid-stream.- Session title redaction (
api/routes.py): The/api/sessionslist endpoint now applies_redact_textto session titles before returning them, consistent with the per-sessionredact_session_data()already applied elsewhere. - Fix:
QuietHTTPServer.handle_errorusessys.exc_info()(standard library) nottraceback.sys.exc_info()(implementation detail);sysis now explicitly imported inserver.py.- 19 new tests in
tests/test_sprint43.py; 841 tests total (up from 822)
- 19 new tests in
[v0.50.13] Fix session_search in WebUI sessions — inject SessionDB into AIAgent (PR #356)
session_searchnow works in WebUI sessions (api/streaming.py): The agent'ssession_searchtool returned "Session database not available" for all WebUI sessions. The CLI and gateway code paths both initialize aSessionDBinstance and pass it viasession_db=toAIAgent.__init__(), but the WebUI streaming path was missing this step._run_agent_streamingnow initializesSessionDB()before constructing the agent and passes it in. Atry/exceptwrapper makes the init non-fatal — ifhermes_stateis unavailable (older installs, test environments), aWARNINGis printed andsession_db=Noneis passed instead, preserving the prior behavior gracefully.- 7 new tests in
tests/test_sprint42.py; 822 tests total (up from 815)
- 7 new tests in
[v0.50.12] Profile .env isolation — prevent API key leakage on profile switch (fixes #351)
- API keys no longer leak between profiles on switch (
api/profiles.py):_reload_dotenv()now tracks which env vars were loaded from the active profile's.envand clears them before loading the next profile. Previously, switching from a profile withOPENAI_API_KEY=Xto a profile without that key leftXinos.environfor the duration of the process — effectively leaking credentials across the profile boundary. A module-level_loaded_profile_env_keys: set[str]tracks loaded keys; it is cleared and repopulated on every_reload_dotenv()call. apply_onboarding_setup()ordering fixed (api/onboarding.py): the belt-and-bracesos.environ[key] = api_keydirect assignment is now placed after_reload_dotenv(). Previously the key was wiped by the isolation cleanup when_reload_dotenv()ran immediately after the direct set.- 2 new tests in
tests/test_profile_env_isolation.py; 815 tests total (up from 813)
- 2 new tests in
[v0.50.11] Chat table styles + plain URL auto-linking (fixes #341, #342)
- Tables in chat messages now render with visible borders (
static/style.css): The.msg-bodyarea had no table CSS, so markdown tables sent by the assistant were unstyled and unreadable. Four new rules mirror the existing.preview-mdtable styles:border-collapse:collapse, per-cell padding and borders viavar(--border2), and an alternating-row tint. Two:root[data-theme="light"]overrides ensure the borders and header background adapt correctly in light mode. (fixes #341) - Plain URLs in chat messages are now clickable (
static/ui.js): Bare URLs likehttps://example.comwere rendered as plain text. A new autolink pass inrenderMd()convertshttps?://...URLs to<a>tags automatically. Runs after the SAFE_TAGS escape pass (protecting code blocks), before paragraph wrapping. Also applied insideinlineMd()so URLs in list items, blockquotes, and table cells are linked too. Trailing punctuation stripped;esc()applied to both href and link text. (fixes #342)- 11 new tests (4 in
tests/test_issue341.py, 7 intests/test_issue342.py); 813 tests total (up from 802)
- 11 new tests (4 in
- Test infrastructure fix (
tests/test_sprint34.py#349): two static-file opens used bare relative paths that failed when pytest ran from outside the repo root; replaced withpathlib.Path(__file__).parent.parentconsistent with the rest of the suite. 813/813 now pass from any working directory.
[v0.50.10] Title auto-generation fix + mobile close button (PR #333)
- Session title now auto-generates for all default title values (
'Untitled','New Chat', empty string): The condition inapi/streaming.pythat triggerstitle_from()previously only matched'Untitled'. It now also covers'New Chat'(used by some external clients/forks) and any empty/falsy title, so sessions started from those states get a proper auto-generated title after the first message. - Redundant workspace panel close button hidden on mobile (
static/style.css): On viewports ≤900px wide, both the desktop collapse button (#btnCollapseWorkspacePanel) and the mobile-specific X button (.mobile-close-btn) were rendered simultaneously. The desktop button is now hidden on mobile and.mobile-close-btnis hidden by default (desktop) and shown only on mobile — eliminating the duplicate control.- 11 new tests in
tests/test_sprint41.py; 802 tests total (up from 791)
- 11 new tests in
[v0.50.9] Onboarding works from Docker bridge networks (PR #335, fixes #334)
- Docker users can now complete onboarding without enabling auth first (closes #334): The onboarding setup endpoint previously only accepted requests from
127.0.0.1. Docker containers connect via bridge network IPs (172.17.x.x, etc.), so the endpoint returned a 403 mid-wizard with no clear explanation. The check now accepts any loopback or RFC-1918 private address (127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) using Python'sipaddress.is_loopbackandis_private. Public IPs are still blocked unless auth is enabled.
[v0.50.8] Model dropdown deduplication — hyphen vs dot separator fix (PR #332)
- Model dropdown no longer shows duplicates for hyphen-format configs (e.g.
claude-sonnet-4-6from hermes-agent config): The server-side normalization inapi/config.pynow unifies hyphens and dots when checking whether the default model is already in the dropdown. Previously,claude-sonnet-4-6(hermes-agent format) andclaude-sonnet-4.6(WebUI list format) were treated as different models, causing the same model to appear twice — once as a raw unlabelled entry and once with the correct display name. The raw entry is now suppressed and the labelled one is selected as default. - README updated: test count corrected to 791 / 51 files; all module line counts updated to current values;
onboarding.py,state_sync.py,updates.pyadded to the architecture listing.
[v0.50.7] OAuth provider onboarding path — Codex/Copilot no longer blocks setup (PR #331, fixes #329 bug 2)
- OAuth providers now have a proper onboarding path (closes bug 2): Users with
openai-codex,copilot,qwen-oauth, or any other OAuth-authenticated provider now see a clear confirmation card instead of an unusable API key input form.- If already authenticated (
chat_ready: true): blue "Provider already authenticated" card with a direct Continue button — no key entry required. - If not yet authenticated: amber card explaining how to run
hermes authorhermes modelin a terminal to complete setup. - Either state includes a collapsible "switch provider" section for users who want to move to an API-key provider instead.
_build_setup_catalognow includescurrent_is_oauthboolean; fixed a latentKeyErrorcrash when looking updefault_modelfor OAuth providers.- 5 new i18n keys in English and Spanish (
onboarding_oauth_*). - 15 new tests in
tests/test_sprint40.py; 791 tests total (up from 776)
- If already authenticated (
[v0.50.6] Skip-onboarding env var + synchronous API key reload (PR #330, fixes #329 bugs 1+3)
HERMES_WEBUI_SKIP_ONBOARDING=1(closes bug 1): Hosting providers can set this env var to bypass the first-run wizard entirely. Only takes effect whenchat_readyis also true — a misconfigured deployment still shows the wizard. Accepts1,true, oryes.- API key takes effect immediately after onboarding (closes bug 3):
apply_onboarding_setupnow setsos.environ[env_var]synchronously after writing the key to.env, so the running process can use it without a server restart. Also attempts to reloadhermes_cli's config cache as a belt-and-suspenders measure.- 8 new tests in
tests/test_sprint39.py; 776 tests total (up from 768)
- 8 new tests in
[v0.50.5] Think-tag stripping with leading whitespace (PR #327)
- Fix think-tag rendering for models that emit leading whitespace (e.g. MiniMax M2.7): Some models emit one or more newlines before the
<think>opening tag. The previous regex used a^anchor, so it only matched when<think>was the very first character. When the anchor failed, the raw</think>tag appeared in the rendered message body.static/ui.js(stored messages): removed^anchor from<think>and Gemma channel-token regexes; switched from.slice()to.replace()+.trimStart()so stripping works regardless of positionstatic/messages.js(live stream):trimStart()beforestartsWith/indexOfchecks; partial-tag-prefix guard also uses trimmed buffer- 10 new tests in
tests/test_sprint38.py; 768 tests total (up from 758)
[v0.50.3] Onboarding completes gracefully for pre-configured providers (PR #323, fixes #322)
- OAuth/CLI-configured providers no longer blocked by onboarding (closes #322): Users with providers already set up via the CLI (
openai-codex,copilot,nous, etc.) hitUnsupported provider for WebUI onboardingwhen clicking "Open Hermes" on the finish page. The wizard now marks onboarding complete and lets them through — the agent setup is already done, no wizard steps needed.- 5 new tests in
tests/test_sprint34.py; 758 tests total (up from 753)
- 5 new tests in
[v0.50.2] Workspace panel state persists across refreshes
- Workspace panel open/closed persists (localStorage key
hermes-webui-workspace-panel): Once you open the workspace/files pane, it stays open after a page refresh. Closing it explicitly saves the closed state, which also survives a refresh. The restore happens in the boot sequence before the first render, so there is no flash of the wrong state. Works for both desktop and mobile.- State is stored as
'open'or'closed'—'open'restores as'browse'mode; any preview state is re-evaluated normally. - 7 new tests in
tests/test_sprint37.py; 753 tests total (up from 746)
- State is stored as
[v0.50.1] Mobile Enter key inserts newline (PR #315, fixes #269)
- Enter inserts newline on mobile (closes #269): On touch-primary devices (detected via
matchMedia('(pointer:coarse)')), the Enter key now inserts a newline instead of sending. Users send via the Send button, which is always visible on mobile. Desktop behavior is unchanged — Enter sends, Shift+Enter inserts a newline.- The
ctrl+entersetting continues to work as before on all devices. - Users who explicitly set send key to
enteron mobile can override in Settings. - 4 new tests in
tests/test_mobile_layout.py; 746 tests total (up from 742)
- The
[v0.50.0] Composer-centric UI refresh + Hermes Control Center (PR #242)
Major UI overhaul by @aronprins — the biggest single contribution to the project. Rebased and reviewed on pr-242-review.
- Composer as control hub — model selector, profile chip, and workspace chip now live in the composer footer as pill buttons with dropdowns. The context window usage ring (token count, cost, fill) replaces the old linear pill.
- Hermes Control Center — a single sidebar launcher button (bottom of sidebar) replaces the gear icon settings modal. Tabbed 860px modal: Conversation tab (transcript/JSON export, import, clear), Preferences tab (all settings), System tab (version, password). Always resets to Conversation on close.
- Activity bar removed — turn-scoped status (thinking, cancelling) renders inline in the composer footer via
setComposerStatus. - Session
⋯dropdown — per-row pin/archive/duplicate/move/delete actions move from inline buttons into a shared dropdown menu; click-outside/scroll/Escape handling. - Workspace panel state machine —
_workspacePanelMode(closed/browse/preview) in boot.js with proper transitions and discard-unsaved guard. - Icon additions — save, chevron-right, arrow-right, pause, paperclip, copy, rotate-ccw, user added to icons.js.
- i18n additions — 6 new keys across en/de/zh/zh-Hant for control center sections.
- OLED theme — 7th built-in theme (true black background for OLED displays), originally contributed by @kevin-ho in PR #168.
- Mobile fixes — icon-only composer chips below 640px,
overflow-y: hiddenon.composer-leftto prevent scrollbar, profile dropdownmax-width: min(260px, calc(100vw - 32px)). - 742 tests total; all existing tests pass; version badge in System tab updated to v0.50.0.
[v0.49.4] Cancel stream cleanup guaranteed (PR #309, fixes #299)
- Reliable cancel cleanup (closes #299):
cancelStream()no longer depends on the SSEcancelevent to clear busy state and status text. Previously, if the SSE connection was already closed when cancel fired, "Cancelling..." would linger indefinitely. NowcancelStream()clearsS.activeStreamId, callssetBusy(false),setStatus(''), and hides the cancel button directly after the cancel API request — regardless of SSE connection state. The SSE cancel handler still runs when the connection is alive (all operations are idempotent).- 9 new tests in
tests/test_sprint36.py; 742 tests total (up from 733)
- 9 new tests in
[v0.49.3] Session title guard + breadcrumb nav + wider panel (PRs #301, #302)
- Preserve user-renamed session titles (PR #301 by @franksong2702 / closes #300):
title_from()now only runs when the session title is still'Untitled'. Previously it overwrote user-assigned titles on every conversation turn.- Fixed in both
api/streaming.py(streaming path) andapi/routes.py(sync path).
- Fixed in both
- Clickable breadcrumb navigation (PR #302 by @franksong2702 / closes #292): Workspace file preview now shows a clickable breadcrumb path bar. Each segment navigates directly to that directory level. Paths with spaces and special characters handled correctly.
clearPreview()restores the directory breadcrumb on close. - Wider right panel (PR #302):
PANEL_MAXraised from 500 to 1200 — right panel can now be dragged wider on ultrawide screens. - Responsive message width (PR #302):
.messages-innernow scales up gracefully at 1400px (1100px max) and 1800px (1200px max) viewport widths instead of capping at 800px on all screen sizes.- 12 new tests in
tests/test_sprint35.py; 733 tests total (up from 721)
- 12 new tests in
[v0.49.2] OAuth provider support in onboarding (issues #303, #304)
- OAuth provider bypass (closes #303, #304): The first-run onboarding wizard now correctly recognizes OAuth-authenticated providers (GitHub Copilot, OpenAI Codex, Nous Portal, Qwen OAuth) as ready, instead of always demanding an API key.
- New
_provider_oauth_authenticated()helper inapi/onboarding.pycheckshermes_cli.auth.get_auth_status()first (authoritative), then falls back to parsing~/.hermes/auth.jsondirectly for the known OAuth provider IDs (openai-codex,copilot,copilot-acp,qwen-oauth,nous). _status_from_runtime()now has anelsebranch for providers not in_SUPPORTED_PROVIDER_SETUPS; OAuth-authenticated providers returnprovider_ready=Trueandsetup_state="ready".- The
provider_incompletestatus note no longer says "API key" for OAuth providers — it now says "Run 'hermes auth' or 'hermes model' in a terminal to complete setup." - 21 new tests in
tests/test_sprint34.py; 721 tests total (up from 700)
- New
[v0.49.1] Docker docs + mobile Profiles button (PRs #291, #265)
- Two-container Docker setup (PR #291 / closes #288): New
docker-compose.two-container.ymlfor running the Hermes Agent and WebUI as separate containers with shared volumes. Documents the architecture clearly; localhost-only port binding by default. - Mobile Profiles button (PR #265 by @Bobby9228): Adds Profiles to the mobile bottom navigation bar (last position: Chat → Tasks → Skills → Memory → Spaces → Profiles). Uses
mobileSwitchPanel()for correct active-highlight behaviour;data-panel="profiles"attribute set; SVG matches other nav icons; 3 new tests.- 700 tests total (up from 697)
[v0.49.0] First-run onboarding wizard + self-update hardening (PRs #285, #287, #289)
-
One-shot bootstrap and first-run setup wizard (PR #285 — first-run onboarding flow): New users are greeted with a guided onboarding overlay on first load. The wizard checks system status, configures a provider (OpenRouter, Anthropic, OpenAI, or custom OpenAI-compatible endpoint), sets a workspace and optional password, and marks setup as complete — all without leaving the browser.
bootstrap.py: one-shot CLI bootstrap that writes~/.hermes/config.yamland~/.hermes/.envfrom flags; idempotent and safe to re-runapi/routes.py:/api/onboarding/status(GET) and/api/onboarding/complete(POST) endpoints; real provider config persistence toconfig.yaml+.envstatic/onboarding.js: full wizard JS module — step navigation, provider dropdown, model selector, API key input, Back/Continue flow, i18n supportstatic/index.html: onboarding overlay HTML shell +<script src="/static/onboarding.js">loadstatic/i18n.js: 40+ onboarding keys added to all 5 locales (en, es, de, zh-Hans, zh-Hant)static/boot.js: on load, fetches/api/onboarding/statusand opens wizard whencompleted=false- Wizard does NOT show when
onboarding_completed=truein settings - 14 new tests in
tests/test_onboarding.py; 693 tests total (up from 679)
-
Self-update git pull diagnostics (PR #287): Fixes multiple failure modes in the WebUI self-update flow when the repo has a non-trivial git state.
_run_git()now returns stderr on failure (stdout fallback, then exit-code message) — users see actionable git errors instead of empty strings- New
_split_remote_ref()helper splitsorigin/masterinto('origin', 'master')beforegit pull --ff-only— fixes silent failures where git misinterpreted the combined string as a repository name --untracked-files=noadded togit status --porcelain— prevents spurious stash failures in repos with untracked files- Early merge-conflict detection via porcelain status codes before attempting pull
- 4 new unit tests in
tests/test_updates.py
-
Skip flaky redaction test in agent-less environments (PR #289):
test_api_sessions_list_redacts_titlesadded to the CI skip list for environments without hermes-agent installed. Test still runs with the full agent; security coverage preserved by 6 pure-unit tests and 2 other API-level redaction tests.- 697 tests total (up from 693)
[v0.48.2] Provider/model mismatch warning (PR #283, fixes #266)
- Provider mismatch warning (PR #283): WebUI now warns when you select a model from a provider different from the one Hermes is configured for, instead of silently failing with a 401 error.
api/streaming.py: 401/auth errors classified astype='auth_mismatch'with an actionable hint ("Runhermes modelin your terminal to switch providers")static/ui.js:populateModelDropdown()storesactive_providerfrom/api/modelsaswindow._activeProvider; new_checkProviderMismatch()helper compares selected model's provider prefix against the configured providerstatic/boot.js:modelSelect.onchangecalls_checkProviderMismatch()and shows a toast warning immediately on selectionstatic/messages.js:apperrorhandler shows "Provider mismatch" label (via i18n) instead of "Error" for auth errorsstatic/i18n.js:provider_mismatch_warningandprovider_mismatch_labelkeys added to all 5 locales (en, es, de, zh-Hans, zh-Hant)- Check skipped for
openrouterandcustomproviders to avoid false positives - 21 new tests in
tests/test_provider_mismatch.py; 679 tests total (up from 658)
[v0.48.1] Markdown table inline formatting (PR #278)
- Inline formatting in table cells (PR #278, @nesquena): Table header and data cells now render
**bold**,*italic*,`code`, and[links](url)correctly. Previouslyesc()was used, which displayed raw HTML tags as text. Changed toinlineMd()consistent with list items and blockquotes. XSS-safe:inlineMd()escapes all interpolated values. Two-line change instatic/ui.js. Fixes #273.
[v0.48.0] Real-time gateway session sync (PR #274)
- Real-time gateway session sync (PR #274, @bergeouss): Gateway sessions from Telegram, Discord, Slack, and other messaging platforms now appear in the WebUI sidebar and update in real time as new messages arrive. Enable via the "Show agent sessions" checkbox (renamed from "Show CLI sessions").
api/gateway_watcher.py: background daemon thread pollingstate.dbevery 5s using MD5 hash-based change detection- New SSE endpoint
/api/sessions/gateway/streamfor real-time push to browser - Dynamic source badges: telegram (blue), discord (purple), slack (dark purple), cli (green)
- Zero changes to hermes-agent — WebUI reads the shared
state.dbthat both components access - 10 new tests in
test_gateway_sync.pycovering metadata, filtering, SSE, and watcher lifecycle - 658 tests (up from 648)
[v0.47.1] Spanish locale (PR #275)
- Spanish (es) locale (PR #275, @gabogabucho): Full Spanish translation for all 175 UI strings. Exposed automatically in the language selector via existing
LOCALESwiring. Includes regression tests verifying locale presence, representative translations, and key-parity with English. 648 tests (up from 645).
[v0.47.0] — 2026-04-11
Features
/skills [query]slash command (PR #257): Fetches from/api/skills, groups results by category (alphabetically), renders as a formatted assistant message. Optional query filters by name, description, or category. Shows in the/autocomplete dropdown. i18n for en/de/zh/zh-Hant. 1 regression test added.- Shared app dialogs replace native
confirm()/prompt()(PR #251, extracted from #242 by @aronprins):showConfirmDialog()andshowPromptDialog()inui.js, backed by#appDialogOverlay. Replaces all 11 native browser dialog call sites across panels.js, sessions.js, ui.js, workspace.js. Full keyboard focus trap (Tab/Escape/Enter), ARIA roles, danger mode, focus restore, mobile-responsive buttons. i18n for en/de/zh/zh-Hant. 5 new tests intest_sprint33.py. - Session
⋯action dropdown (PR #252, extracted from #242 by @aronprins): Replaces 5 per-row hover buttons (pin/move/archive/duplicate/delete) with a single⋯trigger. Menu usesposition:fixedto avoid sidebar clipping. Full close handling: click-outside, scroll, Escape, resize-reposition.test_sprint16.pyupdated to assert the new trigger exists and old button classes are gone.
Bug Fixes
- Custom provider with slash model name no longer rerouted to OpenRouter (PR #255):
resolve_model_provider()now returns immediately with the configuredprovider/base_urlwhenbase_urlis set, before the slash-based OpenRouter heuristic runs. Fixesgoogle/gemma-4-26b-a4bwithprovider: custombeing silently routed to OpenRouter (401 errors). 1 regression test added. Fixes #230. - Android Chrome: workspace panel now closeable on mobile (PR #256):
toggleMobileFiles()now shows/hides the mobile overlay. NewcloseMobileFiles()helper closes the right panel with correct overlay tracking. Overlay tap-to-close calls bothcloseMobileSidebar()andcloseMobileFiles(). Mobile-only×close button added to workspace panel header. Fix applied during review:closeMobileSidebar()now checks if the right panel is still open before hiding the overlay. Fixes #247. - Android Chrome: profile dropdown no longer clipped on mobile (PR #256):
.profile-dropdownswitches toposition:fixed; top:56px; right:8pxatmax-width:900px, escaping theoverflow-x:autostacking context that was making it invisible. Fixes #246.
Tests
- Mobile layout regression suite (PR #254): 14 static tests in
tests/test_mobile_layout.pythat run on every QA pass. Covers: CSS breakpoints at 900px/640px, right panel slide-over, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close,100dvh, 44px touch targets, 16px textarea font. All pass against current and future master.
CSS hotfix (commit a2ae953, post-tag): session action menu — icon now displays inline-left of text. The .ws-opt base class (flex-direction:column) was causing SVG icons to stack above the label. Fixed with 3 CSS rule overrides on .session-action-opt.
645 tests (up from 624 on v0.46.0 — +21 new tests)
[v0.46.0] — 2026-04-11
Features
- Docker UID/GID matching (PR #237 by @mmartial): New
docker_init.bashentrypoint addshermeswebui/hermeswebuitoouser pattern so container-created files match the host user UID/GID. Prevents.hermesvolume mounts from being owned by root. Configure viaWANTED_UIDandWANTED_GIDenv vars (default 1000/1000). README updated with setup instructions.Dockerfile— two-user pattern with passwordless sudo;/.within_containermarker for in-container detection; starts ashermeswebuitoo, switches to correct UID/GIDdocker-compose.yml— mounts.hermesat/home/hermeswebui/.hermes; uses${UID:-1000}/${GID:-1000}for UID/GID passthroughserver.py— detects/.within_containerand prints a note when binding to 0.0.0.0
Security
- Credential redaction in API responses (PR #243 by @kcclaw001): All API endpoints now redact credentials from responses at the response layer. Session files on disk are unchanged; only the API output is masked.
api/helpers.py—redact_session_data()and_redact_value()apply pattern-based redaction to messages, tool_calls, and title; covers GitHub PATs, OpenAI/Anthropic keys, AWS keys, Slack tokens, HuggingFace tokens, Authorization Bearer headers, and PEM private key blocksapi/routes.py—GET /api/session,GET /api/session/export,GET /api/memoryall wrapped with redactionapi/streaming.py— SSEdoneevent payload redacted before broadcastapi/startup.py— newfix_credential_permissions()called at startup;chmod 600on.env,google_token.json,auth.json,.signing_keyif they have group/other read bits settests/test_security_redaction.py— 13 new tests covering redaction functions and endpoint structural verification
Bug Fixes
- Custom model list discovery with config API key (PR #238 by @ccqqlo):
get_available_models()now readsapi_keyfromconfig.yamlbefore env vars when fetching/v1/modelsfrom custom endpoints (LM Studio, Ollama, etc.). Priority:model.api_key→providers.<active>.api_key→providers.custom.api_key→ env vars. Also addsOpenAI/Python 1.0User-Agent header. Fixes model picker collapsing to single default model for config-only setups. 1 new regression test. - HTML entity decode before markdown processing (PR #239 by @Argonaut790): Adds
decode()helper inrenderMd()to fix double-escaping of HTML entities from LLM output (e.g.<code>becoming&lt;code&gt;instead of rendering). XSS-safe: decode runs beforeesc(), only 5 entity patterns (<,>,&,",'). - Simplified Chinese translations completed (PR #239 by @Argonaut790): 40+ missing keys added to
zhlocale (123 → 164 keys). Newzh-Hant(Traditional Chinese) locale with 163 keys. - Cancel button now interrupts agent execution (PR #244 by @huangzt):
cancel_stream()now callsagent.interrupt()to stop backend tool execution, not just the SSE stream.AGENT_INSTANCESdict (protected bySTREAMS_LOCK) tracks active agents. Race condition fixed: after storing agent, immediately checks if cancel was already requested. Frontend: removes stale "Cancelling..." status text;setBusy(false)always called on cancel. 6 new unit tests intests/test_cancel_interrupt.py.
624 tests (up from 604 on v0.45.0 — +20 new tests)
[v0.45.0] — 2026-04-10
Features
- Custom endpoint fields in new profile form (PR #233, fixes #170): The New Profile form now accepts optional Base URL and API key fields. When provided, both are written into the new profile's
config.yamlunder themodelsection, enabling local-endpoint setups (Ollama, LMStudio, etc.) to be configured in one step without editing YAML manually. The write is a no-op when both fields are left blank, so existing profile creation behavior is unchanged.api/profiles.py—_write_endpoint_to_config()mergesbase_url/api_keyintoconfig.yamlusingyaml.safe_load+yaml.dump, preserving any existing keysapi/routes.py— acceptsbase_urlandapi_keyfrom POST body; validates thatbase_url, if provided, starts withhttp://orhttps://(returns 400 for invalid schemes)static/index.html— two new inputs added to the New Profile form: Base URL (withhttp://localhost:11434placeholder) and API key (password type)static/panels.js—submitProfileCreate()reads both fields, validates URL format client-side before sending, and includes them in the create payload;toggleProfileForm()clears them on cancel- 9 tests in
tests/test_sprint31.pycovering: config write (base_url, api_key, both, merge, no-op), route acceptance, profile path in response, and invalid-scheme rejection
604 tests (up from 595)
[v0.44.1] — 2026-04-10
- Unskip 16 approval tests (PR #231):
test_approval_unblock.pywas importinghas_pendingandpop_pendingfromtools.approval, which the agent module had removed. The import failure tripped theAPPROVAL_AVAILABLEguard and skipped all 16 tests in the file. Neither symbol was used in any test body. Removing the stale imports restores 595/595 passing, 0 skipped.
[v0.44.0] — 2026-04-10
Features
- Lucide SVG icons (PR #221): Replaces all emoji icons in the sidebar, workspace, and tool cards with self-hosted Lucide SVG paths via
static/icons.js. No CDN dependency — icons are bundled directly. Theli(name)renderer uses a hardcoded whitelist, so server-supplied tool names never inject arbitrary SVG. All 35onclick=functions verified to exist in JS; all 21 icon references verified inicons.js.
Bug Fixes
- Approval card hides immediately on respond/stream-end (PR #225):
respondApproval()and all stream-end SSE handlers (done, cancel, apperror, error, start-error) now callhideApprovalCard(true). Previously the 30s minimum-visibility guard deferred the hide, leaving the card visible with disabled buttons for up to 30s after the user clicked Approve/Deny or the session completed. The poll-loop tick correctly keeps no-force so the guard still protects against transient polling gaps. Adds 11 structural tests for the timer logic. - Login page CSP fix (PR #226): Moves
doLogin()and Enter key listener from inline<script>/onsubmit/onkeydownattributes intostatic/login.js. Inline handlers are blocked by strictscript-srcCSP, causing silent login failure. i18n error strings now passed viadata-*attributes instead of injected JS literals. Also guardsres.json()parse with try/catch so non-JSON server errors fall back to the password-error message. Fixes #222. - Update error messages (PR #227):
_apply_update_inner()now fetches before pulling and surfaces three distinct failure modes with actionable recovery commands: network unreachable, diverged history (git reset --hard), and missing upstream tracking branch (git branch --set-upstream-to). Generic fallback truncates to 300 chars with a sentinel for empty output. Adds 13 tests covering all new diagnostic code paths. Fixes #223. - Approval pending check (PR #228):
GET /api/approval/pendingalways returned{pending: null}after the agent module renamedhas_pendingtohas_blocking_approval. The route now checks_pendingdirectly under_lock, matching howsubmit_pendingwrites to it. Fixestest_approval_submit_and_respond.
Tests
- 579 passing, 16 skipped at this tag (595/595 after v0.44.1 unskip — +24 new tests across PRs #225, #227, #228)
[v0.43.1] — 2026-04-10
- CSRF fix for reverse proxies (PR #219): The CSRF check now accepts
X-Forwarded-HostandX-Real-Hostheaders in addition toHost, so deployments behind Caddy, nginx, and Traefik no longer reject POST requests with "Cross-origin request rejected". Security is preserved — requests with no matching proxy header are still rejected. Fixes #218.
[v0.43.0] — 2026-04-10
Features
- Auto-install agent dependencies on startup (PRs #215 + #216): When
hermes-agentis found on disk but its Python dependencies are missing (common in Docker deployments where the agent is volume-mounted post-build),server.pynow callsapi/startup.auto_install_agent_deps()to install fromrequirements.txtorpyproject.toml. Falls back gracefully — failures are logged and never fatal.
Bug Fixes
- Session ID validator broadened (PR #212):
Session.load()rejected any session ID containing non-hex characters, breaking sessions created by the new hermes-agent format (YYYYMMDD_HHMMSS_xxxxxx). Validator now accepts[0-9a-z_]while rejecting path traversal patterns (null bytes, slashes, backslashes, dot-extensions). - Test suite isolation (PR #216):
conftest.pynow kills any stale process on the test port (8788) before starting the fixture server. Stale QA harness servers (8792/8793) could occupy 8788 and cause non-deterministic test failures across the full suite.
[v0.42.2] — 2026-04-10
Bug Fixes
- CSP blocking inline event handlers (PR #209):
script-src 'self'blocked all 55+ inlineonclick=handlers inindex.html, making the settings panel, sidebar navigation, and most interactive controls non-functional. Added'unsafe-inline'toscript-src. Also restoreshttps://cdn.jsdelivr.nettoscript-srcandstyle-srcfor Mermaid.js and Prism.js (dropped in v0.42.1).
[v0.42.1] — 2026-04-11
Bug Fixes
- i18n button text stripping (post-review): Three sidebar buttons (
+ New job,+ New skill,+ New profile) and three suggestion buttons haddata-i18non the outer element, which causedapplyLocaleToDOMto replace the entiretextContent— stripping the+prefix and emoji characters on locale switch. Fixed by wrapping only the translatable label text in a<span data-i18n="...">. - German translation corrections (post-review): Fixed
cancelling(imperative → progressive"Wird abgebrochen…"),editing(first-person verb → noun"Bearbeitung"), and completed truncated descriptions forempty_subtitle,settings_desc_check_updates, andsettings_desc_cli_sessions.
[v0.42.0] — 2026-04-10
Features
- German translation (PR #190 by @DavidSchuchert): Complete
delocale covering all UI strings — settings, commands, sidebar, approval cards. Also extends the i18n system withdata-i18n-titleanddata-i18n-placeholderattribute support so tooltip text and input placeholders are now translatable. German speech recognition usesde-DE.
Bug Fixes
- Custom slash-model routing (PR #189 by @smurmann): Model IDs like
google/gemma-4-26b-a4bfrom custom providers (LM Studio, Ollama) were silently misrouted to OpenRouter because of the slash-heuristic. Custom providers now win: entries inconfig.yaml → custom_providersare checked first, so their model IDs route to the correct local endpoint regardless of format. - Phantom Custom group in model picker (PR #191 by @mbac): When
model.providerwas a named provider (e.g.openai-codex) andmodel.base_urlwas set,hermes_clireported'custom'as authenticated, producing a duplicate "Custom" group in the dropdown. The real provider's group was missing the configured default model. Fixed by discarding the phantomcustomentry when a real named provider is active. - Hyphen/space model group injection (PR #191): The "ensure default_model appears" post-pass used
active_provider.lower() in group_name.lower(), which fails foropenai-codexvs display nameOpenAI Codex(hyphen vs space). Now uses_PROVIDER_DISPLAYfor exact display-name matching.
[v0.41.0] — 2026-04-10
Features
- Optional HTTPS/TLS support (PR #199): Set
HERMES_WEBUI_TLS_CERTandHERMES_WEBUI_TLS_KEYenv vars to enable HTTPS natively. Usesssl.PROTOCOL_TLS_SERVERwith TLS 1.2 minimum. Gracefully falls back to HTTP if cert loading fails. No reverse proxy required for LAN/VPN deployments.
Bug Fixes
- CSP blocking Mermaid and Prism (PR #197): Added Content-Security-Policy and
Permissions-Policy headers to every response. CSP allows
cdn.jsdelivr.netinscript-srcandstyle-srcfor Mermaid.js (dynamically loaded) and Prism.js (statically loaded with SRI integrity hashes). All other external origins blocked. - Session memory leak (PR #196):
api/auth.pyaccumulated expired session tokens indefinitely. Added_prune_expired_sessions()called lazily on everyverify_session()call. No background thread, no lock contention. - Slow-client thread exhaustion (PR #198): Added
Handler.timeout = 30to kill idle/stalled connections before they exhaust the thread pool. - False update alerts on feature branches (PR #201): Update checker compared
HEAD..origin/mastereven when on a feature branch, counting unrelated master commits as missing updates. Now usesgit rev-parse --abbrev-ref @{upstream}to track the current branch's upstream. Falls back to default branch when no upstream is set. - CLI session file browser returning 404 (PR #204):
/api/listonly checked the WebUI in-memory session dict, so CLI sessions shown in the sidebar always returned 404 for file browsing. Now falls back toget_cli_sessions()— the same pattern used by/api/sessionGET and/api/sessionslist.
[v0.40.2] — 2026-04-09
Features
- Full approval UI (PR #187): When the agent triggers a dangerous command
(e.g.
rm -rf,pkill -9), a polished approval card now appears immediately instead of leaving the chat stuck in "Thinking…" forever. Four one-click buttons: Allow once, Allow session, Always allow, Deny. Enter key defaults to Allow once. Buttons disable immediately on click to prevent double-submit. Card auto-focuses Allow once so keyboard-only users can approve in one keystroke. All labels and the heading are fully i18n-translated (English + Chinese).
Bug Fixes
- Approval SSE event never sent (PR #187):
register_gateway_notify()was never called before the agent ran, so the approval module had no way to push theapprovalSSE event to the frontend. Fixed by registering a callback that callsput('approval', ...)the instant a dangerous command is detected. - Agent thread never unblocked (PR #187):
/api/approval/responddid not callresolve_gateway_approval(), so the agent thread waited for the full 5-minute gateway timeout. Now calls it on every respond, waking the thread immediately. _unreg_notifyscoping (PR #187): Variable was only assigned inside atryblock but referenced infinally. Initialised toNonebefore thetryso thefinallyguard is always well-defined.
Tests
- 32 new tests in
tests/test_sprint30.py: approval card HTML structure, all 4 button IDs and data-i18n labels, keyboard shortcut in boot.js, i18n keys in both locales, CSS loading/disabled/kbd states, messages.js button-disable behaviour, streaming.py scoping, HTTP regression for all 4 choices. - 16 tests in
tests/test_approval_unblock.py(gateway approval unit + HTTP). - 547 tests total (499 → 515 → 547).
[v0.40.1] — 2026-04-09
Bug Fixes
- Default locale on first install (PR #185): A fresh install would start in
English based on the server default, but
loadLocale()could resurrect a stale or unsupported locale code fromlocalStorage. NowloadLocale()falls back to English when there is no saved code or the saved code is not in the LOCALES bundle.setLocale()also stores the resolved code, so an unknown input never persists to storage.
[v0.40.0] — 2026-04-09
Features
- i18n — pluggable language switcher (PR #179): Settings panel now has a
Language dropdown. Ships with English and Chinese (中文). All UI strings use
a
t()helper that falls back to English for missing keys. The login page also localises — title, placeholder, button, and error strings all respond to the saved locale. Add a language by adding a LOCALES entry tostatic/i18n.js. - Notification sound + browser notifications (PR #180): Two new settings toggles. "Notification sound" plays a short two-tone chime when the assistant finishes or an approval card appears. "Browser notification" fires a system notification when the tab is in the background.
- Thinking / reasoning block display (PR #181, #182): Inline
<think>…</think>and Gemma 4<|channel>thought…<channel|>tags are parsed out of assistant messages and rendered as a collapsible lightbulb "Thinking" card above the reply. During streaming, the bubble shows "Thinking…" until the tag closes. Hardened against partial-tag edge cases and empty thinking blocks.
Bug Fixes
- Stray
}in message row HTML (PR #183): A typo in the i18n refactor left an extra}in themsg-rolediv template literal, producing<div class="msg-role user" }>. Removed. - JS-escape login locale strings (PR #183):
LOGIN_INVALID_PWandLOGIN_CONN_FAILEDwere injected into a JS string context without escaping single quotes or backslashes. Now uses minimal JS-string escaping.
[v0.39.1] — 2026-04-08
Bug Fixes
- _ENV_LOCK deadlock resolved. The environment variable lock was held for the entire duration of agent execution (including all tool calls and streaming), blocking all concurrent requests. Now the lock is acquired only for the brief env variable read/write operations, released before the agent runs, and re-acquired in the finally block for restoration.
[v0.39.0] — 2026-04-08
Security (12 fixes — PR #171 by @betamod, reviewed by @nesquena-hermes)
- CSRF protection: all POST endpoints now validate
Origin/RefereragainstHost. Non-browser clients (curl, agent) without these headers are unaffected. - PBKDF2 password hashing:
save_settings()was using single-iteration SHA-256. Now callsauth._hash_password()— PBKDF2-HMAC-SHA256 with 600,000 iterations and a per-installation random salt. - Login rate limiting: 5 failed attempts per 60 seconds per IP returns HTTP 429.
- Session ID validation:
Session.load()rejects any non-hex character before touching the filesystem, preventing path traversal via crafted session IDs. - SSRF DNS resolution:
get_available_models()resolves DNS before checking private IPs. Prevents DNS rebinding attacks. Known-local providers (Ollama, LM Studio, localhost) are whitelisted. - Non-loopback startup warning: server prints a clear warning when binding to
0.0.0.0without a password set — a common Docker footgun. - ENV_LOCK consistency:
_ENV_LOCKnow wraps allos.environmutations in both the sync chat and streaming restore blocks, preventing races across concurrent requests. - Stored XSS prevention: files with
text/html,application/xhtml+xml, orimage/svg+xmlMIME types are forced toContent-Disposition: attachment, preventing execution in-browser. - HMAC signature: extended from 64 bits to 128 bits (16-char to 32-char hex).
- Skills path validation:
resolve().relative_to(SKILLS_DIR)check added after skill directory construction to prevent traversal. - Secure cookie flag: auto-set when TLS or
X-Forwarded-Proto: httpsis detected. Usesgetattrsafely so plain sockets don't raiseAttributeError. - Error path sanitization:
_sanitize_error()strips absolute filesystem paths from exception messages before they reach the client.
Tests
- Added
tests/test_sprint29.py— 33 tests covering all 12 security fixes.
[v0.38.6] — 2026-04-07
Fixed
/insightsmessage count always 0 for WebUI sessions (#163, #164):sync_session_usage()wrote token counts, cost, model, and title tostate.dbbut nevermessage_count. Both the streaming and sync chat paths now passlen(s.messages). Note:/insightssync is opt-in — enable Sync to Insights in Settings (it's off by default).
[v0.38.5] — 2026-04-06
Fixed
- Custom endpoint URL construction (#138, #160):
base_urlending in/v1was incorrectly stripped before appending/models, producinghttp://host/modelsinstead ofhttp://host/v1/models. Fixed to append directly. custom_providersconfig entries now appear in dropdown (#138, #160): Models defined underconfig.yamlcustom_providers(e.g. Ollama aliases, Azure model overrides) are now always included in the dropdown, even when the/v1/modelsendpoint is unreachable.- Custom endpoint API key reads profile
.env(#138, #160): Custom endpoint auth now checks~/.hermes/.envkeys in addition toos.environ.
[v0.38.4] — 2026-04-06
Fixed
- Copilot false positive in model dropdown (#158):
list_available_providers()reported Copilot as available on any machine withghCLI auth, because the Copilot token resolver falls back togh auth token. The dropdown now skips any provider whose credential source is'gh auth token'— only explicit, dedicated credentials count. Users withGITHUB_TOKENexplicitly set in their.envstill see Copilot correctly.
[v0.38.3] — 2026-04-06
Fixed
- Model dropdown shows only configured providers (#155): Provider detection now uses
hermes_cli.models.list_available_providers()— the same auth check the Hermes agent uses at runtime — instead of scanning raw API key env vars. The dropdown now reflects exactly what the user has configured (auth.json, credential pools, OAuth flows like Copilot). When no providers are detected, shows only the configured default model rather than a full generic list. Addedcopilotandgeminito the curated model lists. Falls back to env var scanning for standalone installs without hermes-agent.
[v0.38.2] — 2026-04-06
Fixed
- Tool cards actually render on page reload (#140, #153): PR #149 fixed the wrong filter — it updated
visbut notvisWithIdx(the loop that actually creates DOM rows), so anchor rows were never inserted. This PR fixesvisWithIdx. Additionally,streaming.py'sassistant_msg_idxbuilder previously only scanned Anthropic content-array format and producedidx=-1for all OpenAI-format tool calls (the format used in saved sessions); it now handles both. As a final fallback,renderMessages()now builds tool card data directly from per-messagetool_callsarrays whenS.toolCallsis empty, covering historical sessions that predate session-level tool tracking.
[v0.38.1] — 2026-04-06
Fixed
- Model selector duplicates (#147, #151): When
config.yamlsetsmodel.defaultwith a provider prefix (e.g.anthropic/claude-opus-4.6), the model dropdown no longer shows a duplicate entry alongside the existing bare-ID entry. The dedup check now normalizes both sides before comparing. - Stale model labels (#147, #151): Sessions created with models no longer in the current provider list now show
"ModelName (unavailable)"in muted text with a tooltip, instead of appearing as a normal selectable option that would fail silently on send.
[v0.38.0] — 2026-04-06
Fixed
- Multi-provider model routing (#138): Non-default provider models now use
@provider:modelformat.resolve_model_provider()routes them throughresolve_runtime_provider(requested=provider)— no OpenRouter fallback for users with direct provider keys. - Personalities from config.yaml (#139):
/api/personalitiesreads fromconfig.yamlagent.personalities(the documented mechanism). Personality prompts pass viaagent.ephemeral_system_prompt. - Tool call cards survive page reload (#140): Assistant messages with only
tool_usecontent are no longer filtered from the render list, preserving anchor rows for tool card display.
[v0.37.0] /personality command, model prefix routing fix, tool card reload fix
April 6, 2026 | 465 tests
Features
/personalityslash command. Set a per-session agent personality from~/.hermes/personalities/<name>/SOUL.md. The personality prompt is prepended to the system message for every turn. Use/personality <name>to activate,/personality noneto clear,/personality(no args) to list available personalities. Backend:GET /api/personalities,POST /api/personality/set. (PR #143)
Bug Fixes
- Model dropdown routes non-default provider models correctly (#138). When the active provider is
anthropicand you pick aminimaxmodel, its ID is now prefixedminimax/MiniMax-M2.7soresolve_model_provider()can route it through OpenRouter. Guards added:active_provider=Noneprevents all-providers-prefixed, case is normalised, shared_PROVIDER_MODELSlist is no longer mutated by the default_model injector. (PR #142) - Tool call cards persist correctly after page reload. The reload rendering logic now anchors cards AFTER the triggering assistant row (not before the next one), handles multi-step chains sharing a filtered anchor in chronological order, and filters fallback anchor to assistant rows only. (PR #141)
[v0.36.3] Configurable Assistant Name
April 6, 2026 | 449 tests
Features
- Configurable bot name. New "Assistant Name" field in Settings panel.
Display name updates throughout the UI: sidebar, topbar, message roles,
login page, browser tab title, and composer placeholder. Defaults to
"Hermes". Configurable via settings or
HERMES_WEBUI_BOT_NAMEenv var. Server-side sanitization prevents empty names and escapes HTML for the login page. (PR #135, based on #131 by @TaraTheStar)
[v0.36.2] OpenRouter model routing fix
April 5, 2026 | 440 tests
Bug Fixes
- OpenRouter models sent without prefix, causing 404 (#116).
resolve_model_provider()was stripping theopenrouter/prefix from model IDs (e.g. sendingfreeinstead ofopenrouter/free) whenconfig_provider == 'openrouter'. OpenRouter requires the fullprovider/modelpath to route upstream correctly. Fixed with an early return that preserves the complete model ID for all OpenRouter configs. (#127) - Added 7 unit tests for
resolve_model_provider()— first coverage on this function. Tests the regression, cross-provider routing, direct-API prefix stripping, bare models, and empty model.
[v0.36.1] Login form Enter key fix
April 5, 2026 | 433 tests
Bug Fixes
- Login form Enter key unreliable in some browsers (#124).
onsubmit="return doLogin(event)"returned a Promise (async functions always return a truthy Promise), which could let the browser fall through to native form submission. Fixed withdoLogin(event);return falseplus an explicitonkeydownEnter handler on the password input as belt-and-suspenders. (#125)
[v0.35.1] Model dropdown fixes
April 5, 2026 | 433 tests
Bug Fixes
- Custom providers invisible in model dropdown (#117).
cfg_base_urlwas scoped inside a conditional block but referenced unconditionally, causing aNameErrorfor users with abase_urlin config.yaml. Fix: initialize to''before the block. (#118) - Configured default model missing from dropdown (#116). OpenRouter and other providers replaced the model list with a hardcoded fallback that didn't include
model.defaultvalues likeopenrouter/freeor custom local model names. Fix: after building all groups, inject the configureddefault_modelat the top of its provider group if absent. (#119)
[v0.34.3] Light theme final polish
April 5, 2026 | 433 tests
Bug Fixes
- Light theme: sidebar, role labels, chips, and interactive elements all broken. Session titles were too faint, active session used washed-out gold, pin stars were near-invisible bright yellow, and all hover/border effects used dark-theme white
rgba(255,255,255,.XX)values invisible on cream. Fixed with 46 scoped[data-theme="light"]selector overrides covering session items, role labels, project chips, topbar chips, composer, suggestions, tool cards, cron list, and more. (#105) - Active session now uses blue accent (
#2d6fa3) for strong contrast. Pin stars use deep gold (#996b15). Role labels are solid and high contrast.
[v0.34.2] Theme text colors
April 5, 2026 | 433 tests
Bug Fixes
- Light mode text unreadable. Bold text was hardcoded white (invisible on cream), italic was light purple on cream, inline code had a dark box on a light background. Fixed by introducing 5 new per-theme CSS variables (
--strong,--em,--code-text,--code-inline-bg,--pre-text) defined for every theme. (#102) - Also replaced remaining
rgba(255,255,255,.08)border references withvar(--border), and darkened light theme--code-bgslightly for better contrast.
[v0.34.1] Theme variable polish
April 5, 2026 | 433 tests
Bug Fixes
- All non-dark themes had broken surfaces, topbar, and dropdowns. 30+ hardcoded dark-navy rgba/hex values in style.css were stuck on the Dark palette regardless of active theme. Fixed by introducing 7 new CSS variables (
--surface,--topbar-bg,--main-bg,--input-bg,--hover-bg,--focus-ring,--focus-glow) defined per-theme, replacing every hardcoded reference. (#100)
[v0.31.2] CLI session delete fix
April 5, 2026 | 424 tests
Bug Fixes
- CLI sessions could not be deleted from the sidebar. The delete handler only
removed the WebUI JSON session file, so CLI-backed sessions came back on refresh.
Added
delete_cli_session(sid)inapi/models.pyand call it from/api/session/deleteso the SQLitestate.dbrow and messages are removed too. (#87, #88)
Notes
- The public test suite still passes at 424/424.
- Issue #87 already had a comment confirming the root cause, so no new issue comment was needed here.
[v0.30.1] CLI Session Bridge Fixes
April 4, 2026 | 424 tests
Bug Fixes
- CLI sessions not appearing in sidebar. Three frontend gaps:
sessions.jswasn't rendering CLI sessions (missingis_cli_sessioncheck in render loop), sidebar click handler didn't trigger import, and the "cli" badge CSS selector wasn't matching the rendered DOM structure. (#58) - CLI bridge read wrong profile's state.db.
get_cli_sessions()resolvedHERMES_HOMEat server launch time, not at call time. After a profile switch, it kept reading the original profile's database. Now resolves dynamically viaget_active_hermes_home(). (#59) - Silent SQL error swallowed all CLI sessions. The
sessionstable instate.dbhas noprofilecolumn — the query referenceds.profilewhich caused a silentOperationalError. Theexcept Exception: return []handler swallowed it, returning zero CLI sessions. Removed the column reference and added explicit column-existence checks. (#60)
Features
- "Show CLI sessions" toggle in Settings. New checkbox in the Settings panel
to show/hide CLI sessions in the sidebar. Persisted server-side in
settings.json(show_cli_sessions, defaulttrue). When disabled, CLI sessions are excluded from/api/sessionsresponses. (#61)
[v0.28.1] CI Pipeline + Multi-Arch Docker Builds
April 3, 2026 | 426 tests
Features
- GitHub Actions CI. New workflow triggers on tag push (
v*). Builds multi-arch Docker images (linux/amd64 + linux/arm64), pushes toghcr.io/nesquena/hermes-webui, and creates a GitHub Release with auto-generated release notes. Uses GHA layer caching for fast rebuilds. - Pre-built container images. Users can now
docker pull ghcr.io/nesquena/hermes-webui:latestinstead of building locally.
[v0.18.1] Safe HTML Rendering + Sprint 16 Tests
April 2, 2026 | 289 tests
Features
- Safe HTML rendering in AI responses. AI models sometimes emit HTML tags
(
<strong>,<em>,<code>,<br>) in their responses. Previously these showed as literal escaped text. A new pre-pass inrenderMd()converts safe HTML tags to markdown equivalents before the pipeline runs. Code blocks and backtick spans are stashed first so their content is never touched. inlineMd()helper. New function for processing inline formatting inside list items, blockquotes, and headings. The old code calledesc()directly, which escaped tags that had already been converted by the pre-pass.- Safety net. After the full pipeline, any HTML tags not in the output
allowlist (
SAFE_TAGS) are escaped viaesc(). XSS fully blocked -- 7 attack vectors tested. - Active session gold style. Active session uses gold/amber (
#e8a030) instead of blue, matching the logo gradient. Project border-left skipped when active (gold always wins).
Tests
- 74 new tests in
test_sprint16.py: static analysis (6), behavioral (10), exact regression (1), XSS security (7), edge cases (51). Total: 289 passed.
[v0.17.3] Bug Fixes
April 2, 2026
Bug Fixes
- NameError crash in model discovery.
logger.debug()was called in the custom endpointexceptblock inconfig.py, butloggerwas never imported. Every failed custom endpoint fetch crashed withNameError, returning HTTP 500 for/api/models. Replaced with silentpasssince unreachable endpoints are expected. (PR #24) - Project picker clipping and width. Picker was clipped by
overflow:hiddenon ancestor elements. Width calculation improved with dynamic sizing (min 160px, max 220px). Event listenerclosehandler moved after DOM append to fix reference-before-definition. Reorderedpicker.remove()beforeremoveEventListenerfor correct cleanup. (PR #25)
[v0.17.2] Model Update
April 2, 2026
Enhancements
- GLM-5.1 added to Z.AI model list. New model available in the dropdown for Z.AI provider users. (Fixes #17)
[v0.17.1] Security + Bug Fixes
April 2, 2026 | 237 tests
Security
- Path traversal in static file server.
_serve_static()now sandboxes resolved paths insidestatic/via.relative_to(). PreviouslyGET /static/../../.hermes/config.yamlcould expose API keys. - XSS in markdown renderer. All captured groups in bold, italic, headings,
blockquotes, list items, table cells, and link labels now run through
esc()beforeinnerHTMLinsertion. - Skill category path traversal. Category param validated to reject
/and..to prevent writing outside~/.hermes/skills/. - Debug endpoint locked to localhost.
/api/approval/inject_testreturns 404 to any non-loopback client. - CDN resources pinned with SRI hashes. PrismJS and Mermaid tags now have
integrity+crossoriginattributes. Mermaid pinned to@10.9.3. - Project color CSS injection. Color field validated against
^#[0-9a-fA-F]{3,8}$to preventstyle.backgroundinjection. - Project name length limit. Capped at 128 chars, empty-after-strip rejected.
Bug Fixes
- OpenRouter model routing regression.
resolve_model_provider()was incorrectly stripping provider prefixes from OpenRouter model IDs (e.g.openai/gpt-5.4-minibecamegpt-5.4-miniwith provideropenai), causing AIAgent to look for OPENAI_API_KEY and crash. Fix: only strip prefix whenconfig.providerexplicitly matches that direct-API provider. - Project picker invisible. Dropdown was clipped by
.session-itemoverflow:hidden. Now appended todocument.bodywithposition:fixed. - Project picker stretched full width. Added
max-width:220px; width:max-contentto constrain the fixed-positioned picker. - No way to create project from picker. Added "+ New project" item at the bottom of the picker dropdown.
- Folder button undiscoverable. Now shows persistently (blue, 60% opacity) when session belongs to a project.
- Picker event listener leak.
removeEventListeneradded to all picker item onclick handlers. - Redundant sys.path.insert calls removed. Two cron handler imports no longer prepend the agent dir (already on sys.path via config.py).
[v0.16.2] Model List Updates + base_url Passthrough
April 1, 2026 | 247 tests
Bug Fixes
- MiniMax model list updated. Replaced stale ABAB 6.5 models with current MiniMax-M2.7, M2.7-highspeed, M2.5, M2.5-highspeed, M2.1 lineup matching hermes-agent upstream. (Fixes #6)
- Z.AI/GLM model list updated. Replaced GLM-4 series with current GLM-5, GLM-5 Turbo, GLM-4.7, GLM-4.5, GLM-4.5 Flash lineup.
- base_url passthrough to AIAgent.
resolve_model_provider()now readsbase_urlfrom config.yaml and passes it to AIAgent, so providers with custom endpoints (MiniMax, Z.AI, local LLMs) route to the correct API.
[v0.16.1] Community Fixes -- Mobile + Auth + Provider Routing
April 1, 2026 | 247 tests
Community contributions from @deboste, reviewed and refined.
Bug Fixes
- Mobile responsive layout. Comprehensive
@media(max-width:640px)rules for topbar, messages, composer, tool cards, approval cards, and settings modal. Uses100dvhwith100vhfallback to fix composer cutoff on mobile browsers. Textareafont-size:16pxprevents iOS/Android auto-zoom on focus. - Reverse proxy basic auth support. All
fetch()andEventSourceURLs now constructed vianew URL(path, location.origin)to strip embedded credentials per Fetch spec.credentials:'include'on fetch,withCredentials:trueon EventSource ensure auth headers are forwarded through reverse proxies. - Model provider routing. New
resolve_model_provider()helper inapi/config.pystrips provider prefix from dropdown model IDs (e.g.anthropic/claude-sonnet-4.6→claude-sonnet-4.6) and passes the correctproviderto AIAgent. Handles cross-provider selection by matching against known direct-API providers.
[v0.12.2] Concurrency + Correctness Sweeps
March 31, 2026 | 190 tests
Two systematic audits of all concurrent multi-session scenarios. Each finding became a regression test so it cannot silently return.
Sweep 1 (R10-R12)
- R10: Approval response to wrong session.
respondApproval()usedS.session.session_id-- whoever you were viewing. If session A triggered a dangerous command requiring approval and you switched to B then clicked Allow, the approval went to B's session_id. Agent on A stayed stuck. Fixed: approval events tag_approvalSessionId;respondApproval()uses that. - R11: Activity bar showed cross-session tool status. Session A's tool
name appeared in session B's activity bar while you were viewing B. Fixed:
setStatus()in the tool SSE handler is now inside theactiveSidguard. - R12: Live tool cards vanished on switch-away and back. Switching back to
an in-flight session showed empty live cards even though tools had fired.
Fixed:
loadSession()INFLIGHT branch now restores cards fromS.toolCalls.
Sweep 2 (R13-R15)
- R13: Settled tool cards never rendered after response completes.
renderMessages()has a!S.busyguard on tool card rendering. It was called withS.busy=truein the done handler -- tool cards were skipped every time. Fixed:S.busy=falseset inline beforerenderMessages(). - R14: Wrong model sent for sessions with unlisted model.
send()used$('modelSelect').valuewhich could be stale if the session's model isn't in the dropdown. Fixed: now usesS.session.model || $('modelSelect').value. - R15: Stale live tool cards in new sessions.
newSession()didn't callclearLiveToolCards(). Fixed.
[v0.12.1] Sprint 10 Post-Release Fixes
March 31, 2026 | 177 tests
Critical regressions introduced during the server.py split, caught by users and fixed immediately.
uuidnot imported in server.py --chat/startreturned 500 (NameError) on every new messageAIAgentnot imported in api/streaming.py -- agent thread crashed immediately, SSE returned 404has_pendingnot imported in api/streaming.py -- NameError during tool approval checksSession.__init__missingtool_callsparam -- 500 on any session with tool history- SSE loop did not break on
cancelevent -- connection hung after cancel - Regression test file added (
tests/test_regressions.py): 10 tests, one per introduced bug. These form a permanent regression gate so each class of error can never silently return.