Commit graph

278 commits

Author SHA1 Message Date
Hermes Agent
fea47bd986 Heal 'provider: local' mid-conversation crash for local-model users (#1388, fixes #1384) 2026-05-01 05:29:42 +00:00
starship-s
bdc328d034 fix: preserve webui model provider context
Persist session model_provider separately from model IDs so active/default provider selections like gpt-5.5 remain bare while routing through OpenAI Codex. Keep @provider:model for picker disambiguation and runtime bridging, and preserve explicit OpenRouter plus custom/proxy base_url routing.
2026-04-30 23:23:47 -06:00
bergeouss
c5f4f569d6 fix(#1361): preserve reasoning, tool calls, and partial output on Stop/Cancel (#1375)
Three distinct data-loss paths fixed:

§A — Reasoning text was accumulated in a thread-local _reasoning_text
inside _run_agent_streaming. cancel_stream() never saw it because it
went out of scope when the thread was interrupted. Now mirrored to a
new shared dict STREAM_REASONING_TEXT keyed by stream_id, populated
in on_reasoning() and the reasoning branch of on_tool(), read in
cancel_stream().

§B — Live tool calls in thread-local _live_tool_calls were similarly
invisible to cancel_stream(). Now mirrored to STREAM_LIVE_TOOL_CALLS
on tool.started + tool.completed.

§C — Reasoning-only streams produced no partial message because the
thinking-block regex strip returned empty string and the `if _stripped:`
guard skipped the append. Now appends the partial message when EITHER
content text, reasoning trace, OR tool calls exist.

Mirrors the existing STREAM_PARTIAL_TEXT pattern from #893 exactly:
same dict creation in _run_agent_streaming, same _live_config fallback
in cancel_stream, same cleanup in _periodic_checkpoint.

8 regression tests in tests/test_issue1361_cancel_data_loss.py
covering all three sections plus tools+text combinations.

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-30 23:24:29 +00:00
nesquena-hermes
4683a4a0d0 fix(models): default model rehydration when providers share slash-qualified IDs (#1313)
From PR #1326.

Co-authored-by: hacker2005 <chen20057275@outlook.com>
2026-04-30 15:24:35 +00:00
nesquena-hermes
ded9b7e1c4
release: v0.50.243 (#1302)
release: v0.50.243

Batch release of 2 PRs.

- #1301 — fix: remove PRIMARY chip badge + add Claude Opus 4.7 label
  Drops the chip-projected configured-model badge added in #1287 (chip
  width 235px → 164px). Adds Claude Opus 4.7 label entries so the picker
  no longer renders "Claude Opus 4 7" (missing dot).
  Independently reviewed and approved by nesquena (commit c0bbd23).

- #1297 (@franksong2702) — fix: preserve cron output response snippets
  Fixes #1295. /api/crons/output now preserves the ## Response section
  when a large skill dump appears in the prompt section; falls back to
  file tail when no marker exists.

Tests: 3254 passed, 2 skipped, 3 xpassed.

Independently reviewed and approved by nesquena (commit b262e4d).
2026-04-29 21:06:30 -07:00
nesquena-hermes
20ac6dfe5c
release: v0.50.242 — revert assistant serif font + remove Calm theme (#1299)
Reverts the global assistant serif rule and removes the Calm theme that were shipped in v0.50.240 PR #1282. Pure deletion; 3252 tests passing. Override on independent review per Nathan.
2026-04-29 19:59:26 -07:00
nesquena-hermes
0ad95cb16a
release: v0.50.241 (#1293)
release: v0.50.241

Batch release of 4 PRs:

- #1290 (@nickgiulioni1) — Inline audio/video media editor with playback
  speed controls and HTTP byte-range streaming. PDF/media previews in
  workspace file browser. Composer tray inline players for audio/video.
  (Rebased from #1232.)

- #1287 (@renatomott) — Configured model badges (Primary / Fallback N) in
  the model picker, carried through to the composer chip. Persists through
  on-disk model cache.

- #1289 (@franksong2702) — Appearance autosave for theme/skin/font-size in
  Settings; inline Saving / Saved / Failed status. Font size now persists
  to config.yaml. Refs #1003.

- #1294 (@franksong2702) — Normalize agent session source metadata
  (raw_source / session_source / source_label) through /api/sessions and
  gateway watcher SSE snapshots. Existing source_tag / is_cli_session
  fields preserved. Refs #1013.

Tests: 3254 passed, 2 skipped, 3 xpassed (was 3199 before this release).

Independently reviewed and approved by nesquena (commit d1738f6).
2026-04-29 19:54:07 -07:00
nesquena-hermes
33a145a669
release: v0.50.240
## Release v0.50.240

Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).

---

### Added

- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269

### Fixed

- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273

---

### Test results

```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```

### PRs on hold (not included)

#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-29 17:42:32 -07:00
Hermes Agent
1cf406addb Merge remote-tracking branch 'pr/1246' into stage/batch-v0.50.238 2026-04-29 15:05:09 +00:00
Hermes Agent
26579ba141 Merge remote-tracking branch 'pr/1250' into stage/batch-v0.50.238 2026-04-29 14:29:05 +00:00
Hermes Agent
3feef25737 Merge remote-tracking branch 'pr/1244' into stage/batch-v0.50.238 2026-04-29 14:29:04 +00:00
happy5318
cc45175ee5 docs: add thread safety comment for SESSION_AGENT_CACHE
All LRU cache operations (get, set, move_to_end, popitem) are already
protected by SESSION_AGENT_CACHE_LOCK. This addresses the reviewer's
concern about thread safety in multi-threaded ASGI servers.
2026-04-29 20:08:12 +08:00
happy5318
65e5690772 fix: add LRU limit to SESSION_AGENT_CACHE to prevent memory bloat
The agent cache stores full AIAgent instances (each holding complete
conversation history) without size limit. Long-running servers with
many sessions can accumulate unbounded memory usage.

Changes:
- Replace dict with OrderedDict for LRU tracking
- Add SESSION_AGENT_CACHE_MAX = 50 limit
- Evict least-recently-used entries when cache exceeds limit
- Call move_to_end() on cache hits to maintain LRU order

This prevents memory exhaustion on servers with many active sessions.
2026-04-29 17:35:12 +08:00
Frank Song
b277e195fe Fix MiniMax China provider visibility 2026-04-29 15:50:32 +08:00
KingBoyAndGirl
be08842642 fix: trust custom provider base_url in SSRF validation
When using custom providers with private IPs (like AxonHub on internal
networks), the SSRF protection incorrectly blocks API calls to the user's
own configured endpoint.

This fix automatically adds the model.base_url hostname to the SSRF
trusted hosts list, since it's explicitly configured by the user.

Fixes issues where /api/models and /v1/* endpoints fail silently
when using custom providers with private IPs or IPv6 addresses.
2026-04-29 13:45:52 +08:00
Hermes Agent
867f2a3f81 absorb: address Opus review findings (security + correctness)
B1: fix stored XSS in MCP delete button — replace inline onclick with
    data-mcp-name attribute + event delegation (panels.js)
B2: fix zip/tar-slip via startswith prefix collision — use
    is_relative_to(); track actual extracted bytes instead of trusting
    member.file_size (upload.py)
B3: add NVIDIA NIM endpoint to _OPENAI_COMPAT_ENDPOINTS and
    _SUPPORTED_PROVIDER_SETUPS so provider is reachable (routes.py,
    onboarding.py)
H1: add terminalResizeHandle element to index.html and return it from
    _terminalEls() so resize-by-drag works (index.html, terminal.js)
H2: fix dead get_terminal() branch — return None for dead terminals
    instead of always returning term (terminal.py)
H3: replace os.environ.copy() with a safe allowlist in PTY shell env
    so API keys are not exposed inside the terminal (terminal.py)
H5: make model dedup deterministic — sort groups by provider_id
    alphabetically before first-occurrence assignment (config.py)
H7: add pid regex validation before OAuth probe; constrain key_source
    to a closed set of safe values (providers.py)
M8: add double-run guard for cron run-now — reject if job is already
    tracked as running (routes.py)
2026-04-29 05:06:34 +00:00
Frank Song
2487de2cc0 Harden model cache invalidation paths 2026-04-29 04:33:28 +00:00
Frank Song
eefa1bbad8 fix(models): preserve model cache metadata 2026-04-29 04:33:28 +00:00
fxd-jason
26f51b7190 fix: address review feedback — restore V3 as legacy, fix zai base_url
- Restore deepseek-chat-v3-0324 and deepseek-reasoner with '(legacy)' labels;
  these are deprecated 2026-07-24 but still live until then
- Fix zai (Z.AI/GLM) default_base_url: use /api/paas/v4 instead of /api/coding/paas/v4;
  the coding plan path is for the glmcode custom provider, not the general API
- Update test assertions to match
2026-04-29 04:31:16 +00:00
fxd-jason
568a913615 chore: remove deprecated DeepSeek V3/R1 models, keep only V4
- Remove deepseek-chat-v3-0324 (DeepSeek V3) and deepseek-reasoner (R1)
  from _MODEL_LIST, _PROVIDER_MODELS, static/index.html, and static/ui.js
- Keep only deepseek-v4-flash and deepseek-v4-pro
- These old model IDs are deprecated since 2026-07-24
2026-04-29 04:31:15 +00:00
fxd-jason
c707e6760b feat: add Z.AI/GLM provider UI, update DeepSeek defaults to V4
- Add zai (Z.AI / GLM / 智谱) to onboarding _SUPPORTED_PROVIDER_SETUPS
  with default model glm-5.1
- Add GLM models (glm-5.1, glm-5, glm-5-turbo, glm-4.x) to _MODEL_LIST
  for display in model dropdowns
- Update DeepSeek default_model from deepseek-chat-v3-0324 to deepseek-v4-flash
- Update DeepSeek default_base_url from /v1 to bare domain (API docs change)
2026-04-29 04:31:15 +00:00
fxd-jason
9df01c6167 feat: add DeepSeek V4 Flash and V4 Pro models
Add deepseek-v4-flash and deepseek-v4-pro model entries to:
- api/config.py (_MODEL_LIST and _PROVIDER_MODELS)
- static/index.html (model dropdown)
- static/ui.js (static label map)

These are the latest DeepSeek models with 1M context window,
replacing the legacy deepseek-chat/deepseek-reasoner (deprecated 2026-07-24).
2026-04-29 04:31:14 +00:00
bergeouss
c5e8372686 fix: address PR #1231 review feedback
- Use rsplit(':', 1) instead of split(':', 1) in resolve_model_provider()
  to handle provider_ids containing ':' (e.g. custom:my-key)
- Add note in _deduplicate_model_ids docstring about ordering instability
  across config changes (first occurrence wins is intentional)
- Add comment confirming N>2 provider dedup correctness
- Add tests for rsplit behavior with colon-containing provider_ids
- Mark test_sprint31 integration tests as xfail (pre-existing isolation
  issue)
2026-04-29 04:31:12 +00:00
bergeouss
5a563a45a4 docs: clarify dedup ordering semantics and provider_id safety (#1228)
Address reviewer questions:
- Document that first-occurrence ordering is not stable across
  config changes, but removing a provider causes re-dedup on next
  cache rebuild, so sessions still match the new bare entry
- Confirm @provider_id: format is consistent with existing
  _apply_provider_prefix() and resolved by resolve_model_provider()
  (splits on first ':')
2026-04-29 04:31:11 +00:00
bergeouss
a8101d98f7 fix(models): deduplicate model IDs across provider groups (#1228)
When multiple providers expose the same bare model ID (e.g. two custom
providers both listing gpt-5.4), the model picker cannot distinguish
them — both rows appear active and clicking the other provider's copy
is a no-op.

Fix:
- Add _deduplicate_model_ids() post-process in api/config.py that
  detects duplicate bare model IDs across groups and prefixes
  collisions with @provider_id: so each entry is globally unique
- Update norm() regex in static/ui.js to strip @provider: prefixes
  for fuzzy matching, so existing sessions with bare model IDs still
  restore correctly
- First occurrence stays bare for backward compatibility with sessions
  that already store the bare model name
- Update test_model_resolver to be dedup-aware

Closes #1228
2026-04-29 04:31:11 +00:00
JinYue-GitHub
24d65a1efa Fix nvidia provider support in WebUI
- Add nvidia to _PROVIDER_DISPLAY, _PROVIDER_MODELS, and _PROVIDER_ALIASES
- Add nvidia to _PORTAL_PROVIDERS to preserve full model paths (e.g. qwen/qwen3-next-80b-a3b-instruct)
- Add NVIDIA_API_KEY to _PROVIDER_ENV_VAR for API key management
- Fixes 404 errors when using nvidia provider with models from multiple namespaces
2026-04-29 04:30:55 +00:00
nesquena-hermes
3780df9428
fix: batch v0.50.232 — fuzzy match, codex detection, workspace reload, timestamp sync (#1198)
Batch release v0.50.232 — 4 fixes.

## PRs included

| PR | Author | Fix |
|---|---|---|
| #1192 | @nesquena-hermes | Model chip fuzzy-match false positive (#1188) |
| #1193 | @nesquena-hermes | openai-codex not detected in model picker (#1189) |
| #1196 | @nesquena-hermes | Workspace files blank after second empty-session reload |
| #1197 | @bergeouss | Session timestamps wrong with server/client clock drift (#1144) |

All four PRs independently reviewed and approved by @nesquena.

## Integration fixes applied

**#1193:** Updated misleading comment — `OPENAI_API_KEY` does NOT authenticate the default Codex OAuth endpoint (that uses `chatgpt.com/backend-api/codex` and requires a separate OAuth flow). The comment now accurately states the known limitation. Also replaced a fragile 400-char source-scan test with an isolation-safe unit test. Note: OAuth-authenticated users already get detected via `hermes_cli.auth` — this fix only addresses the env-var fallback path.

## Test results

**2764 passed, 2 skipped** (macOS-only workspace tests). Browser QA: **21/21**. `/api/sessions` confirmed returning `server_time` and `server_tz` fields.
2026-04-27 18:40:13 -07:00
nesquena-hermes
8b8ff3328a
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.

Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)

Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 13:34:59 -07:00
nesquena-hermes
fc0152b2fc
v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy (#1127)
* fix(#604): model picker shows all configured providers

Two fixes to ensure the model picker surface every provider a user has
configured:

1. Added env var detection for XAI_API_KEY (→ x-ai) and MISTRAL_API_KEY
   (→ mistralai). Previously these providers were only detectable via
   hermes auth or credential pool, not via environment variables.

2. Added config.yaml providers section scanning. Users who configure
   providers in config.yaml (e.g. providers.anthropic.api_key) without
   setting the corresponding env var will now see those providers in the
   model picker. Only providers with known model catalogs are added.

- Added 12 regression tests

* fix(#1112): allow Google Fonts in CSP style-src and font-src

Mermaid themes inject @import for fonts.googleapis.com at render time.
CSP style-src blocked these requests, causing console violations.

- Add https://fonts.googleapis.com to style-src (CSS stylesheets)
- Add https://fonts.gstatic.com to font-src (WOFF2/WOFF font files)
- Add 3 regression tests + verify existing CSP tests still pass

* fix(#1118): retry api() calls on network errors after long idle

After a long idle period, the browser's TCP keep-alive connection to the
server can become stale. The next fetch() throws a TypeError (network
failure), causing 'Failed to load session' instead of transparently
reconnecting.

- Added retry loop in api() (workspace.js): up to 3 attempts
- Only retries on TypeError (network failures), NOT on HTTP errors (4xx/5xx)
- 401 redirects still fire immediately
- Added 6 regression tests

* feat(#1116): composer placeholder reflects active profile name

When a named profile is active (not 'default'), the composer placeholder
and title bar show the profile name (capitalised) instead of the global
bot_name. Falls back to bot_name/'Hermes' for the default profile.

- boot.js: applyBotName() checks S.activeProfile before _botName
- panels.js: switchToProfile() calls applyBotName() after switch
- Added 5 regression tests

* feat(#1097): drag and drop workspace files into chat composer

Files and folders in the workspace file tree are now draggable.
Dropping them into the composer inserts @path reference at cursor
position. OS file drag-and-drop (attach files) still works.

- ui.js: _renderTreeItems sets draggable + dragstart with ws-path
- panels.js: drop handler checks for application/ws-path first,
  inserts @path with smart spacing and cursor positioning
- Added 9 regression tests

* fix(#1096): copy buttons work — add clipboard-write Permissions-Policy

Copy buttons on messages and code blocks were silently failing because
the Permissions-Policy header did not include clipboard-write=(self).
Firefox blocks navigator.clipboard.writeText() without explicit permission.

- api/helpers.py: add clipboard-write=(self) to Permissions-Policy
- ui.js: _copyText now catches clipboard API errors and falls back
  to execCommand('copy'). _fallbackCopy extracted as separate function
  with proper focus() call and visible-but-hidden positioning (not -9999px)
- Added 8 regression tests

* chore: CHANGELOG for v0.50.223

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 15:29:02 -07:00
nesquena-hermes
27b17a8fc8
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts

- Add _copyText() helper: tries navigator.clipboard first, falls back to
  document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
  navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests

* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge

- User message attachments with image extensions now render as <img> via
  api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests

* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)

* fix: improve mobile touch responsiveness for session list items

iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
  to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
  double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch

Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
  to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
  double-tap detection — works consistently on mouse and touch

* fix(#1106): iterate custom_providers[].models dict keys for dropdown population

- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests

* fix(#1105): allow custom_providers hostnames through SSRF check

- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)

* fix(tests): update hover padding assertions for #1110 touch fix (absorb)

* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)

* fix: filter right/middle-click from pointerup handler (absorb)

* docs: v0.50.221 release notes and version bump

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 10:36:59 -07:00
nesquena-hermes
58ad315dca
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly

MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.

The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.

* test(workspace): lock in MIME_MAP entry for .html/.htm

PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).

Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.

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

* fix(composer): raise .approval-card.visible z-index above .queue-card

.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.

Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.

* test(composer): pin approval-card z-index > queue-card invariant

PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.

Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.

Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).

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

* fix: intercept /steer /interrupt /queue before busy-mode routing in send()

Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."

Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.

Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
  intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
  _bc.fn(_pc.args) and returns, not queues

* test(busy-intercept): pin sync input-clear before await in slash intercept

PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."

Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.

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

* fix: update steer i18n and settings copy — steer no longer interrupts

With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.

Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
  cmd_steer:                  "falls back to interrupt" removed
  settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
  cmd_steer_fallback:         "interrupted" → "queued for next turn"
  busy_steer_fallback:        "interrupted instead" → "queued for next turn"
  settings_desc_busy_input_mode: "currently falls back to interrupt" removed

Also:
  static/index.html: inline fallback text updated to match
  static/commands.js: internal comment clarified (fallback = queue+cancel,
                      not "interrupt mode" which implies the primary action)

* 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

* fix(renderer): drop empty trailing line from blockquote match

The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.

Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.

Reproducer:
  '> Hello\n\nThe rest of the message.'
    → '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
                          ^^^ phantom <br>

Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).

Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
  - input ending with \n
  - quote followed by paragraph (the real-world case)
  - multi-line quote ending with \n
  - quote with blank continuation + trailing \n (internal <br> stays,
    trailing <br> does not)

Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.

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

* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes

Five additional fixes on top of the blockquote grouping from the initial commit:

1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
   line endings do not produce stray \r characters in rendered output

2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
   inside blockquotes/lists) and the outer pass (for plain paragraphs).
   Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.

3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
   via task-done/task-todo span wrappers. Checks [X] (uppercase) too.

4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
   own <blockquote> element rather than passing through as literal >.
   Implemented by extracting the blockquote rule into _applyBlockquotes()
   which calls itself recursively on the stripped inner content.

5. Lists inside blockquotes: > - item now renders <ul><li> inside the
   blockquote instead of a literal "- item" string. Task list items work
   inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).

Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.

68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.

* fix(renderer): restore space in blockquote prefix-strip regex

Commit 04e7b53 changed the blockquote prefix-strip regex from
  /^>[ \t]?/   (consume "> ", "\t>", or just ">")
to
  /^>[\t]?/    (only consume "\t>" or just ">")

The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?:  )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.

Reproducer (against the actual ui.js via node, before the fix):
  > Hello world         → <blockquote> Hello world</blockquote>
                                       ^ phantom leading space
  > Steps:              → <blockquote>Steps:
  > - one                  - one
  > - two                  - two</blockquote>
                          ^ literal text, NOT a <ul>; lists-in-quote feature broken
  > - [x] done          → blockquote with literal "[x] done", no checkbox span

Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.

Fix: restore the space character in the strip regex's character class.

Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).

Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.

Suite: 2354 passed, 0 new failures.

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

* Collapse agent session compression chains

* Restore upstream changelog entries

* fix(agent_sessions): bubble active compression chains to top by tip last_activity

The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.

Reproducer (with the harness against actual code, before the fix):
  - root: started 30 days ago, last msg 30 days ago
  - tip:  started 28 days ago (parent_session_id=root), last msg 5 seconds ago
  - standalone: last msg 2 days ago

  Sidebar order with original PR:
    [0] standalone  (48h ago)
    [1] active_tip  (last_activity=root's 720h ago)  ← wrong

  Sidebar order after fix:
    [0] active_tip  (last_activity=tip's 0h ago)     ← correct
    [1] standalone  (48h ago)

This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.

Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.

The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.

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

* docs: v0.50.216 release notes and version bump

Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.

* chore: gitignore local-only review harness directory

Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.

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

* Keep reasoning chip visible for None effort

* test(reasoning): pin chip render output via node, not just source regex

The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.

Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:

  TestChipAlwaysVisible
    - empty / null  -> "Default" label, inactive=true
    - "none"        -> "None" label, inactive=true
    - "low"/"high"  -> verbatim label, inactive=false
  TestNormalizationEdgeCases
    - "NONE"        -> normalises to "None"
    - "  none  "    -> trims and normalises
    - unknown junk  -> falls through visible, never hidden
  TestTitleAttributeAccessibility
    - title attribute carries the human-readable label for tooltip /
      screen-reader use

Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.

This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.

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

* docs: add #1074 to v0.50.216 changelog, bump test count to 2428

* fix(i18n): restore broken Unicode in Russian and Spanish steer strings

Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:

  Spanish (es):
    - cmd_steer:                   correcci00f3n  → corrección
    - cmd_steer_fallback:          2014 en cola   → — en cola
    - busy_steer_fallback:         2014 en cola   → — en cola
    - settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
    - settings_busy_input_mode_steer: correcci00f3n  → corrección

  Russian (ru):
    - settings_desc_busy_input_mode: the entire Cyrillic string was
      replaced with raw 4-hex-char code-points without the \u prefix
      (041e043f... instead of actual Cyrillic). Decoded:
      "Определяет поведение при отправке сообщения во время работы
      агента. Очередь ждёт; Прерывание отменяет и начинает заново;
      Steer внедряет коррекцию без прерывания."

Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).

All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.

Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).

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

* docs: remove duplicate compression chain entry from [Unreleased]

---------

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>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-25 21:06:31 -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
9d22ea7ff4
fix: move models disk cache from /dev/shm to STATE_DIR for per-instance isolation (#1064)
Using /dev/shm caused cross-instance cache pollution: any server started
on a different port (QA harness on 8789, test runs) would write its own
provider set to the shared file, and the production server on 8787 would
load it on next restart — showing only OpenRouter (or whatever the test
environment had configured) instead of the real provider list.

Moving the cache file to STATE_DIR / "models_cache.json" gives each
server instance its own isolated cache (each port uses a different
HERMES_WEBUI_STATE_DIR). Also fixes macOS/Windows portability where
/dev/shm does not exist.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 18:38:06 -07:00
nesquena-hermes
360463dd8e
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL

Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
  Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
  Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
  Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
  Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.

Result: /api/models returns in ~1ms on restart instead of ~30s.

* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick

Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches

* style: add user-select:none to session titles to prevent accidental text selection

* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard

Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.

* fix(models): add missing global declarations, move mtime check to outer scope for test

* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe

* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated

Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h.  The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.

Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.

* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase

The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:

  PR #1024 (session attention indicators):
    - _renderOneSession(s, isPinnedGroup=false) signature
    - body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
    - pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
    - last_message_at preference in _sessionTimestampMs
    - Right-slot attention indicator + hide-timestamp-when-attentive

  PR #1025 (session restore speed):
    - &resolve_model=0 on the loadSession metadata fetch
    - S.session._modelResolutionDeferred=true after assignment
    - _resolveSessionModelForDisplaySoon(sid) helper + invocation
    - &resolve_model=0 on the lazy full-message fetch

Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
  - _loadingSessionId global for stale-response discard
  - composer draft persistence on session switch (via S.composerDrafts)
  - _loadingSessionId !== sid bail-outs at every async await point
  - Cleanup _loadingSessionId = null at all exit paths

Test outcome:
  - tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
  - tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
  - tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
  - Full suite: 2233 passed, 0 failed

fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache

The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.

Two complementary fixes:
  1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
  2. invalidate_provider_models_cache(provider_id) pops just that
     provider's entry — surgical eviction for live key edits

Pinned by tests/test_credential_pool_providers.py — 23/23 passing.

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

* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception

1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
   on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
   _load_models_cache_from_disk() serves a stale prior-test result immediately after
   invalidation, breaking all test_credential_pool_providers and test_model_resolver
   tests that rely on get_available_models() returning fresh mocked data.

2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
   is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
   waiting threads from being stuck at wait_for() for the full 60s timeout.

3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".

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

* docs: v0.50.212 release notes and version bump

Model cache perf, session switch UX improvements, cache isolation fixes.

---------

Co-authored-by: Josh <josh@fyul.link>
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>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-25 18:24:30 -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
xingyue
91703e3e54
fix(config): add .venv discovery paths in _discover_python (#949) 2026-04-24 10:45:23 -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
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
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
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
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
nesquena-hermes
8f1f582caf
fix: BYOK/custom provider models missing from WebUI model dropdown (#815)
Closes #815.

Three root causes fixed:

1. Provider aliases (z.ai/x.ai/google/grok/claude/aws-bedrock/dashscope/~25 more) not
   normalized before _PROVIDER_MODELS lookup — provider fell to empty else-branch while
   TUI worked (it normalizes at startup). Fixed via _resolve_provider_alias() + inlined
   _PROVIDER_ALIASES table in api/config.py.

2. Silent ImportError in original normalization: 'from hermes_cli.models import
   _PROVIDER_ALIASES' inside try/except silently failed without hermes-agent on sys.path
   (CI, minimal installs). The inlined table fixes this — normalization now works
   regardless of whether hermes-agent is installed.

3. /api/models/live?provider=custom now falls back to custom_providers entries from
   config.yaml when provider_model_ids() returns empty.

Also: provider_id on every group in /api/models response for deterministic JS optgroup
matching (no substring false positives). 17 targeted tests, 1725/1725 full suite.
2026-04-21 17:24:54 -07:00
nesquena-hermes
811424a87b
feat(reasoning): full /reasoning CLI parity — show|hide + effort levels via config.yaml (#812)
Closes #461

Adds full /reasoning CLI parity to the WebUI slash command system:

- /reasoning show|on → window._showThinking = true; writes display.show_reasoning to config.yaml (same key as CLI); mirrors to settings.json for boot.js
- /reasoning hide|off → same in reverse; re-renders immediately
- /reasoning none|minimal|low|medium|high|xhigh → POST /api/reasoning → writes agent.reasoning_effort to config.yaml; takes effect next turn (matching CLI semantics)
- /reasoning (no args) → GET /api/reasoning → live status toast from config.yaml
- Autocomplete shows all 8 options: show|hide|none|minimal|low|medium|high|xhigh
- Profile-isolated: _get_config_path() is thread-local so per-profile settings never bleed across
- Boot hydration: window._showThinking initialised from settings.json show_thinking on page load
- Inspect.signature guard in streaming.py so older hermes-agent builds don't TypeError

28 new tests, 1708/1708 total passing. Full browser QA on port 8789 with isolated state. CLI/config.yaml sync verified with hermes_constants.parse_reasoning_effort().
2026-04-21 15:26:52 -07:00
Nathan Esquenazi
e91325db25 fix(config): invalidate model-list TTL cache on default-model change
set_hermes_default_model() calls reload_config() which resyncs _cfg_mtime,
so the mtime check inside get_available_models() never fires and the POST
response returns the stale cached default. Explicitly drop the TTL cache
after reload so the next read recomputes. Fixes the CI failure in
test_default_model_updates_hermes_config which the prior teardown-only
fix in this PR did not actually address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:32:33 -07:00
nesquena-hermes
b6d335feaa
perf: TTL cache for model list + incremental session index (#780)
Fixes AWS IMDS timeout on model dropdown. Incremental index writes.

Co-authored-by: starship-s <starship-s@users.noreply.github.com>
2026-04-21 00:33:03 +00:00
nesquena-hermes
76e602af25
feat: remove bubble_layout setting end-to-end (#777)
Removes the bubble_layout toggle from Settings, all persistence, CSS, i18n strings, and the UI docs demo. The CSS was already effectively dead. Users with a saved bubble_layout value in settings.json get a clean migration via _SETTINGS_LEGACY_DROP_KEYS.

Credit: @aronprins (PR #760 / #777)

Co-authored-by: aronprins <aronprins@users.noreply.github.com>
2026-04-20 22:34:45 +00:00
nesquena-hermes
63f9b719bb
fix(config): use Hermes config.yaml as single source of default model (#773)
Removes split-brain where WebUI Settings persisted default_model separately from Hermes runtime config.yaml. New POST /api/default-model endpoint writes to config.yaml. Existing saved values migrated on first load.

Fixes #761

Co-authored-by: aronprins <aronprins@users.noreply.github.com>
2026-04-20 22:12:01 +00:00
Frank Song
0dd5d6f21c
feat(ui): add sidebar density mode to session list (#764)
Adds compact/detailed toggle for the session list sidebar. Compact is the default (no behavior change for existing users). Detailed mode shows message count and model; profile names only appear when mixing sessions across profiles.

Fixes #673

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-20 19:43:40 +00:00
nesquena-hermes
69570ca77c
release: v0.50.102–v0.50.108 batch (code blocks, utf-8, image URLs, deletion warning, PermissionError, Docker docs, kimi-k2.5) (#755)
## Batch release: v0.50.102 – v0.50.108

Seven self-built PRs reviewed and approved by @nesquena, now consolidated into a single release branch.

### Included fixes

| Version | PR | What it fixes |
|---|---|---|
| v0.50.102 | #746 | Code blocks lose newlines when not preceded by blank line (fixes #745) |
| v0.50.103 | #743 | `encoding='utf-8'` on `write_text()` in `api/profiles.py` — Windows `.env` detection (fixes #741) |
| v0.50.104 | #735 | Agent `MEDIA:localhost:*` image URLs rewritten to `document.baseURI` — remote users get working images (fixes #642) |
| v0.50.105 | #736 | Profile deletion warning strengthened: "permanently deleted, cannot be undone" across all 6 locales (fixes #637) |
| v0.50.106 | #738 | Catch `PermissionError` in `_signing_key()` — three-container Docker UID mismatch no longer crashes all HTTP requests |
| v0.50.107 | #737 | Docs: three-container UID/GID alignment guide in README + `HERMES_UID`/`HERMES_GID` forwarded in compose (fixes #645) |
| v0.50.108 | #742 | Add `kimi-k2.5` to Kimi/Moonshot provider model list (fixes #740) |

### Testing
- **pytest**: 1510 passed, 1 warning (1 pre-existing unrelated failure excluded)
- **QA harness**: 20/20 passed (`~/WebUI/scripts/run-browser-tests.sh`)
- **Browser**: layout, slash autocomplete width, edit button, image URL rewrite, profile deletion dialog all verified

All PRs reviewed and approved by @nesquena. Ready to merge and tag **v0.50.108**.
2026-04-20 00:26:55 -07:00
woaijiadanoo
d7071cd424
fix: explicit UTF-8 encoding on all read_text() calls — v0.50.89 (PR #700 by @woaijiadanoo)
Fixes config loading failures on Windows with non-UTF-8 default locales (GBK, Shift_JIS etc). All Path.read_text() calls in api/config.py and api/profiles.py now specify encoding='utf-8'.
2026-04-19 04:22:28 +00:00
Frank Song
75e4f8b201 fix(model dropdown): stop injecting default into unrelated providers 2026-04-19 08:18:24 +08:00
nesquena-hermes
352354790f
fix: streaming scroll override, Gemini 3.x models, read-only workspace, two-container UID — v0.50.87 (closes #677 #669 #670 #668)
- #677: renderMessages() and appendThinking() use scrollIfPinned() during stream; scroll threshold 80→150px; floating ↓ scroll-to-bottom button added
- #669: Gemini 3.1 Pro Preview, 3 Flash Preview, 3.1 Flash Lite Preview added to all provider sections; gemini-3.1-flash-lite-preview was the missing ID causing API_KEY_INVALID; GEMINI_API_KEY env var detection added
- #670: docker_init.bash guards chown/write-test with [ -w ]; :ro workspace mounts no longer crash startup
- #668: UID/GID auto-detect probes /home/hermeswebui/.hermes and HERMES_HOME before /workspace; two-container Zeabur/Compose setups inherit correct UID automatically
- 18 new tests; 1441 total passing
2026-04-18 17:09:59 +00:00
nesquena-hermes
75e6595e06
feat: add MiniMax M2.7 to fallback model list and fix env var detection — PR #650 by @octo-patch
MiniMax M2.7/highspeed added to _FALLBACK_MODELS. MINIMAX_API_KEY and MINIMAX_CN_API_KEY added to env scan tuple so os.environ is checked. 11 tests. Independent review by @nesquena confirmed correct, needed rebase only.
2026-04-18 07:18:20 +00:00
nesquena-hermes
20a5f48a1f
fix(config): load provider models from config.yaml in model dropdown — PR #644 by @ccqqlo
Providers in config.yaml with explicit models: list were silently ignored. Fix extends the model-list builder to check cfg.providers[pid].models, covering both dict and list formats. Also includes providers only in config.yaml (not _PROVIDER_MODELS). 5 regression tests added. Independent review by @nesquena.
2026-04-18 07:14:03 +00:00
nesquena-hermes
ec48c482e2
fix(config): default model empty string — no unavailable OpenAI model for non-OpenAI users — closes #646 (PR #649)
DEFAULT_MODEL now defaults to "" instead of "openai/gpt-5.4-mini". Guards added in model-list builder so empty default does not create blank model entries. Adds 3 tests in test_issue646.py. Independent review by @nesquena.
2026-04-18 06:46:43 +00:00
Aron Prins
7cb5547056
feat(theme): replace color scheme system with light/dark + accent skins (PR #627 by @aronprins)
Independent review by @nesquena confirmed all blockers resolved. Theme×skin two-axis system replaces old monolithic color schemes. Closes #627. Co-Authored-By: aronprins <aronprins@users.noreply.github.com>
2026-04-18 06:37:09 +00:00
nesquena-hermes
79428f93c6
fix: catch OSError from SETTINGS_FILE.exists() — Docker UID-mismatch 500 crash (#614)
Squash-merges PR #614. Fixes Docker 500-on-every-request crash from PermissionError in load_settings() (issue #570 follow-up).

Both SETTINGS_FILE.exists() call sites now catch OSError and fall back to defaults. Reviewer nits addressed: removed unused imports/var in tests, improved log message to say "inaccessible?" instead of "permission denied?". Rebased clean onto v0.50.73. 1373 tests passing, QA harness green.
2026-04-16 20:16:07 -07:00
nesquena-hermes
2484409b7a
fix: HERMES_WEBUI_DEFAULT_WORKSPACE wins over settings.json; trust DEFAULT_WORKSPACE subtree (#610)
Squash-merges PR #610. Fixes Docker workspace env var override and trust validation (issue #609). 1367 tests passing, QA harness green. Reviewed by independent agent (see PR comments).
2026-04-16 18:09:16 -07:00
nesquena-hermes
6c5911a79f
fix: light theme dialogs, workspace panel snap, model cache staleness, docker-compose docs — v0.50.68
Fixes four bugs + locks in one existing fix with regression tests.

Closes #594 (light theme dialogs), #576 (workspace panel snap), #585 (stale model list after CLI change), #567 (docker-compose macOS UID docs). Confirms and tests #590 (transcribing spinner already present).

Reviewed and approved by @nesquena. 1340 tests passing.
2026-04-16 11:55:18 -07:00
nesquena-hermes
a512f2020e
feat: MCP toolsets in WebUI + onboarding fix for non-standard providers — v0.50.63
Squash-merges PR #578 (rebased from #574 by @renheqiang + #575 by @nesquena-hermes). MCP server toolsets now included in WebUI sessions; onboarding wizard no longer fires for non-standard providers. 1331 tests pass. Nathan override applied for self-built #575.
2026-04-15 23:39:07 -07:00
nesquena-hermes
360379136b
feat(upload): support Excel and Word file attachments — v0.50.61
Squash-merges PR #571 (rebased from contributor PR #566 by @renheqiang). Adds .xls/.xlsx/.doc/.docx to the file picker and MIME map. 1319 tests pass.
2026-04-15 22:43:31 -07:00
Hermes Agent
3e1ba1b783 fix(models): show named custom provider label in model dropdown instead of generic 'Custom'
When a custom_providers entry in config.yaml has a 'name' field (e.g. 'Agent37'),
the web UI model picker now uses that name as the group header instead of the
generic 'Custom' label.

Previously all custom_providers entries were bucketed under 'custom' which
rendered as 'Custom' in the dropdown optgroup — losing the named identity the
user set up during onboarding.

Changes:
- Track named custom providers as 'custom:<slug>' keys internally so multiple
  named providers can coexist as separate groups
- When building model groups, emit each named provider under its own display
  name (e.g. 'Agent37') rather than falling through to the generic label
- Unnamed entries (no 'name' field) still fall back to the 'Custom' group
- When all entries are named, the bare 'Custom' bucket is suppressed

Adds 7 tests covering single named provider, multiple named providers,
multiple models in same named provider, unnamed fallback, and mixed cases.

Fixes #557
2026-04-16 01:09:39 +00:00
Hermes Agent
9d4c075e2b fix: correct OpenRouter model slugs from live catalog verification
- google/gemini-3.1-pro -> google/gemini-3.1-pro-preview (not GA yet)
- google/gemini-3-flash -> google/gemini-3-flash-preview (not GA yet)
- x-ai/grok-4-20 -> x-ai/grok-4.20 (dot not dash in slug)
- Fix stale label: 'Gemini 2.5 Pro (via Nous)' -> 'Gemini 3.1 Pro Preview (via Nous)'
2026-04-15 23:00:29 +00:00
Hermes Agent
f5c4e110a4 chore: add Qwen3 Coder, Qwen3.6 Plus, Grok 4.20; drop Llama
- Remove llama-4-scout and llama-4-maverick
- Add qwen/qwen3-coder, qwen/qwen3.6-plus, x-ai/grok-4-20
- Add qwen and x-ai to _PROVIDER_MODELS and _PROVIDER_DISPLAY
2026-04-15 22:54:18 +00:00
Hermes Agent
4c142da3f6 chore: expand OpenRouter list per feedback — Claude 4.5 gen, Opus, R1, Maverick, Mistral
OpenRouter / _FALLBACK_MODELS (7 → 13 models):
- Add gpt-5.4 (full OpenAI alongside Mini)
- Restore claude-sonnet-4-5 (keep 4.5 generation alongside 4.6)
- Add claude-opus-4.6 (flagship)
- Add deepseek-r1 (popular reasoning model)
- Add llama-4-maverick (larger open-weight option)
- Add mistral-large-latest (Mistral via OpenRouter)

Structural:
- Add mistralai to _PROVIDER_MODELS for correct prefix-stripping routing
- Add mistralai to _PROVIDER_DISPLAY for correct group label
2026-04-15 22:27:55 +00:00
Hermes Agent
3b53b3f4f6 chore: update OpenRouter and provider model lists
OpenRouter / _FALLBACK_MODELS (8 → 7 models):
- Remove o4-mini (reasoning specialist, not a general-purpose pick)
- Remove claude-sonnet-4-5 (superseded by 4.6)
- Add gemini-3-flash as fast/cheap Google option
- Update gemini-2.5-pro → gemini-3.1-pro (current flagship)
- Better provider labels (Google, DeepSeek, Meta instead of 'Other')

Direct-API providers:
- openai: replace o4-mini with gpt-5.4 (general-purpose pairing with Mini)
- google / gemini: gemini-2.5-pro → 3.1-pro, gemini-2.0-flash → 3-flash
- Copilot, Nous, opencode-zen: same Gemini updates throughout

Test: update test_fallback_still_has_o4_mini → test_fallback_has_gpt54
2026-04-15 22:20:25 +00:00
Hermes Agent
dc2334c5a3 fix(review): use _PROVIDER_MODELS check instead of custom-only guard
The original fix preserved full IDs only when config_provider == 'custom',
which broke existing tests expecting prefix-stripping for known namespaces
like 'openai/' and 'google/'.

The correct heuristic: strip the prefix only when it is a known provider
namespace (i.e. prefix in _PROVIDER_MODELS — 'openai', 'google', 'anthropic',
etc.). Unknown prefixes like 'zai-org' are intrinsic to the model ID and must
be preserved. This satisfies both the DeepInfra use case (#548) and the
existing #433 regression tests.
2026-04-15 22:11:15 +00:00
eba8
bd55379886 fix: preserve slash model IDs for custom endpoints 2026-04-15 20:06:34 +00:00
Hermes Agent
7ea7331f26 fix: show custom_providers models regardless of active provider (#515 #519)
When a user has custom_providers configured in config.yaml, their custom
models should appear in the model picker even if active_provider is set
to a different provider (e.g. openrouter). Previously, the custom provider
was always discarded from detected_providers when active_provider != 'custom',
making custom models invisible.

Fix: only discard 'custom' if there are no custom_providers entries.

Co-authored-by: cloudyun888 <cloudyun888@users.noreply.github.com>
Co-authored-by: shruggr <shruggr@users.noreply.github.com>
2026-04-15 07:42:12 +00:00
Nathan Esquenazi
b3ad60d2c9 fix(routing): strip provider prefix from model ID when custom base_url is configured (fixes #433) 2026-04-14 19:06:35 +00:00
SaulgoodMan-C
8b857d9efc login-module-patch: sync to v0.50.36-local.1 2026-04-14 17:54:06 +00:00
nesquena-hermes
a2258139f2
fix: expand openai-codex model catalog to match DEFAULT_CODEX_MODELS (#407)
* fix: expand openai-codex model catalog to match agent DEFAULT_CODEX_MODELS

The _PROVIDER_MODELS["openai-codex"] catalog only listed codex-mini-latest,
so the model dropdown for profiles using openai-codex provider (e.g. CodePath)
showed only that one entry — even when the profile's saved default_model was
gpt-5.4 or another standard Codex model.

Updated to match DEFAULT_CODEX_MODELS from hermes_cli/codex_models.py:
- 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 (kept, relabeled as 'Codex Mini (latest)')

Also adds 2 regression tests: catalog includes gpt-5.4, display name correct.

* docs: v0.50.28 release — version badge and CHANGELOG

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:35:27 -07:00
nesquena-hermes
2beebaa6a2
feat: opt-in chat bubble layout (closes #336) (#403)
* feat(ui): opt-in chat bubble layout

Closes #336.

Adds a settings toggle that right-aligns user messages and left-aligns
assistant replies. Off by default - the current full-width layout is
friendlier to code blocks and tool output, so bubbles are strictly
opt-in per the maintainer note on the issue.

Wiring follows the existing token-usage / cli-sessions pattern:

- api/config.py: new bubble_layout bool in _SETTINGS_DEFAULTS and
  _SETTINGS_BOOL_KEYS, validated + persisted like the rest.
- static/style.css: .bubble-layout gated selectors using :has() to
  tag msg-rows by .msg-role.user / .msg-role.assistant without any JS
  changes to message creation. User rows get align-self: flex-end,
  max-width: 75%, and a row-reverse header; assistant rows flex-start.
  A 700px media query widens the max to 92% on narrow screens.
- static/index.html: new checkbox with i18n keys next to the existing
  token-usage toggle.
- static/panels.js: loads the setting into the checkbox, saves it
  back, and toggles body.bubble-layout immediately on save.
- static/boot.js: applies the class on initial load so refreshed
  tabs honor the persisted setting without a flash.
- static/i18n.js: English label + description.

Test suite errors are environmental (test server fails to start on
port 8788 on main as well).

* i18n(es): add Spanish translations for bubble_layout setting

* fix+test: boot.js bubble-layout reset on failure; add 22 tests for issue #336

* docs: v0.50.24 release — version badge and CHANGELOG

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 21:42:01 -07:00
nesquena-hermes
12a60faaee
fix: add OpenCode Zen and Go provider support (closes #362) (#392)
* Add OpenCode Zen and OpenCode Go provider support

The webui model dropdown had no knowledge of these providers.
When hermes_cli detected them as authenticated, they fell through
to the unknown-provider fallback showing wrong models.

Changes:
- Add opencode-zen and opencode-go to _PROVIDER_DISPLAY
- Add model lists for both to _PROVIDER_MODELS
- Add OPENCODE_ZEN_API_KEY and OPENCODE_GO_API_KEY to env-var fallback detection
- Fix custom:* provider IDs (e.g. custom:my-server) displaying raw ID instead of "Custom"

* Add tests for OpenCode provider registration and detection

---------

Co-authored-by: David Case <david.case@shruggr.cloud>
2026-04-13 18:46:11 -07:00
nesquena-hermes
7a80e73eb2
fix: silent agent errors, stale model list, live model fetching (#377)
* fix: silent errors, stale models, live model fetching (#373, #374, #375)

- api/streaming.py: detect empty agent response (_assistant_added check),
  emit apperror(type='no_response' or 'auth_mismatch') instead of silent done
- api/streaming.py: add _token_sent flag so guard works for streaming agents
- static/messages.js: done handler belt-and-suspenders guard for zero replies
- static/messages.js: apperror handler labels 'no_response' type distinctly

- api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and
  _PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini)

- api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models
  from provider API with B310 scheme check + SSRF guard
- static/ui.js: _fetchLiveModels() background fetch after static list loads,
  appends new models to dropdown, caches per session, skips unsupported providers

Other:
- tests/test_issues_373_374_375.py: 25 new structural tests
- tests/test_regressions.py: extend done-handler window 1500->2500 chars
- CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922)

* fix: SSRF hostname bypass + auth detection operator precedence

1. routes.py: SSRF guard used substring matching (any(k in hostname))
   which allows bypass via hostnames like evil-ollama.attacker.com.
   Changed to exact hostname matching against a fixed set of known
   local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1).

2. streaming.py: _is_auth detection had a Python operator precedence
   bug on the ternary expression. The line:
     'AuthenticationError' in type(...).__name__ if _last_err else False
   parsed as the ternary absorbing the rest of the or-chain when
   _last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...)

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

* docs: fix v0.50.20 CHANGELOG version number and test count (949 tests)

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:52:35 -07:00
nesquena-hermes
68426124c5
fix: recover from invalid default workspace paths (#366)
* fix: recover from bad default workspace paths

(cherry picked from commit 789d7537a325d1c7d3aa03c387918dddd2d0897d)

* fix: recover from invalid default workspace paths — 7 tests, CHANGELOG (#366)

- tests/test_default_workspace_fallback.py: 5 additional tests (dedup,
  RuntimeError, env var priority, mkdir on missing dir, unwritable path)
- CHANGELOG.md: v0.50.18 entry; 922 tests (up from 915)

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 14:28:24 -07:00
nesquena-hermes
dd17a0e9b7
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)

- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists

* fix: handle ConnectionResetError gracefully and add debug logging

- Add QuietHTTPServer class to suppress noisy connection reset errors
  caused by clients disconnecting abruptly (fixes log spam from
  'ConnectionResetError: [Errno 54] Connection reset by peer')

- Replace silent 'pass' statements with logger.debug() calls across
  api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
  and api/onboarding.py for better observability during troubleshooting

- All tests pass (25 passed in test_regressions.py)

* chore: add debug logging to profiles and routes modules

- Replace silent 'pass' statements with logger.debug() calls in
  api/profiles.py for better error visibility during profile switching
  and module patching

- Add logger initialization to api/routes.py

* security: fix B110 bare except/pass issues (bandit security scan)

- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully

* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)

- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)

---------

Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 11:11:56 -07:00
Nathan Esquenazi
39d42be396
fix: deduplicate model dropdown (hyphen vs dot) + README accuracy (#332)
Normalizes hyphens to dots in backend model-ID comparison so claude-sonnet-4-6 (hermes-agent format) matches claude-sonnet-4.6 (WebUI list) and no duplicate entry is injected. README line counts and test count corrected. 791 tests, all pass.
2026-04-12 14:45:39 -07:00
nesquena-hermes
31a721417e
feat(onboarding): add one-shot bootstrap and first-run setup wizard (#285)
Adds a bootstrap launcher and a blocking first-run onboarding wizard that guides
new users through minimum Hermes setup from the browser UI.

Supported provider flows: OpenRouter, Anthropic, OpenAI, custom OpenAI-compatible.
OAuth/terminal-first flows remain via 'hermes model'.

Security hardening applied during review:
- /api/onboarding/setup restricted to loopback when auth disabled
- Newline injection guard in _write_env_file
- esc() on setup.unsupported_note in onboarding.js
- Test isolation fix (send_key instead of bot_name in contamination test)
- Skip markers for PyYAML-dependent tests in agent-less environments

Tests: 693 passed (up from 679)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: gabogabucho <gabogabucho@gmail.com>
2026-04-12 00:11:41 -07:00
nesquena-hermes
b86ace6ce3
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)

When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.

Fixes #230

* test: mobile layout regression suite — 14 tests for every QA run (#254)

Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.

* feat: /skills slash command lists and filters available Hermes skills (#257)

Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.

Fixes #248

* feat: shared app dialogs replace native confirm()/prompt() calls (#251)

Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.

Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.

Extracted from PR #242.

* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)

Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.

Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.

Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.

Fixes #247 Fixes #246

* feat: session ⋯ action dropdown replaces per-row buttons (#252)

Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.

5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.

Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.

Extracted from PR #242.

* docs: v0.47.0 release notes, bump version, update test counts (645)

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 12:19:12 -07:00
nesquena-hermes
27c2fd6c08
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)

Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. &lt;code&gt; becoming &amp;lt;code&amp;gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.

Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.

Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.

Fixes #240

* fix: restore custom model list discovery with config api key (#238)

get_available_models() now reads api_key from config.yaml before env vars:
  1. model.api_key
  2. providers.<active>.api_key / providers.custom.api_key
  3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)

Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.

Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.

* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)

Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.

Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.

Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.

* security: redact credentials from API responses and fix credential file permissions (#243)

Adds response-layer credential redaction to three endpoints:
  - GET /api/session — messages[], tool_calls[], and title
  - GET /api/session/export — download also redacted
  - SSE done event — session payload in stream
  - GET /api/memory — MEMORY.md and USER.md content

Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.

Merged with #237 container detection changes in server.py.

* fix: cancel button now interrupts agent and cleans up UI state (#244)

Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.

Changes:
  - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
  - api/streaming.py: stores agent in AGENT_INSTANCES after creation,
    checks CANCEL_FLAGS immediately after store (race condition fix),
    calls agent.interrupt() in cancel_stream(), cleans up in finally block
  - static/boot.js: removes stale setStatus(cancelling) call
  - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel

Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.

New test file: tests/test_cancel_interrupt.py with 6 unit tests.

* docs: v0.46.0 release notes, bump version, update test counts

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 10:17:52 -07:00
nesquena-hermes
e68c1b92a4
fix: do not build phantom Custom group when active provider is set (#206)
* fix: do not build phantom "Custom" group when active provider is set

When model.provider is a real provider (e.g. openai-codex) and model.base_url
is configured, hermes_cli reports 'custom' as an authenticated provider. The
WebUI model picker was building a separate "Custom" group for it and parking
the configured default_model there instead of under the active provider's
group — diverging from the TUI which correctly shows the model under its
configured provider.

Two fixes in api/config.py get_available_models():

1. Discard 'custom' from detected_providers when active_provider is set and
   isn't 'custom' itself. The base_url belongs to the active provider.

2. Replace the substring-based default-model injection check with an exact
   match against _PROVIDER_DISPLAY. The old check `active_provider.lower() in
   g.get('provider', '').lower()` silently failed for hyphenated IDs like
   'openai-codex' vs display name 'OpenAI Codex' (hyphen vs. space),
   falling through to groups[0] and landing the model in the alphabetical
   first group instead.

Adds two regression tests in tests/test_model_resolver.py covering both
conditions.

* fix: do not build phantom Custom group when active provider is set

Two bugs in get_available_models():

1. Phantom Custom group: hermes_cli reports 'custom' as authenticated
whenever model.base_url is set. With provider=openai-codex + base_url,
detected_providers contained both 'openai-codex' and 'custom', producing
a duplicate group. Fixed by discarding 'custom' from detected_providers
when the active provider is any real named provider.

2. Hyphen/space mismatch in default_model injection: the substring check
'openai-codex' in 'openai codex' is False (hyphen vs space), causing the
default model to fall through to groups[0] (alphabetically first provider)
instead of the active provider group. Fixed by using _PROVIDER_DISPLAY
for exact display-name comparison.

Also fixes test helper _available_models_with_full_cfg to clear model env
vars during the call, preventing real hermes profile env from leaking into
the test assertions.

---------

Co-authored-by: mbac <marco.baciarello@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 18:33:24 -07:00
sean
fb19c7ea1f
fix: route slash-based custom provider models correctly (#189)
Co-authored-by: smurmann <smurmann@users.noreply.github.com>
2026-04-09 18:23:40 -07:00
Cyprian Kowalczyk
011034dc71
feat: optional HTTPS/TLS support via cert and key env vars (#199)
Add optional HTTPS support controlled by two env vars:
  HERMES_WEBUI_TLS_CERT=/path/to/cert.pem
  HERMES_WEBUI_TLS_KEY=/path/to/key.pem

- Wraps server socket with ssl.SSLContext (min TLSv1.2)
- Dynamic scheme detection for startup messages (http:// vs https://)
- Graceful fallback to HTTP if cert loading fails — server never crashes
  due to bad TLS config, just prints a warning and continues
- Auth cookie Secure flag already set when HTTPS is detected via getpeercert
- 6 end-to-end tests: config flags, HTTPS handshake, HTTP still works,
  fallback on bad paths

Addresses #191 (HTTPS support issue).
2026-04-09 18:08:29 -07:00
Nathan Esquenazi
b979b4c443 feat: pluggable i18n with English/Chinese language switcher in Settings
Introduces a locale bundle system that makes UI language switchable at
runtime and trivially extensible to any future language.

Architecture:
- static/i18n.js: LOCALES object with 'en' and 'zh' bundles, t(key)
  helper with English fallback, setLocale()/loadLocale() for persistence
  via localStorage. Adding a new language = adding one object.
- api/config.py: 'language' setting (default 'en'), BCP-47 validation
- api/routes.py: _LOGIN_LOCALE dict for server-rendered login page;
  template placeholders substituted at request time from saved setting
- static/index.html: loads i18n.js first (before other scripts); adds
  Language dropdown to Settings panel, auto-populated from LOCALES

Wiring:
- boot.js: applies server-persisted locale at startup (after /api/settings
  fetch); speech recognition lang follows _locale._speech
- panels.js: populates Language dropdown from LOCALES on settings open;
  saves + applies locale on Save Settings
- All JS files: hardcoded user-facing strings replaced with t() calls

Coverage:
- test_sprint20.py: relaxed recognition.lang assertion to accept dynamic
  locale-driven assignment (behavior unchanged for English default)
- 499/499 tests pass

Closes #177 (incorporates Chinese translations as a proper locale bundle
rather than hardcoded strings, so English default is fully preserved)
2026-04-08 18:57:50 -07:00
Nathan Esquenazi
5e899ee8fe feat: notification sound and browser notifications on task completion
Add two new settings (both default off):
- sound_enabled: plays a short tone via Web Audio API when assistant
  finishes a response or requests approval
- notifications_enabled: shows a browser notification when a response
  completes while the tab is in the background

Uses Web Audio API (oscillator) instead of bundled MP3 file — zero
additional assets. Follows the standard 4-file settings pattern.

Also skip test_valid_skill_accepted when hermes-agent not installed
(skills endpoint returns 500 without the agent module).

Inspired by #176 (DavidSchuchert)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:02:02 -07:00
nesquena-hermes
a064542df9
release: v0.39.0 — security hardening, 12 fixes (#171)
* Security: harden auth, CSRF, SSRF, XSS, and env race conditions

Twelve fixes from a full security audit:

CRITICAL
- Add CSRF Origin/Referer validation on all POST endpoints
  (prevents cross-origin abuse of self-update, settings, file ops)

HIGH
- Unify password hashing: config.py now uses PBKDF2 (600k iters)
  instead of single-iteration SHA-256
- Add per-IP rate limiting on login (5 attempts/60s, 429 on excess)

MEDIUM
- Validate session IDs as hex-only before filesystem operations
  (prevents path traversal via crafted session ID)
- SSRF: resolve DNS before private-IP check in model fetching
  (prevents DNS rebinding to internal services)
- Warn loudly when binding non-loopback without password set
- SSE env var mutations: wrap sync chat + streaming restore in _ENV_LOCK
- Force Content-Disposition:attachment for HTML/XHTML/SVG uploads
  (prevents stored XSS via uploaded files)

LOW
- Extend HMAC session signature from 64 to 128 bits
- Add resolve()+relative_to() check on skills path construction
- Set Secure flag on session cookie when connection is HTTPS
- Sanitize exception messages to strip filesystem paths

No breaking changes. All fixes are backward-compatible.

* fix: use getattr for Secure cookie SSL detection

handler.request.getpeercert raises AttributeError on plain sockets
(non-SSL). Use getattr(..., None) to safely check for SSL.

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

* tests: add sprint 29 security hardening coverage (PR #171)

33 tests covering all 12 security fixes:
- CSRF origin/referer validation
- Login rate limiting (5 attempts/60s)
- Session ID hex validation (path traversal prevention)
- Error path sanitization (_sanitize_error)
- Secure cookie getattr safety
- HMAC signature length (64->128 bit)
- Skills path traversal prevention
- Content-Disposition for HTML/SVG/XHTML
- PBKDF2 password hashing verification
- Non-loopback startup warning
- SSRF DNS guard code presence
- _ENV_LOCK export from streaming module

* release: v0.39.0 — security hardening, 12 fixes (#171)

---------

Co-authored-by: betamod <matthew.sloly@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:26:03 -07:00
nesquena-hermes
d6de7c8650
fix: custom endpoint URL, custom_providers in dropdown, .env key resolution (#157) (#160)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:39:19 -07:00
nesquena-hermes
5b4c5b0094
fix: exclude ambient gh-cli token from model dropdown provider detection (#158)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:35:30 -07:00
nesquena-hermes
107c446187
fix: model dropdown shows only hermes-configured providers (#155)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:29:06 -07:00
nesquena-hermes
481eefaf91
fix: model selector duplicate + stale model label (#147) (#151)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:15:24 -07:00
Nathan Esquenazi
442b0d872a
fix: multi-provider model routing via @provider: hint (#138) (#146)
The previous fix (#142) prefixed non-default provider models with
'provider/model' which then hit the cross-provider guard and routed
to OpenRouter — worse than before for users without an OpenRouter key.

New approach: non-default provider models use '@provider:model' format
(e.g. @minimax:MiniMax-M2.7). resolve_model_provider() parses this
hint and returns (bare_model, provider, None). streaming.py and
routes.py then pass the resolved provider to
resolve_runtime_provider(requested=provider) which gets the correct
per-provider API key and base_url from hermes-agent.

This uses the agent's own credential resolution instead of reinventing
routing logic in the webui.

Fixes #138

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:10:26 -07:00
Jeff Scott Ward
5f014b7c4a fix: correct Claude Haiku model ID from 3-5 to 4-5
The model ID `claude-haiku-3-5` does not exist on Anthropic's API and
returns HTTP 404. The correct model is `claude-haiku-4-5` (Claude Haiku 4.5).

Fixes both `_PROVIDER_MODELS` and `_FALLBACK_MODELS` lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:49:22 -04:00
Nathan Esquenazi
76cdfb69e0
fix: prefix non-default provider model IDs for correct routing (#142)
* fix: prefix non-default provider model IDs for correct routing

When multiple providers are configured, models from non-default providers
(e.g. MiniMax when Anthropic is default) were sent as bare names without
provider context. resolve_model_provider() couldn't determine the target
provider and routed them to the default provider's API, which failed.

Fix: get_available_models() now prefixes model IDs with the provider name
(e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config
provider. The default provider's models keep bare names for direct API
routing. This matches the existing pattern for OpenRouter models.

Added 2 tests to test_model_resolver.py for cross-provider routing.

Closes #138
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: model prefix — null-guard, case normalization, mutation safety, tests

Four fixes on top of original PR:
- active_provider=None guard: without a confirmed provider all models were
  being prefixed. Only prefix when active_provider is set.
- Case normalisation: compare pid against active_provider.lower() so
  config.yaml entries like 'Anthropic' match pid 'anthropic'.
- Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid];
  the default_model injector later calls list.insert() on that reference,
  permanently mutating the shared constant. Both branches now use a copy.
- Already-prefixed model IDs pass through as-is (no double-prefix).

Added 3 tests for get_available_models() prefix behaviour:
- Non-default provider models are prefixed
- Active provider's own entries remain bare
- No double-prefix when active_provider is absent

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:05:44 -07:00
TaraTheStar
e8a8fceb26 feat: make bot name configurable 2026-04-06 05:14:31 +00:00
Nathan Esquenazi
e829fa50d5
fix: OpenRouter models stripped of prefix, causing 404 (#116)
When config has provider=openrouter and model=openrouter/free,
resolve_model_provider() stripped the 'openrouter/' prefix because
prefix == config_provider. This sent 'free' to OpenRouter's API,
which returned 404 (model not found).

OpenRouter always needs the full provider/model path (e.g.
openrouter/free, anthropic/claude-sonnet-4.6). The prefix-stripping
logic is only correct for direct-API providers.

Fix: skip prefix stripping entirely when config_provider is 'openrouter'.
Return the full model_id with provider='openrouter'.

Added 7 unit tests for resolve_model_provider() covering:
- openrouter/free keeps full path (the bug)
- openrouter cross-provider models keep full path
- direct API providers still strip prefix correctly
- cross-provider routing to openrouter
- bare model names use config provider
- empty model returns defaults

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:58:37 -07:00
Nathan Esquenazi
8d1b7a1e01 feat: self-update checker with one-click update for WebUI + Agent
Shows a blue banner when the webui or hermes-agent git repos are behind
their upstream branches. One-click 'Update Now' button does stash, pull
--ff-only, stash pop, then reloads the page.

Backend (api/updates.py):
- _check_repo(): git fetch + rev-list count with 15s timeout
- check_for_updates(): 30-min server-side cache, thread-safe, skips
  Docker (no .git dir)
- apply_update(): stash (if dirty), pull --ff-only, pop, invalidate cache

Routes:
- GET /api/updates/check -- returns cached {webui, agent} with behind count
- POST /api/updates/apply -- {target: 'webui'|'agent'}

Frontend:
- Blue banner (matches reconnect-banner pattern) with 'Later' / 'Update Now'
- Non-blocking boot check via fire-and-forget .then(), once per tab session
- sessionStorage guards prevent re-checking and re-showing after dismiss

Settings:
- 'Check for updates' checkbox (default: on) -- when off, no git operations
- Removed 'Default Workspace' dropdown to keep settings panel compact

Performance:
- Server cache: git fetch at most 2x/hour regardless of client count
- sessionStorage: one check per browser tab session
- _check_in_progress flag prevents concurrent fetch storms
- Fire-and-forget: does NOT block the boot sequence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:11:44 -07:00
Nathan Esquenazi
b327103885
fix: model dropdown missing custom/configured models (#116, #117)
Two related bugs in get_available_models():

1. cfg_base_url undefined for string model configs (#117):
   cfg_base_url was defined inside 'elif isinstance(model_cfg, dict)'
   but referenced unconditionally at line 506. If model config was a
   plain string, NameError crashed model detection. Fix: initialize
   cfg_base_url='' before the conditional.

2. Configured default_model missing from dropdown (#116):
   The OpenRouter branch substituted _FALLBACK_MODELS without checking
   if the user's model.default was in the list. Models like
   'openrouter/free' or custom local models were invisible. Fix: after
   building all groups, check if default_model is present. If not,
   inject it at the top of the matching provider group.

Closes #116, closes #117

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:29:40 -07:00
nesquena-hermes
df9ad1fd27
fix: initialize cfg_base_url for custom providers
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 08:25:20 -07:00
Nguyễn Công Thuận Huy
4d333acbbc chore: add missing type hints across 10 files 2026-04-05 13:30:20 +07:00
Nathan Esquenazi
96137750a4 feat: Sprint 26 — pluggable UI themes (dark, light, solarized, monokai, nord)
Five built-in themes with instant switching, persistent preference,
and zero-flicker loading. Custom themes are pure CSS additions.

Theme system:
- CSS variable overrides via :root[data-theme="name"] blocks
- Flicker prevention: inline <script> reads localStorage before
  stylesheet parses, preventing dark-flash on light-mode users
- Server-side persistence via settings.json (theme field)
- Boot.js syncs server preference to DOM + localStorage

Built-in themes:
- Dark (default): deep navy/indigo, muted blue accents
- Light: clean white/gray, high contrast, scrollbar overrides
- Solarized Dark: teal background, warm accents
- Monokai: warm dark, green/pink accents
- Nord: arctic blue-gray, calm and minimal

UI integration:
- Settings panel: theme dropdown with instant live preview
- /theme slash command: /theme dark|light|solarized|monokai|nord
- No enum constraint on theme setting — custom themes just work

Documentation:
- THEMES.md: how to switch themes, create custom themes, contribute

8 new tests. All 408 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:48:05 -07:00
Nathan Esquenazi
bb595afde9
feat: opt-in state.db sync for /insights visibility (#92)
WebUI sessions were invisible to 'hermes /insights' because the WebUI
bypasses the gateway and calls AIAgent.run_conversation() directly,
never writing to state.db.

New 'Sync usage to /insights' setting (default: off) that mirrors
WebUI session metadata (tokens, cost, model, title) into state.db
after each turn. Uses absolute token counts to avoid double-counting.

Components:
- api/state_sync.py: bridge module with sync_session_start() and
  sync_session_usage(). Uses ensure_session() (idempotent) and
  update_token_counts(absolute=True). All wrapped in try/except.
- api/config.py: new 'sync_to_insights' boolean setting
- api/streaming.py: calls sync_session_usage() after s.save()
- api/routes.py: same for the non-streaming chat path
- Settings UI: checkbox toggle with description

Default off because:
- Writing to state.db while CLI/gateway also writes could cause
  WAL lock contention on busy systems
- Some users may not want WebUI sessions in /insights stats

Closes #92

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:07:05 -07:00
Nathan Esquenazi
6c54eda462
Merge pull request #76 from vCillusion/fix/agent-dir-pip-shadow
fix: resolve pip packages from site-packages instead of agent dir
2026-04-04 12:01:55 -07:00
nesquena-hermes
123207e0a6
fix: default STATE_DIR to ~/.hermes/webui instead of webui-mvp (#72)
The previous default pointed to 'webui-mvp' which is the internal
development repo name and meaningless to anyone deploying the public
repo. Changed to the generic '~/.hermes/webui' which is a sensible
default for any deployment.

The state dir remains fully overridable via HERMES_WEBUI_STATE_DIR
for anyone who wants to run multiple instances side by side.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 11:27:11 -07:00
Varun Chopra
d05e15e612 fix: resolve pip packages from site-packages instead of agent dir
When `pip install --target .` is run inside the hermes-agent checkout,
third-party package directories (openai/, pydantic/, requests/, etc.)
end up alongside real Hermes source files. With the agent dir at the
front of sys.path (insert(0)), Python resolves imports from those local
directories, breaking whenever the host platform differs from the
container (e.g. macOS .so files inside a Linux image).

Fix: append agent dir to sys.path instead of prepending. This lets
site-packages resolve pip packages correctly while still allowing
Hermes-specific modules (run_agent, hermes/, etc.) to resolve since
they do not exist in site-packages.

Also improves verify_hermes_imports() to surface the actual exception
message in startup logs, making it much easier to diagnose why a
module failed to import.
2026-04-04 23:29:33 +05:30
nesquena-hermes
66f95e08c2
feat: 'Show CLI sessions' toggle in Settings (#61)
Adds a server-side boolean setting (default: false) that controls whether
CLI sessions from state.db appear in the sidebar. Off by default so the
sidebar is clean until the user explicitly opts in.

- api/config.py: add show_cli_sessions to _SETTINGS_DEFAULTS and _SETTINGS_BOOL_KEYS
- api/routes.py: gate get_cli_sessions() call on the setting at request time
- static/index.html: checkbox in settings panel with description
- static/panels.js: load/save checkbox, refresh session list on save
- static/boot.js: load on startup alongside send_key and show_token_usage

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-03 21:06:23 -07:00
Nathan Esquenazi
2fb2ddeaaa feat: token usage toggle (setting + /usage command) + timestamp fixes
Token usage display:
- Add 'show_token_usage' boolean to settings (default: false, off by default)
- Settings panel: checkbox 'Show token usage after responses'
- /usage slash command: instant toggle with toast feedback, persists to
  server, updates checkbox if settings panel is open, re-renders messages
- Boot: load show_token_usage alongside send_key on startup
- ui.js: gate usage badge on window._showTokenUsage flag

Timestamps:
- streaming.py: stamp 'timestamp' on every message that lacks one at
  conversation completion; old messages (no timestamp field) now get a
  wall-clock time the first time they're touched by a new turn
- messages.js: stamp _ts on the last assistant message at done-event time
  so the time shows immediately on the current turn before next reload
- Timestamps already render in the UI (Sprint 14): faint time on each
  role header line, full opacity on hover, full date in title tooltip
2026-04-03 19:11:36 -07:00
Nathan Esquenazi
4eae6c98f9 fix: cross-provider model pick causes Connection lost on non-OpenRouter profiles
Root cause: resolve_model_provider() had a branch:
  if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
      return bare, prefix, None

When Camanji profile (config_provider='anthropic') picked openai/gpt-5.4-mini
from the OpenRouter dropdown, prefix='openai' matched _PROVIDER_MODELS and
config_provider was not 'openrouter', so it returned ('gpt-5.4-mini', 'openai', None).
The agent then demanded OPENAI_API_KEY directly -- not found -- RuntimeError --
stream crashed -- 'Connection lost'.

Fix: if prefix != config_provider (cross-provider selection), always route through
openrouter with the full provider/model string. Only strip the prefix and call a
direct provider API when the config_provider EXACTLY matches the model prefix.

Cases verified:
  openrouter + openai/gpt-5.4-mini     -> (openai/gpt-5.4-mini, openrouter)  ✓
  anthropic  + openai/gpt-5.4-mini     -> (openai/gpt-5.4-mini, openrouter)  ✓ FIXED
  anthropic  + anthropic/claude-...    -> (claude-..., anthropic)             ✓
  anthropic  + claude-sonnet-4-6 bare  -> (claude-sonnet-4-6, anthropic)      ✓
  openrouter + anthropic/claude-...    -> (anthropic/claude-..., openrouter)  ✓

Tests: 426 passed, 0 failed.
2026-04-03 20:23:25 +00:00
Nathan Esquenazi
d2b27f6f1e feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.

Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.

Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:50:21 -07:00
Nathan Esquenazi
e0a1ab8e03 fix(auth): blank password field no longer clears auth; add Disable Auth button
The previous logic treated a blank password field as intent to clear auth,
which meant saving any other setting (model, send key, etc.) would silently
disable password protection.

New behavior:
- Blank password field + Save Settings = no change to auth (do nothing)
- Password field with content + Save = set/change password (unchanged)
- 'Disable Auth' button = explicit confirmation-gated clear (new)

UI changes:
- index.html: updated description text to 'Leave blank to keep current
  setting'; added 'Disable Auth' button (amber, shown only when auth active)
- panels.js: saveSettings() skips password logic entirely when field is blank;
  loadSettingsPanel() shows/hides both btnDisableAuth and btnSignOut based on
  auth_enabled; new disableAuth() function sends _clear_password:true after
  confirm() prompt and hides both auth buttons on success

Server: no logic changes needed; _clear_password handling in save_settings()
is now only triggered by the explicit Disable Auth action.
2026-04-03 06:21:04 -07:00
Nathan Esquenazi
3c95502979 fix(auth): harden password_hash handling in settings API
Three security issues found during review:

1. password_hash exposed via GET /api/settings
   load_settings() returned all fields including the stored hash.
   Fix: strip password_hash from the response in routes.py.

2. password_hash directly settable via POST /api/settings
   'password_hash' was in _SETTINGS_ALLOWED_KEYS, so an attacker
   could POST {password_hash: 'X'} to hijack auth without knowing
   the current password.
   Fix: exclude password_hash from _SETTINGS_ALLOWED_KEYS.
   (Use _set_password for the legitimate hash-and-store path.)

3. Security headers missing from /api/auth/login and /api/auth/logout
   These endpoints built their responses manually (bypassing j()),
   so they omitted X-Content-Type-Options etc.
   Fix: call _security_headers() before end_headers() on both.

Tests updated: renamed test to assert key absent (not just None),
added new test verifying direct password_hash POST is blocked.
2026-04-03 06:21:04 -07:00
Nathan Esquenazi
b8b62722ec feat: Sprint 19 — password auth, security headers, login page
Auth system (off by default, zero friction for localhost):
- New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt),
  signed HMAC session cookies (24h TTL), auth middleware
- Enable via HERMES_WEBUI_PASSWORD env var or Settings panel
- Minimal dark-themed login page at /login (self-contained HTML)
- POST /api/auth/login, /api/auth/logout, GET /api/auth/status
- Settings panel: "Access Password" field + "Sign Out" button
- password_hash added to settings.json (null = auth disabled)

Security hardening:
- Security headers on all responses: X-Content-Type-Options: nosniff,
  X-Frame-Options: DENY, Referrer-Policy: same-origin
- POST body size limit: 20MB cap in read_body() to prevent DoS

Closes #23. 9 new tests. Total: 304 passed, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:53:26 -07:00
Nathan Esquenazi
0f2bd537f1 feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory

Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally

Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json

5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 04:13:38 -07:00
Nathan Esquenazi
6c7fb4ee44 fix: NameError on logger in custom endpoint model discovery
get_available_models() references 'logger' in the except block of
the custom endpoint fetch (added in PR #18), but 'logger' is never
imported or defined in api/config.py. When the custom endpoint is
unreachable (the normal case -- most users don't have a local LLM),
the except handler raises NameError: name 'logger' is not defined,
which propagates as a 500 on every GET /api/models request.

This broke 7 test_sprint11 tests and caused the model dropdown to
fail for all users regardless of whether they have a custom endpoint.

Fix: replace logger.debug() with a silent pass -- the exception is
expected when no local LLM is configured and needs no logging.

Tests: 237 passed, 0 failed.
2026-04-02 11:36:01 -07:00
Nathan Esquenazi
7ddd896b36 feat: add GLM-5.1 to Z.AI model list
Adds the newly available GLM-5.1 model to the hardcoded Z.AI provider
model list so it appears in the model dropdown. Fixes #17.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:02:10 -07:00
Nathan Esquenazi
b784fff104 refactor: keep only model discovery, drop redundant routing changes
- Revert routes.py and streaming.py to master: resolve_model_provider()
  already handles provider routing and base_url passthrough for all models.
- Fix indentation error in config.py (2-space indent on comment line).
- Fix auto_detected_models scope: initialize before try block.
- Remove unused urllib.parse import.
- Simplify unknown-provider model group logic.
- Remove verbose comments and redundant variable assignments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:15:41 -07:00
Dhaval
669412cbc9 refactor: switch from requests to urllib for custom endpoint model fetching 2026-04-02 09:14:21 -07:00
Dhaval
c1a9324f35 feat: add support for Ollama and LM Studio providers in model fetching 2026-04-02 09:14:21 -07:00
Dhaval
cbf82898cb feat: implement custom endpoint fetching for model lists and update streaming handler 2026-04-02 09:14:21 -07:00
Nathan Esquenazi
8075442200 fix: OpenRouter model routing regression + project input validation
- resolve_model_provider: fix regression where OpenRouter model IDs like
  openai/gpt-5.4-mini had their prefix stripped, causing AIAgent to look
  for OPENAI_API_KEY (direct API) instead of routing through OpenRouter.
  All chats returned Connection lost for OpenRouter users. Fix: only strip
  prefix and use direct-API when config.provider explicitly matches that
  provider; pass full provider/model string through for openrouter.
- Project name: cap at 128 chars, reject empty after strip on create/rename
- Project color: validate ^#[0-9a-fA-F]{3,8}$ to prevent CSS injection
  via dot.style.background in sessions.js
- Remove 2 redundant sys.path.insert() calls in cron handlers

Tests: 214 passed, 23 pre-existing failures, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 01:04:14 -07:00
Nathan Esquenazi
1a4793848e feat: Sprint 15 — session projects, code copy button, tool card toggle
Session projects: named groups for organizing sessions. Project filter
bar with chips between search and session list. Create/rename/delete
projects, assign sessions via folder icon dropdown. Stored in
projects.json, project_id on Session model. 5 new API endpoints.

Code block copy button: every code block gets a Copy button in the
language header (or top-right for plain blocks). Clipboard API with
"Copied!" feedback.

Tool card expand/collapse: messages with 2+ tool cards get an
"Expand all / Collapse all" toggle above the card group.

13 new tests (237 total), all passing. No regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:11:49 -07:00
Nathan Esquenazi
ceb091d6b1 fix: update MiniMax/Z.AI model lists, pass base_url to AIAgent
- Update _PROVIDER_MODELS['minimax'] from stale ABAB 6.5 models to
  current MiniMax-M2.7/M2.5/M2.1 lineup (matching hermes-agent upstream)
- Update _PROVIDER_MODELS['zai'] from GLM-4 to current GLM-5/4.7/4.5
  lineup (matching hermes-agent upstream)
- Extend resolve_model_provider() to also return base_url from config.yaml,
  so providers with custom endpoints (MiniMax, Z.AI) are routed correctly
- Pass base_url to AIAgent in both streaming and sync chat paths

Fixes #6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:15:04 -07:00
Nathan Esquenazi
241357595d refactor: extract resolve_model_provider helper, fix cross-provider routing
Replace duplicated inline provider resolution in routes.py and streaming.py
with a shared resolve_model_provider() helper in config.py.

Improvements over original:
- If model ID has a prefix matching any known direct-API provider
  (not just the config provider), strip it and route correctly.
  This handles edge cases like localStorage restoring a model from
  a different provider group.
- Single source of truth for the resolution logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:56:34 -07:00
Hermes
7019c25021 Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
  Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)

Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export

Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name

Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)

Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
Nathan Esquenazi
a4e2174c29 Hermes WebUI v0.1.0 — initial public release 2026-03-30 20:40:19 -07:00