Commit Graph

248 Commits

Author SHA1 Message Date
nesquena-hermes f6ea11d22e fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
  1. `.+` required at least one character — bare `>` lines (blank
     continuation lines) did not match and passed through as literal `>`
  2. Each matching line became its own `<blockquote>` element — a 10-line
     blockquote produced 10 stacked `<blockquote>` tags with no grouping
  3. When a fenced code block sat inside a blockquote, the fence-stash
     pass consumed the code content and left orphaned `>` lines that the
     old `.+` pattern could not match

Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.

14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
2026-04-26 02:44:34 +00:00
nesquena-hermes 3d96dc1498 v0.50.215: real /steer via agent.steer() — mid-turn correction without interrupt (#1069)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-04-25 19:21:00 -07:00
nesquena-hermes 520034c071 v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067)
* feat: busy input modes with queue/interrupt/steer slash commands

- Add busy_input_mode setting (queue/interrupt/steer) to config defaults
- Add /queue, /interrupt, /steer slash commands with handlers
- Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior)
- Add settings dropdown in settings panel with load/save/apply wiring
- Initialize window._busyInputMode at boot and on settings save
- Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant)
Addresses #720

* test: 17 regression tests for busy_input_mode + slash commands

PR description noted manual testing only. Added structural tests
matching the pattern used by recent contributor PRs (#1010, #1011,
#1018, #1022, #1058) so future refactors don't silently regress
the wiring:

  Backend (api/config.py):
    - default 'queue' is set in _DEFAULT_SETTINGS
    - enum validator restricts to {queue, interrupt, steer}

  Slash commands (static/commands.js):
    - /queue, /interrupt, /steer all registered with correct fns
    - /interrupt and /steer set noEcho:true (the queued payload
      becomes the visible turn, not the slash invocation)
    - cmdQueue requires S.busy
    - cmdInterrupt + cmdSteer call queueSessionMessage before
      cancelStream (otherwise the drain has nothing to pick up)

  send() busy branch (static/messages.js):
    - reads window._busyInputMode
    - calls cancelStream on interrupt/steer
    - queues before cancelling (ordering invariant)

  Boot init + panels.js wiring (static/boot.js, static/panels.js):
    - both success and fallback paths set window._busyInputMode
    - load/save/apply path threads busy_input_mode through

  i18n (static/i18n.js):
    - all 17 new keys present in each of the 6 locale blocks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers

1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text
   as a user bubble, then the drain would send the queued message, causing a
   double-bubble in the conversation (#840 pattern).

2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue
   payload but never cleared S.pendingFiles or called renderTray(). Staged files
   would remain in the tray and be re-attached on the next send(), duplicating
   attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge().

3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only
   interrupt + steer), now documents that all three must set noEcho:true.

4. test_slash_commands_clear_pending_files: new test that all three handlers clear
   S.pendingFiles and call renderTray() after enqueuing.

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>

* docs: v0.50.214 release notes and version bump

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 18:51:06 -07:00
nesquena-hermes 01404ac062 v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels

* feat: adaptive session title refresh based on conversation evolution

Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.

Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
  with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
  refresh after stream_end (non-blocking)

Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}

Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)

Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.

* test: add 37 tests for adaptive title refresh helpers

Covers all five new functions introduced in this PR:
  _count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
  _run_background_title_refresh, _maybe_schedule_title_refresh

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>

* fix(settings): show selected state on theme/skin/font-size picker cards

The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.

Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.

Closes #1057

* fix: rename test file to match PR number, fix stale issue reference

* docs: v0.50.211 release notes and version bump

Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.

* docs(changelog): correct settings tab for adaptive title refresh

The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: create state dir before writing settings file

save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.

---------

Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:50:58 -07:00
nesquena-hermes 6c343aff84 v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs

Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.

Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent

* fix(models): add gpt-5.5-mini to copilot provider catalog

* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)

Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.

Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.

Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.

3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.

* fix(onboarding): non-standard provider/path cluster (#1029)

* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)

The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.

Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:

  syncTopbar()              — restores model chip, title, topbar state
  syncWorkspacePanelState() — restores workspace panel open/closed
  _initResizePanels()       — reattaches panel resize drag listeners
  startGatewaySSE()         — reconnects the gateway SSE watcher
                              (bfcache-persisted connections are dead)

All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.

loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.

7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.

* fix(bfcache): also close open dropdowns on bfcache restore (#1045)

Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.

Extend the pageshow handler to call all four named close functions before
the layout sync:
  closeModelDropdown()     — composer model selector
  closeReasoningDropdown() — thinking/reasoning effort selector
  closeWsDropdown()        — workspace chip dropdown
  closeProfileDropdown()   — profile switcher dropdown

All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.

2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)

2221/2221 tests passing.

* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners

* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test

* fix(sessions): use cron job name as session title when available (#1032)

* fix(test): add id column to messages table in cron title test fixture

* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block

* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)

When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.

Fix: intercept 401 responses client-side before surfacing any error.

- workspace.js api(): check res.status===401 first; call
  window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
  calls that bypass api() — api/models, api/models/live, api/upload

All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.

6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.

* fix(pwa): preserve current URL in ?next= param on 401 redirect

* fix(test): update 401-redirect assertion to accept ?next= URL format

* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login

Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.

* Implement session agent cache for AIAgent reuse

Added session agent cache to reuse AIAgent across messages.

* Implement agent caching for session management

* Implement session agent eviction on session deletion

Added session agent eviction to prevent turn count leakage in recycled sessions.

* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)

* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210

Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:

  - Mermaid CSP font fix (#1044)        → v0.50.210 / Mermaid Google Fonts CSP
  - bfcache layout restore (#1045)      → v0.50.210 / bfcache layout and dropdown restore
  - iOS PWA auth redirect (#1038)       → v0.50.210 / Login redirects back to original URL

The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:47:44 -07:00
nesquena-hermes 7d1aa2e261 v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
* feat: add manual 'Check for Updates' button in System settings (#785)

Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.

Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
  with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
  settings_up_to_date, settings_updates_available, settings_updates_disabled)
  in all 6 locales (en, ru, es, de, zh, zh-Hant)

* fix: sanitize error message in checkUpdatesNow to avoid exposing paths

Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.

* fix: fully sanitize error in update check — never expose raw e.message in UI

Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales

* fix(#785): align HTML selectors with CSS and add regression tests

- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
  checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales

* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)

* feat: categorize providers in setup wizard (#603)

- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
  Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
  Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
  instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant

* ci: re-run tests

* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644

The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.

Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.

* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests

- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
  gemini, deepseek, mistral, and x-ai through apply_onboarding_setup

* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai

* fix(onboarding): correct stale model defaults for specialized providers

Three issues in the new specialized provider catalog (#1027 hold reason):

1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
   has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
   Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
   which returns []. The catalog in api/config.py is keyed under "google"
   (even though the agent's alias map normalizes google -> gemini).
   Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
   the actual 5-model list. Also forward-compatible lookup for x-ai
   (xai or x-ai key).

Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.

deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.

Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
  prefixes (3.x for gemini, 4.x for grok)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: inline HTML preview in workspace panel (#779)

Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.

Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
  in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
  in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales

The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.

* docs: document sandbox security tradeoff for HTML preview

Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.

* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading

routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.

* fix(security): CSP sandbox header for inline HTML preview

The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.

Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.

Added test_inline_html_response_sets_csp_sandbox to pin the header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)

* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209

The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.

Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:33:41 -07:00
nesquena-hermes ad8e10304c v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object

Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.

The correct translations already exist in the zh-Hant locale block.

Fixes #1008

* fix: block stale SSE events from polluting new session's DOM

- appendThinking(): guard with !S.session||!S.activeStreamId to drop
  events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
  scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
  streaming if user is watching (scroll pinned)

* Fix empty agent sessions in sidebar

* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status

Fixes #995 — three sub-issues in the Cron Jobs UI:

1. Dual play icons ambiguous: Resume button now shows a distinct
   play+bar icon (play triangle + vertical line) instead of the
   identical triangle used by Run now.

2. Toast notification overlapping header buttons: Added
   position:relative; z-index:10 to .main-view-header so it
   stacks above the fixed toast (z-index:100 within its layer).

3. No running status after trigger: After triggering a job, the
   status badge immediately shows 'running…' with a CSS spinner
   animation, and polls the cron list every 3s (up to 30s) to
   refresh when the job completes.

- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()

* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback

- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback

* feat: create folder and add space directly from UI (#782)

- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales

* fix: validate workspace path before mkdir to prevent orphan directories

Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.

New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir

Also imports _workspace_blocked_roots for the pre-mutation blocklist check.

* fix(#1014): classify model-not-found errors with helpful message

- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n

* feat(ui): add live TPS stat to header

Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.

Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
  window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock

Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
  updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end

Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
  focus/blur was replaced with simple always-1s-when-active approach)

UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)

* feat: session restore speed + title gen reasoning hardening (#1025, #1026)

PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session

PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback

Co-authored-by: Frank Song <franksong2702@gmail.com>

* feat: session attention indicators in right slot + last_message_at timestamps (#1024)

PR #1024 (@franksong2702): Polish session attention indicators

- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
  so metadata-only saves don't make old sessions appear under Today

Co-authored-by: Frank Song <franksong2702@gmail.com>

* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 13:07:35 -07:00
nesquena-hermes 2800ebdcff fix(workspace): allow adding external paths not under home directory (#991)
The workspace add endpoint used resolve_trusted_workspace() which blocks any path
outside the user's home directory, the saved workspace list, or BOOT_DEFAULT_WORKSPACE.
This created a circular dependency: to add /mnt/d/Projects you need it in the saved
list, but to get it in the list you need to add it.

Fix: introduce validate_workspace_to_add() used by /api/workspaces/add, which only
blocks non-existent paths, non-directories, and known system roots. The stricter
resolve_trusted_workspace() is still used for actual file operations within a workspace.

Fixes #953.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 13:04:36 -07:00
Basit Mustafa e62338d3a0 fix(queue): drain correct session queue after cross-session stream completion (#964)
When a session finishes streaming while the user has switched to a different
session, setBusy(false) was draining S.session.session_id (the currently
*viewed* session) instead of the session that actually finished. Queued
follow-up messages were silently dropped.

Root cause: setBusy() has no context about which session triggered it.
The activeSid closure variable inside attachLiveStream() knew the right
session but was not propagated.

Fix: add _queueDrainSid module global (null by default). Stream done and
error handlers set it to activeSid immediately before calling setBusy(false).
setBusy(false) reads and clears _queueDrainSid, falling back to S.session if
it is unset (the common case where the user hasn't switched away).

Handlers patched: done event, start-call error handler, stream_end/stream_stop
reconnection fallback, and max-retry error exit.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 12:33:56 -07:00
nesquena-hermes ef72384217 fix: harden _accepts_gzip + update stale test assertions post-#959 (#981)
Fixes introduced when absorbing PR #959 (fast conversation switching):
- _accepts_gzip() now uses getattr() to tolerate _FakeHandler and any
  synthesised handler that lacks a .headers attribute (fixes 2 test failures
  in test_sprint46.py)
- test_issue401: updated assertion to accept both minified and reformatted
  forms of the tool_calls fallback guard (PR reformatted the code)
- test_regressions: updated activeStreamId assertion — PR refactored
  data.session references to S.session for direct state access

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:41:17 -07:00
Basit Mustafa 4a882bec66 fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.

The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:

- _load_sessions(): reads .sessions.json on module import, prunes expired
  entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
  mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed

Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 11:21:41 -07:00
bsgdigital a2d7f311be fix(streaming): prevent dropped characters in incremental smd path (#960)
Detect prefix desync between current display text and already-streamed text, then rebuild the streaming-markdown parser from full content to avoid character loss during live rendering. Add regression assertions for the new desync guard.

Made-with: Cursor

Co-authored-by: bsgdigital <bsg@bsgdigital.com>
2026-04-24 11:04:32 -07:00
bsgdigital e5cf9c5910 fix(streaming): strip malformed DSML function_calls tags (#958)
Handle DeepSeek DSML variants including truncated and spaced tag forms, and sanitize thinking-card text so leaked XML fragments never render. Add regression tests for DSML edge cases and thinking-card sanitization.

Made-with: Cursor

Co-authored-by: bsgdigital <bsg@bsgdigital.com>
2026-04-24 11:04:16 -07:00
starship-s 0a91e3cb02 fix: identify WebUI sessions as webui platform (#948)
* fix: use webui platform for webui sessions

* test: harden WebUI platform hint regression coverage
2026-04-24 11:03:42 -07:00
Basit Mustafa b072a6887c fix(csp): add explicit manifest-src 'self' directive (#961)
PR #920 added static/manifest.json and sw.js for PWA support. The CSP
in _security_headers() had no explicit manifest-src directive, so browsers
fell back to default-src 'self' and emitted a console warning on every page
load. The fallback is functionally correct but non-compliant with CSP Level 3
best practice of declaring each directive explicitly.

Adds manifest-src 'self' before base-uri. No origin set is changed.
Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 10:44:46 -07:00
Aron Prins 970bc1d3fd refactor(ui): three-column layout with left rail + main-view migration (#899)
refactor(ui): three-column layout with left rail + main-view migration (#899)

Unifies the shell into a three-column layout (rail + sidebar + main) matching the
hermes-desktop reference, and migrates every per-item detail/edit surface into a
shared main-view canvas with consistent headers, empty states, and action buttons.

Changes:
- New desktop-only left rail (48px) with 8 nav tabs (chat/tasks/skills/memory/workspaces/profiles/todos/settings)
- Persistent app titlebar (replaces per-chat topbar), active conversation title shown
- All panel detail/create/edit views migrated to #mainSkills, #mainTasks, #mainSettings, #mainWorkspaces, #mainProfiles, #mainMemory
- Settings moved out of modal into main-view page; ESC closes it
- YAML frontmatter rendered in collapsible <details> block in skill detail
- Toasts repositioned from bottom-center to top-right with theme-aware success/error/warning/info variants
- Composer workspace chip split into two-button group: files-icon toggles file panel, label opens workspace picker
- .settings-menu → .side-menu / .side-menu-item (generalised, shared by memory and settings panels)
- i18n: ~25 new keys across en/ru/es/de/zh/zh-Hant for all new form labels, placeholders, and empty states
- Mobile: hamburger in titlebar, slide-in sidebar; box-shadow removed from sidebar
- New regression test: tests/test_settings_navigation_and_detail_refresh.py (9 tests)

Co-authored-by: Aron Prins <pwf.aron@gmail.com>
2026-04-24 09:05:25 -07:00
nesquena-hermes 061af78cde v0.50.185: /btw stream hardening + .venv bootstrap + /reasoning toast (#935 #939 #941 #942)
* fix(bootstrap): discover .venv layout in agent_dir (closes #938) (#941)

* fix(btw): harden _streamDone flag — defensive ordering + session guard + stream_end coverage (#935)

* fix(btw): align /reasoning toast prefix with BRAIN const (#939)

* docs: v0.50.185 release notes, update test counts to 2107

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 23:25:45 -07:00
nesquena-hermes 1a9dba7844 fix: reasoning chip dropdown visible + monochrome SVG icon + /btw answer preserved (closes #933) (#934)
* fix: reasoning chip dropdown visible + SVG icon + /btw answer no longer wiped (closes #933)

* fix(ui): resize handler symmetry + lock regressions for PR #934 fixes

Two small additions on top of the core PR:

1. Resize handler now re-positions the reasoning dropdown when the window
   resizes while it's open, matching the existing model-dropdown branch.
   Without this, resizing while the dropdown is open leaves it aligned to
   the pre-resize chip position — fine in practice (most resizes close the
   dropdown via the global click handler) but inconsistent with the
   model-dropdown sibling.

2. Regression test file tests/test_reasoning_chip_btw_fixes.py with 10
   tests locking all four fixes in place so they can't silently regress:

   - Dropdown sits OUTSIDE .composer-left (so overflow-y: hidden can't clip it)
   - Dropdown is grouped with the other composer-level dropdowns
   - Chip button contains stroke="currentColor" SVG (not a 🧠 emoji)
   - _applyReasoningChip() body doesn't include 🧠
   - cmdReasoning calls _applyReasoningChip(eff) directly with the
     server-confirmed effort, not syncReasoningChip() (stale cache)
   - _streamDone flag declared, set in done handler, checked in onerror
   - _ensureBtwRow() called in done handler (creates bubble when no tokens arrive)
   - resize handler re-positions composerReasoningDropdown

Full suite: 2056 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:18:51 -07:00
Nathan Esquenazi 63b0207604 fix(background): wire completion hook + keep running tasks in tracker
The /background feature was fundamentally non-functional as shipped —
two coupled bugs kept results from ever reaching the user:

1. complete_background() was defined but NEVER called.  The
   _handle_background thread ran _run_agent_streaming and then exited;
   no hook signalled the task tracker that the work was done.  Every
   background task stayed in status="running" forever and
   get_results() (which filters to done-only) always returned [].

2. get_results() called _BACKGROUND_TASKS.pop(parent_sid, []) which
   removed the ENTIRE list — including tasks still in flight.  Even if
   bug #1 were fixed, the first frontend poll during a long-running
   task would drop the task from the tracker, and
   complete_background()'s loop would iterate over an empty list when
   the worker eventually finished — the result would still be lost.

Fix:

- api/background.py::get_results now retains running tasks in the
  dict; only done ones are popped and returned.
- api/routes.py::_handle_background wraps _run_agent_streaming in an
  inline worker (_run_bg_and_notify) that, after streaming completes,
  reloads the hidden bg session, extracts the last non-error assistant
  message, and calls complete_background(parent_sid, task_id, answer).
  Worker also best-effort unlinks the hidden bg session file so
  SESSION_DIR doesn't accumulate debris.
- Exception safety: any failure in _run_agent_streaming or the
  post-processing path still calls complete_background with a fallback
  sentinel so the frontend's polling loop doesn't hang forever.

Added 5 regression tests in tests/test_background_tasks.py:
- running tasks survive get_results polls
- done tasks are returned and removed
- poll → complete → poll round-trip surfaces the answer (this is the
  original bug's reproduction path)
- empty parent is cleaned up
- static check: _handle_background's worker calls complete_background
  and uses Session.load to extract the answer

Full suite: 2023 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:32:47 +00:00
nesquena-hermes 14a1924796 fix(streaming): respect auxiliary.title_generation config for session titles
- _aux_title_configured(): returns True when provider/model/base_url is set
- _aux_title_timeout(): reads configured timeout, falls back to 15.0s default
- _generate_llm_session_title_via_aux: use_agent_model kwarg preserves old behavior
- Missing llm_invalid_aux fallback now triggers agent-model retry
- 23 new tests in tests/test_title_aux_routing.py — all pass

Co-authored-by: starship-s <starship-s@users.noreply.github.com>
2026-04-24 01:07:02 +00:00
Nathan Esquenazi b563484a56 fix(smd): strip javascript:/data:/vbscript: URLs — smd does not sanitize schemes
streaming-markdown@0.2.15 preserves arbitrary URL schemes in href/src.
Verified with a Node + jsdom harness:

  IN : [click](javascript:alert(1))
  OUT: <p><a href="javascript:alert(1">click</a>)</p>        ← XSS vector

Confirmed unsafe for: javascript:, vbscript:, data:text/html, file://.
The library uses only safe DOM primitives (createElement/appendChild/
createTextNode — no innerHTML/eval), so <script> tags are escaped as
text, but URL-scheme filtering is absent. The existing renderMd() path
implicitly filtered to http(s) via its regex, so this is a regression
the moment streaming markdown is enabled.

Attack path: agent echoes prompt-injection content containing a
markdown link with javascript: href → smd renders it live → user clicks
during the streaming window → JS executes in webui origin → session
cookie, API calls, etc.

Fix: walk the live DOM after each parser_write (and again after
parser_end) and remove href/src attributes whose scheme isn't on the
safe allowlist (http, https, mailto, tel, and relative/anchor paths).
Blocked anchors keep their text content but lose href; blocked images
lose src and get data-blocked-scheme="1" for debugging.

Harness confirms all 10 tested cases behave correctly — javascript:,
vbscript:, data:text/html, file:// all stripped; https://, /path,
#anchor, mailto:, tel: all preserved.

Added 5 regression tests in TestSmdUrlSchemeSanitization that lock:
  - the sanitize helper exists
  - the allowlist regex permits https? and forbids javascript/vbscript/data:
  - _smdWrite invokes sanitize after parser_write
  - _smdEndParser invokes sanitize after parser_end
  - the sanitizer covers both <a href> and <img src>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:28:40 -07:00
nesquena-hermes 89b0c8eb41 feat: incremental streaming markdown via streaming-markdown (v0.50.180, #917)
Co-authored-by: bsgdigital
2026-04-23 23:09:08 +00:00
nesquena-hermes a3647570fb fix: persist onboarding_completed for CLI-configured users on first chat_ready (#922)
* fix: persist onboarding_completed for CLI-configured users on first chat_ready (v0.50.179, #921)

Co-authored-by: bsgdigital

* fix(onboarding): don't 500 the status endpoint if save_settings fails

The #921 persist call `save_settings({"onboarding_completed": True})` in
get_onboarding_status() raises if the settings.json write fails
(read-only filesystem, disk full, permission error). That turns every
/api/onboarding/status call into a 500 until the disk is writable,
which is much worse UX than losing the persistence-across-restart guard.

Wrapped in try/except so persistence becomes best-effort. The function
still sets settings["onboarding_completed"] = True in memory on success,
and `completed` reflects `config_auto_completed` on this request either
way, so the user sees the right state even when the write fails — only
the next-restart protection degrades.

Added regression test that patches save_settings to raise OSError and
asserts the endpoint still returns completed=True without raising.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:46:02 -07:00
nesquena-hermes 1011918d50 feat: add PWA support (manifest, service worker, install prompt) (#920)
* feat: add PWA support (manifest, service worker, install prompt) (v0.50.178, #911)

Co-authored-by: bsgdigital
Closes #685

* fix(sw): await caches.match() before `|| fallback` so offline HTML actually shows

The offline-navigation fallback was dead code:

    return caches.match('./') || new Response('<html>...</html>', ...);

`caches.match()` returns a Promise, and Promise objects are always truthy
in a `||` check — so the `new Response(...)` branch was never taken. On
actual offline, `caches.match('./')` resolves to undefined (no cache hit
for the root), the SW returns undefined, and the browser falls back to
its own default offline page. The custom "Hermes requires a server
connection" HTML was unreachable.

Fix by threading the match through `.then()` so the resolved value (not
the Promise object) feeds the `||`:

    return caches.match('./').then((cached) => cached || new Response(...));

Added 13 regression tests in tests/test_pwa_manifest_sw.py covering:
- manifest.json validity + required PWA fields + icon existence
- sw.js cache-version placeholder + API/stream bypass + correct offline
  pattern (explicitly rejects the broken `|| new Response` shape so it
  can't regress)
- /manifest.json + /sw.js routes serve correct Content-Type,
  Cache-Control, Service-Worker-Allowed headers and inject WEBUI_VERSION
- index.html links manifest, registers SW, has iOS PWA meta tags

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:14:21 -07:00
nesquena-hermes 1175ee363f fix(models): duplicate dropdown entries, stale default model, lowercase injected label (#907 #908 #909) (#918)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 14:41:06 -07:00
nesquena-hermes 5b923a9502 fix: harden session persistence and per-session lock handling during streaming (v0.50.175, #910) (#910)
Co-authored-by: starship-s

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 14:25:43 -07:00
nesquena-hermes 537c8271db fix(renderer): ordered list items always showed 1. — emit value= on each li (#886) (#904)
* fix(renderer): ordered list items always showed 1. — emit value= on each <li> (#886)

Root cause: when LLMs output numbered lists with blank lines between items,
renderMd()'s paragraph-splitter (split(/\n{2,}/)) breaks the markdown into
one chunk per item. The ordered-list regex then wraps each item in its own
<ol>, and since each <ol> restarts at 1, the rendered output is always 1. 1. 1.

Fix: capture the original number from each list line and emit value="N" on
every <li>. The HTML spec guarantees that value= overrides the <ol> counter,
so even items in separate <ol> containers display their correct ordinal.

6 regression tests in tests/test_886_ordered_list_numbering.py.
1958 tests pass.

* chore: add v0.50.173 CHANGELOG entry for ordered list fix

---------

Co-authored-by: Hermes Bedrock Fix <hermes-fixes@local>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 12:15:56 -07:00
nesquena-hermes 9dd6e3f338 fix(cancel): preserve partial streamed response on Stop Generation (#893) (#902)
* fix(cancel): preserve partial streamed response on Stop Generation (#893)

* docs(cancel): fix misleading comment — partial message is NOT _error=True

The outer comment block claimed `_error=True so _sanitize_messages_for_api()
strips it from future conversation history`, but the actual append call
sets only `_partial=True` (correctly matching the inner comment six lines
below and the PR description). Updated the outer comment to match reality
so a future reader doesn't try to "fix" the code to match the wrong comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:16:59 -07:00
nesquena-hermes 4089972b09 fix(models): preserve @nous: prefix in settings + fix cross-namespace 404 for Nous (#895 #894) (#901)
* fix(models): preserve @nous: prefix in settings + fix cross-namespace 404 for Nous (#895 #894)

* fix(review): persist bare form for CLI compatibility + picker smart-match

The PR persisted `@nous:anthropic/claude-opus-4.6` verbatim to config.yaml
to make the Settings picker match its dropdown options (which carry the
`@nous:` prefix after #885). That fixes the WebUI picker but introduces a
cross-tool regression: hermes-agent's CLI reads `config.yaml -> model.default`
directly and passes it to the provider API verbatim. For aggregator providers
(Nous is one — see hermes_cli/model_normalize.py `_AGGREGATOR_PROVIDERS`),
`normalize_model_for_provider` is skipped entirely (run_agent.py:887), so
the literal `@nous:anthropic/...` string flows to the Nous API, which rejects
it — breaking every user who runs `hermes` in the terminal right after
saving via WebUI.

Fix the tension at the picker rather than the persistence: the existing
`_findModelInDropdown()` smart matcher already normalises both sides
(lowercase, strip namespace prefix, dashes→dots) so a saved bare
`anthropic/claude-opus-4.6` resolves to the `@nous:anthropic/claude-opus-4.6`
option automatically. Applied this in panels.js via `_applyModelToDropdown()`.

Changes:
  api/config.py         revert the @-prefix preservation; persist the
                        resolved bare/slash form (CLI-compatible)
  static/panels.js      Settings picker uses _applyModelToDropdown()
                        instead of raw `.value =` so saved bare forms
                        still select the matching @nous: option
  tests                 test renamed + asserts bare persisted form;
                        new test locks the smart-matcher contract

This also improves behaviour for a dormant case not flagged in #895: a user
who set their default via `hermes model X` and opens Settings for the first
time used to see a blank picker (bare form vs prefixed options). Now the
smart matcher finds the right option, so the "open Settings → save → bare
form in config.yaml" round-trip is stable for both CLI- and WebUI-origin
saves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update CHANGELOG v0.50.171 — bare-form persistence + picker smart-match

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:44:10 -07:00
nesquena-hermes 498156a3e8 fix(settings): show live models in default model picker and apply to new chats (#872) (#900)
* fix(settings): show live models in default model picker and apply to new chats (#872)

Two related bugs:
1. Settings > Preferences > Default Model dropdown only showed static models
   from /api/models — live-fetched models (e.g. @nous:anthropic/claude-opus-4.7)
   were missing. Now calls _fetchLiveModels() on the settings picker too.
2. New chats ignored the saved default model preference — they always used the
   chat-header dropdown value (which reflects the previous session's model).
   Now newSession() uses the saved default_model and syncs the dropdown.

Extracted _addLiveModelsToSelect() from _fetchLiveModels() so cached live models
can be applied to any <select> element (chat-header or settings picker).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): update live-model prefix tests for _addLiveModelsToSelect extraction

The tests searched for og.dataset.provider, _isPortalFetch, and openrouter
exclusion patterns inside _fetchLiveModels(). These were extracted into
_addLiveModelsToSelect() as part of the #872 fix. Updated regex targets to
check _addLiveModelsToSelect first, falling back to _fetchLiveModels.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: add multi-tab note on window._defaultModel

Clarifies that window._defaultModel is per-page-load and not synced
across browser tabs, following maintainer feedback on #889.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: CHANGELOG for v0.50.170

* chore: trigger PR refresh after rebase

---------

Co-authored-by: fr33m1nd <bergeouss@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 09:58:15 -07:00
Pavol Biely 96c97c5e0e fix: remove hardcoded chinese title heuristics (#887)
* fix: remove hardcoded chinese title heuristics

* fix: use english placeholder for non-latin fallback titles
2026-04-23 09:45:34 -07:00
nesquena-hermes b82954ee70 feat(ui): session attention indicators — streaming spinner, unread dot, timestamps (#856)
Closes #856. Co-authored-by: Frank Song <138988108+franksong2702@users.noreply.github.com>
Reviewed-by: nesquena (709bd37 — test isolation fix also included)
2026-04-23 09:05:57 -07:00
nesquena-hermes 666d385c03 fix: Nous static models use @nous: prefix — v0.50.164 (#885)
fix: Nous static models use @nous: prefix — v0.50.164 (#885)

Follow-up to #854 / PR #870. The previous fix made Nous static IDs
slash-prefixed and added a portal-guard branch to resolve_model_provider().
This tightens the static list to use the explicit @nous: prefix, matching
the format of live-fetched models after ui.js's _fetchLiveModels() portal-
prefix step.

The @provider:model branch in resolve_model_provider() is more explicit and
reliable than the portal-guard fallback. Both static and live-fetched paths
now converge on the same resolver output — and as a side effect, the dedup
check in _fetchLiveModels() now correctly identifies static entries as already
present, eliminating duplicate entries in the dropdown for Nous users.

Verified: all 29 Nous models in the browser dropdown carry @nous: prefix,
routing confirmed correct via resolve_model_provider() for all 4 static IDs,
1941 tests passing.

Closes #854.
2026-04-22 22:56:21 -07:00
nesquena-hermes d39d30a213 fix: correct message ordering after task cancellation — v0.50.163 (#883)
fix: correct message ordering after task cancellation — v0.50.163 (#883)

Fixes the message-ordering glitch from #882: clicking Cancel while the
agent is responding could cause a subsequent response to render above
the "*Task cancelled.*" marker.

Root cause: the cancel handler pushed the marker only to local S.messages
without persisting to the server. When the done event fired shortly after
and replaced S.messages from server state, the marker disappeared from
client state while the next response anchored to the server-authoritative
position.

Fix has three parts:
- Server (cancel_stream): append *Task cancelled.* to session.messages
  with _error:True + timestamp, then save. _error ensures
  _sanitize_messages_for_api() strips it from conversation_history on
  the next agent turn, so the LLM never sees it as a prior assistant
  turn. Precedent: same flag used for the apperror marker at line 1343.
- Client (SSE cancel handler): fetch /api/session instead of pushing
  locally (same pattern as the done handler). Falls back to local push
  if the fetch fails.
- Tests: fix test window width for cancel handler (1200→dynamic); add
  two regression tests pinning _error flag and _sanitize invariant.

1941 tests passing.

Co-authored-by: piliang <piliang1@jd.com>
2026-04-22 22:17:40 -07:00
Frank Song 62c56175b7 feat(workspaces): autocomplete trusted workspace paths — v0.50.162 (PR #880 by @franksong2702, closes #616)
Adds GET /api/workspaces/suggest endpoint and autocomplete dropdown in the Spaces panel. Suggestions limited to trusted roots (home, saved workspaces, boot default). Keyboard nav, Tab completion, hidden dir support. Symlink-escape and dotdot-escape invariants locked by regression tests.
2026-04-23 02:35:58 +00:00
nesquena-hermes 0f1b232c12 fix(ci): eliminate test_set_key flakiness — v0.50.161
Root cause: test_profile_env_isolation.py and test_profile_path_security.py called sys.modules.pop() without restoring, poisoning subsequent tests. Fix: monkeypatch.delitem so pytest auto-restores. Also holds _ENV_LOCK for full I/O cycle in _write_env_file and creates .env at 0600 via os.open. Reviewed by Opus (no independent review needed — test/providers fix only).
2026-04-23 02:09:37 +00:00
Pavol Biely 236a116888 fix(ux): selected text visible in user message bubbles + CI i18n fix — v0.50.160 (PR #877 by @pavolbiely)
User bubble selection contrast fixed via scoped ::selection CSS (closes #877). Also adds missing provider i18n keys to es/de/zh/ru/zh-Hant locales, fixing 3 CI failures that crept in from PR #867.
2026-04-23 01:19:21 +00:00
nesquena-hermes 04b00065f9 feat: provider key management from Settings — v0.50.159 (PR #867 by @bergeouss, closes #586)
New Providers tab in Settings lets users add/update/remove API keys without editing .env. Six review fixes applied. 18 tests.
2026-04-23 01:09:22 +00:00
nesquena-hermes e3607855b1 fix: poll /health after update instead of blind setTimeout — v0.50.158 (closes #874)
Replaces blind setTimeout reload with /health polling loop. Banner shows restart status with manual Reload button. Works behind reverse proxies. 25 regression tests.
2026-04-23 00:51:12 +00:00
nesquena-hermes 0a75b3f1d3 fix: Nous portal model IDs + portal provider routing guard — v0.50.157 (closes #854)
Two bugs fixed: (1) _PROVIDER_MODELS["nous"] updated to slash-prefixed IDs that Nous API expects. (2) resolve_model_provider() now routes portal provider models through the portal (not OpenRouter) and preserves the full slash-prefixed model ID. 10 regression tests.
2026-04-22 23:05:27 +00:00
nesquena-hermes 3a63fe479e fix(security): gate auto-install behind HERMES_WEBUI_AUTO_INSTALL=1 — v0.50.156
Breaking: auto_install_agent_deps() is now disabled by default. Set HERMES_WEBUI_AUTO_INSTALL=1 to re-enable. New _trusted_agent_dir() checks ownership and permission bits. Addresses #842 by @tomaioo.
2026-04-22 20:49:28 +00:00
nesquena-hermes 558b1730a6 fix: thinking card no longer mirrors main response — v0.50.154 (closes #852)
Remove early return in _streamDisplay() bypassing think-block stripping when reasoningText populated.
2026-04-22 20:21:42 +00:00
nesquena-hermes 201235d807 fix: live-fetched portal models route through configured provider — v0.50.153 (closes #854)
_fetchLiveModels() applies @provider: prefix to model IDs from portal providers.
2026-04-22 20:21:02 +00:00
nesquena-hermes 256b3fbbdf fix: image_generate renders inline + auto-title strips thinking preamble — v0.50.152 (closes #853, #857)
MEDIA: restore renders all https:// URLs as img (closes #853).
_strip_thinking_markup strips Qwen3 plain-text reasoning preambles (closes #857).
2026-04-22 20:20:01 +00:00
nesquena-hermes 5fa731ea4a release: v0.50.151 — credential_pool provider detection + Ollama Cloud support (PR #820 by @starship-s)
Surfaces providers added via credential_pool in the model dropdown. Ambient gh-cli tokens suppressed. _apply_provider_prefix helper extracted. Ollama Cloud display name + dynamic model list. looksLikeBareOllamaId heuristic tightened. Test isolation fixed.

PR #820 by @starship-s.
2026-04-22 20:18:02 +00:00
Miguel Tavares f42f1c69ca fix: correct webui profile switching state — v0.50.150 (PR #849 by @migueltavares)
Three related profile-switching fixes:
- Always persist hermes_profile=default cookie when switching back to default (was being cleared with max-age=0, causing fallback to process-global profile)
- Replace undefined updateWorkspaceChip() with syncTopbar() in the sessionInProgress branch of switchToProfile()
- Make sidebar/dropdown active-profile rendering prefer S.activeProfile client state when available, with safe fallback

Tests: 1854 passing.
2026-04-22 16:27:01 +00:00
Frank Song 418d77443c fix: keep GET /api/session side-effect free for stale models — v0.50.149 (PR #848 by @franksong2702)
Replace _normalize_session_model_in_place() on the GET /api/session read path with a read-only _resolve_effective_session_model_for_display() that returns the effective display model without writing it back to disk or the session index.

Closes #845.

Tests: 1856 passing.
2026-04-22 16:26:48 +00:00
Frank Song 13dbd818c9 fix: prune stale session index entries after session-id rotation — v0.50.148 (PR #847 by @franksong2702)
Prune ghost _index.json rows whose backing session file no longer exists, on both incremental index writes and all_sessions() reads. Fixes duplicate session entries after session-id rotation (e.g. context compression). Also pre-snapshots in_memory_ids under a single LOCK acquisition in all_sessions() rather than one per row.

Closes #846.

Review additions: optimised lock pattern in all_sessions() (one LOCK acquisition instead of N). Tests: 1856 passing.
2026-04-22 16:26:38 +00:00
nesquena-hermes 85434dd03c fix(appearance): font size setting now visibly scales UI text (closes #843)
* fix(appearance): font size setting now visibly scales UI text

Root cause: the original CSS override only changed :root{font-size} which
has no effect on the 232+ hardcoded px values throughout style.css. Only
the ~49 em/rem values were affected, which are not the main visible text.

Fix: add explicit px overrides for the key UI surfaces under each
data-font-size attribute selector:
  - .msg-body (chat messages) + headings, code, tables
  - .session-item, .session-meta (sidebar session list)
  - #msg (composer textarea)
  - .file-item (workspace file tree)

The :root override is kept so em/rem cascade correctly, but the targeted
element overrides are what actually make the text visibly larger/smaller.

Also: 8 new regression tests lock in the targeted CSS rules so this
cannot silently regress again.

* fix: composer large font was no-op — bump to 18px (default is 16px)

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 23:39:39 -07:00
nesquena-hermes db57c47ff3 fix(ui): slash command input now echoed as user message in chat (closes #840)
* fix(ui): echo slash command input as user message in chat (#840)

Slash commands like /skills, /help, /status previously showed only the
assistant response with no user message above it — the conversation
appeared to start from nowhere.

Fix: executeCommand() now returns {noEcho:bool} instead of true/false
(returns null when no command matched). send() in messages.js pushes a
user message bubble before returning when noEcho is false.

Commands with noEcho:true are action-only and don't get echoed:
/clear, /new, /stop, /retry, /undo, /voice, /model, /workspace,
/theme, /usage, /reasoning.

Commands without noEcho (get echoed):
/help, /skills, /status, /title, /compress, /compact, /personality.

16 new tests in test_issue840_slash_echo.py.

* fix(ui): push user message BEFORE running slash handler (ordering bug)

The PR as originally written pushed the user message AFTER the slash
command handler ran.  That works correctly for async handlers (the
assistant response lands later, after the user push) but breaks for
sync handlers like cmdHelp which push their assistant response
synchronously:

  S.messages = [assistant response, user "/help"]   ← reverse order

The chat would render the help content ABOVE the user's own "/help"
input — not what the issue asked for.

Fix: look up the command inline, push the user message first (for
echo-worthy commands), then run the handler.  If the handler opts out
(returns false — e.g. /reasoning <level>), pop the user message back
off so the normal send path can add it cleanly when forwarding to the
agent.

Renamed the flow so it's clear we're not calling executeCommand twice
(my first attempt did that by accident).  executeCommand() stays as a
public API returning null or {noEcho:bool} — just isn't the only path
send() uses now.

Added 2 regression tests:

- test_send_pushes_user_message_before_running_handler: asserts
  the user push appears before the handler invocation in source order.
- test_send_rolls_back_user_push_on_handler_optout: asserts the
  S.messages.pop() for the opt-out case.

Also tightened the existing `test_send_checks_noecho_flag` and
`test_send_pushes_user_message_for_echo_commands` tests to look at
the new `_cmd.noEcho` pattern inline (vs the original
`cmdResult.noEcho`).  Removed `test_send_uses_null_check_not_truthy`
(obsoleted — the control flow no longer stores the executeCommand
return in a variable).

Full suite: 1767 passed, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): compress/compact noEcho + title/personality confirmation messages

Applied Opus mentor review fixes:
- compress and compact: add noEcho:true (S.messages reset internally causes
  user bubble to flicker/disappear without noEcho)
- /title <name>: push assistant confirmation message after rename succeeds
- /personality <name>: push assistant confirmation message after set succeeds
- 4 new regression tests covering the above invariants

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:08:24 -07:00