Compare commits

...

672 Commits

Author SHA1 Message Date
nesquena-hermes
9f269a4f1c release: v0.50.239
Some checks failed
Release & Docker / release (push) Has been cancelled
h4-h6 heading fix. Approved by @nesquena. Tests: 3064 passed.
2026-04-29 09:07:03 -07:00
Hermes Agent
36eb6515f6 docs: v0.50.239 CHANGELOG 2026-04-29 15:56:06 +00:00
Hermes Agent
8e546c0273 Merge remote-tracking branch pr/1260 into stage/batch-v0.50.239 2026-04-29 15:55:51 +00:00
nesquena-hermes
9b6bce3a0d release: v0.50.238
Some checks failed
Release & Docker / release (push) Has been cancelled
Batch release — 12 PRs. Approved by @nesquena. Tests: 3061 passed.
2026-04-29 08:53:51 -07:00
Hermes Agent
af433de7a7 docs: add #1261 to v0.50.238 CHANGELOG 2026-04-29 15:52:56 +00:00
Hermes Agent
eeef360a74 Merge remote-tracking branch pr/1261 into stage/batch-v0.50.238 2026-04-29 15:51:54 +00:00
Hermes Agent
e538286d9a docs: add #1229 to v0.50.238 CHANGELOG 2026-04-29 15:19:01 +00:00
Hermes Agent
bd8fc6a2e2 fix(models): preserve @provider:model hint when hint matches active provider
When the user explicitly selects @provider:model from the picker,
_resolve_compatible_session_model() was stripping the prefix because
the hint matched the active provider (hint_matches_active=True → return bare_model, True).

This caused:
- The picker to snap back to the first duplicate entry on next render
- resolve_model_provider() to use the default provider instead of the
  explicitly selected one, running the agent on the wrong backend

The hint_matches_active branch was intended for normalizing stale cross-
provider session models. But an @provider:model where the hint IS the
active provider is not stale — it is the user's deliberate selection.

Fix: return (model, False) so the full @provider:model survives to
resolve_model_provider() in config.py, which already handles it correctly.

Updates test_active_at_provider_session_model_preserved_with_hint and
adds test_issue1253_duplicate_model_id_active_provider_hint_preserved.

Closes #1253
2026-04-29 15:18:43 +00:00
Hermes Agent
4ee80425f2 Merge remote-tracking branch 'refs/remotes/pr/1229' into stage/batch-v0.50.238 2026-04-29 15:17:57 +00:00
Hermes Agent
c75be8f564 docs: v0.50.238 CHANGELOG 2026-04-29 15:16:14 +00:00
Brian
f65f488635 fix(renderer): render h4-h6 markdown headings (####, #####, ######)
The post-stream renderMd() in static/ui.js only handled #, ##, ### — lines starting with #### through ###### fell through and emitted as literal text after streaming finalized.

  Extend the heading replacer chain to cover h4-h6, ordered longest-first, so ###### cannot be partially captured by the shorter ### rule. Add the matching .msg-body h4/h5/h6 CSS rules (and data-font-size variants) so the new tags inherit the same visual rhythm as h1-h3.

  Adds 3 node-driven tests in test_renderer_js_behaviour.py pinning all six heading levels and the longest-first replacer order.

Closes #1258
2026-04-29 23:15:59 +08:00
Hermes Agent
e0f77d6ab4 Merge remote-tracking branch pr/1242 into stage/batch-v0.50.238 2026-04-29 15:11:25 +00:00
Hermes Agent
e2ff00f819 Merge remote-tracking branch pr/1247 into stage/batch-v0.50.238 2026-04-29 15:11:21 +00:00
Hermes Agent
d5c0838fcd Merge remote-tracking branch pr/1249 into stage/batch-v0.50.238 2026-04-29 15:11:16 +00:00
Hermes Agent
8b9ad761f9 Merge remote-tracking branch pr/1251 into stage/batch-v0.50.238 2026-04-29 15:10:49 +00:00
Hermes Agent
2bb0af49f2 Merge remote-tracking branch pr/1254 into stage/batch-v0.50.238 2026-04-29 15:10:22 +00: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
ea4d381e43 Merge remote-tracking branch 'pr/1248' into stage/batch-v0.50.238 2026-04-29 14:29:05 +00:00
Hermes Agent
2bdf5c77d4 Merge remote-tracking branch 'pr/1245' into stage/batch-v0.50.238 2026-04-29 14:29:05 +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
bergeouss
3b614c4cd5 fix(i18n): translate MCP UI strings from Korean to English in en locale
The English locale (en) contained Korean translations for MCP server
management UI strings. This caused the Settings -> System -> MCP Servers
section to display in Korean when the user's browser language is English.

Fixed:
- tree_view: '트리' -> 'Tree'
- raw_view: '원본' -> 'Raw'
- mcp_servers_title: 'MCP 서버' -> 'MCP Servers'
- mcp_servers_desc: 'config.yaml의 MCP 서버를 관리합니다.' -> 'Manage MCP servers configured in config.yaml.'
- mcp_no_servers, mcp_add_server, mcp_field_name, mcp_transport_label,
  mcp_field_command, mcp_field_args, mcp_field_url, mcp_field_timeout,
  mcp_save, mcp_cancel, mcp_name_required, mcp_url_required,
  mcp_command_required, mcp_saved, mcp_save_failed,
  mcp_delete_confirm_title, mcp_delete_confirm_message, mcp_deleted,
  mcp_delete_failed, mcp_load_failed

Closes #1252
2026-04-29 10:50:26 +00:00
KingBoyAndGirl
4e0d8da060 fix: restore GET /api/mcp/servers route inside handle_get()
Problem:
- GET /api/mcp/servers returned 404 error
- MCP servers management UI could not load server list
- Root cause: route was placed outside handle_get(), in unreachable code

Root Cause:
- The MCP servers GET route was incorrectly placed after handle_get() returned False (404)
- handle_get() function returns False at line ~1224, so any code after it won't execute
- The route was also in handle_post() area but without proper method checking

Solution:
- Moved GET /api/mcp/servers route inside handle_get() before the return False statement
- Removed the misplaced route from the old location (originally around line 1636)
- Also updated /api/profiles response format to include full profiles list

Testing:
- After restart: curl http://localhost:8787/api/mcp/servers returns {"servers": []}
- No more 404 errors
- WebUI can now properly load MCP servers list
2026-04-29 17:39:56 +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
yzp12138
0fe59831fe tests: add regression tests + magic-byte image validation for native image attachments 2026-04-29 17:01:01 +08:00
Frank Song
9350af6fd7 Update reasoning metadata guards for context split 2026-04-29 16:46:32 +08:00
Frank Song
22cf29d477 Restore terminal resize and collapse controls 2026-04-29 16:45:26 +08:00
Frank Song
1ed1ce219d Preserve transcript across context compaction 2026-04-29 16:37:08 +08:00
KingBoyAndGirl
d184613752 fix: fetch live models for custom provider from model.base_url 2026-04-29 16:24:19 +08:00
Frank Song
b277e195fe Fix MiniMax China provider visibility 2026-04-29 15:50:32 +08:00
Dennis Soong
8a74ea89e7 fix: apply profile terminal env in webui sessions 2026-04-29 14:12:59 +08:00
Feco Linhares
1fe9b76a3a Add Portuguese (pt-BR) locale
- Added Brazilian Portuguese translation with 721 keys
- 100% key parity with en locale (reference)
- Follows project convention: _lang='pt', _speech='pt-BR'
- Clean insertion without modifying existing locales
- Syntax validated with node --check

AI Translation Disclosure:
Translated using NVIDIA NIM (qwen3.5-plus model) with human review by native Brazilian Portuguese speaker (Feco Linhares)
2026-04-29 06:06:01 +00:00
Feco Linhares
db358e362b Add Portuguese (pt-BR) locale
- Added Brazilian Portuguese translation with 721 keys
- 100% key parity with en locale (reference)
- Follows project convention: _lang='pt', _speech='pt-BR'
- Clean insertion without modifying existing locales
- Syntax validated with node --check

AI Translation Disclosure:
Translated using NVIDIA NIM (qwen3.5-plus model) with human review by native Brazilian Portuguese speaker (Feco Linhares)
2026-04-29 06:03:13 +00: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
72b4ff66f0 fix+feat: batch v0.50.237 — 21 PRs (embedded terminal, JSON/diff viewers, MCP UI, cron tracking, workspace CRUD, archive upload, DeepSeek V4, NVIDIA NIM, security fixes) (#1243)
Some checks failed
Release & Docker / release (push) Has been cancelled
2026-04-29 05:23:56 +00:00
Nathan Esquenazi
c86545b6a7 chore(repo): remove accidentally-committed graphify artifacts; ignore them going forward
Two artifacts from a contributor's local graphify (code-graph) tooling
slipped into PR #1233 (workspace drag-to-reorder):

  .graphify_cached.json    (3.5MB)
  .graphify_uncached.txt   (refs /home/fr33m1nd/hermes-webui-src/...)

Neither belongs in source control: the .json is an autogenerated cache
of node IDs for a graph visualisation tool, and the .txt is a
file-discovery index pointing at the contributor's local workspace
(/home/fr33m1nd/hermes-webui-src/) — paths that aren't valid for any
other developer.

The repo already ignores graphify-out/ but these two top-level dotfiles
weren't covered. Add explicit ignore entries and remove the tracked
copies.

No code change. CI remains green on 3.11/3.12/3.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:12:44 -07:00
Hermes Agent
bbd754a496 chore: CHANGELOG for v0.50.237 batch (21 PRs) 2026-04-29 05:08:28 +00: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
bergeouss
6a17e4cc0c fix(ui): add touch toggle support for context tooltip on mobile
Addresses reviewer feedback on #524 — the compress affordance was only
reachable via hover (desktop). Mobile users can now tap the context ring
button to toggle the tooltip and access the compress button.

- CSS: add .ctx-tooltip-active class with opacity + pointer-events
- JS: tap-to-toggle handler on ctxIndicator with outside-click dismiss
- aria-hidden toggled correctly for accessibility

Ref: #1223 review comment
2026-04-29 04:59:00 +00:00
Hermes Agent
74ecc58afa fix(test): extend renderMd window to 15000 chars (renderMd grew with diff+tree viewers) 2026-04-29 04:39:50 +00:00
Frank Song
2f0d036455 Add terminal locale coverage 2026-04-29 04:37:31 +00:00
Frank Song
eb9614854e Refine embedded terminal card entrypoint 2026-04-29 04:37:31 +00:00
Frank Song
940c82b2da Synchronize initial terminal open layout 2026-04-29 04:37:30 +00:00
Frank Song
70417359e3 Synchronize dock expand layout 2026-04-29 04:37:28 +00:00
Frank Song
8e67e4aa78 Add controlled terminal card resizing 2026-04-29 04:37:27 +00:00
Frank Song
4575cae9db Keep terminal card from covering transcript 2026-04-29 04:37:26 +00:00
Frank Song
38c0912da1 Add collapsible embedded terminal dock 2026-04-29 04:37:12 +00:00
Frank Song
d501daafe1 Fix collapsed terminal dock layering 2026-04-29 04:36:40 +00:00
Frank Song
7df55b9789 Smooth collapsed terminal expansion 2026-04-29 04:36:14 +00:00
Frank Song
10c4ea24f1 Disable dock expand slide jank 2026-04-29 04:35:43 +00:00
Frank Song
60a4cb057e Add embedded workspace terminal 2026-04-29 04:35:11 +00:00
bergeouss
9806a42a26 fix: protect secrets from masked-value round-trip overwrite (#1237)
- Add _strip_masked_values() to skip masked placeholders in PUT endpoint,
  preserving the original stored secret values instead of overwriting them
- Fix transport badge to gracefully handle unknown/future transport types
  with a fallback that shows the raw string
- Add TestStripMaskedValues (5 tests) for the round-trip protection logic
- Addresses reviewer feedback on secret masking semantics and transport badge
2026-04-29 04:34:55 +00:00
bergeouss
b2771ebf69 feat: MCP server management UI (#538)
- Add GET /api/mcp/servers (list with masked secrets)
- Add PUT /api/mcp/servers/<name> (add/update stdio and http servers)
- Add DELETE /api/mcp/servers/<name> (remove server)
- MCP section in System settings with server list, add/delete form
- Auto-detect transport type (stdio vs http) from server config
- Mask sensitive values (API keys, tokens, passwords) in list response
- Uses showConfirmDialog for delete confirmation (no native confirm)
- i18n: 21 keys across 7 locales
- 21 tests (list, save, delete, mask_secrets, validation)
2026-04-29 04:34:55 +00:00
bergeouss
29a31a6e26 fix(484): lazy-load js-yaml CDN for YAML tree view, add parse_failed_note i18n 2026-04-29 04:34:27 +00:00
bergeouss
49a2a424d5 feat: collapsible JSON/YAML tree viewer (#484)
- Fenced code blocks with json/yaml lang get Tree/Raw toggle
- Recursive DOM builder (_buildTreeDOM) with type-colored values
  (green strings, blue numbers, amber booleans, muted nulls)
- Auto-collapse at depth 2+, default tree for >=10 lines blocks
- YAML parsing via js-yaml (lazy, CDN-loaded)
- CSS: tree-view, tree-node, collapsible, type-colored classes
- i18n: tree_view, raw_view keys in all 7 locales
- 14 tests: renderer, types, collapse, CSS, i18n

Closes #484
2026-04-29 04:34:26 +00:00
Frank Song
6f37da38a6 Clarify model scope in composer and settings 2026-04-29 04:33:29 +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
bergeouss
1f602b47ec fix: add file size cap and error i18n keys for diff viewer (#1234)
- Add 512 KB cap for inline diff rendering to prevent DOM bloat on large patch files
- Add diff_error and diff_too_large i18n keys in all 7 locales for clear error messages
- Improve error state to show explanatory message instead of just filename
- Addresses reviewer feedback on file size cap and missing diff_error i18n key
2026-04-29 04:33:25 +00:00
bergeouss
9a371f06f5 feat: inline diff/patch viewer (#483)
- Fenced code blocks with diff/patch lang hint render with colored lines
  (green +lines, red -lines, italic @@ hunks)
- MEDIA:.patch/.diff files render inline instead of download link
  (async fetch via loadDiffInline() in post-render pipeline)
- CSS: diff-block, diff-line, diff-plus/minus/hunk classes
- i18n: diff_loading key in all 7 locales
- 12 tests: renderer, MEDIA inline, CSS classes, i18n parity

Closes #483
2026-04-29 04:33:25 +00:00
bergeouss
acbd0c14f2 fix: sanitize err.message in workspace reorder error toast (#1233)
- Remove raw err.message from error toast to prevent leaking internal error
  details to the UI (Path Trust Boundary Rule)
- Use i18n key workspace_reorder_failed for the sanitized message
- Addresses reviewer concern about optimistic vs confirmed reorder:
  the reorder is confirmed (API-first), not optimistic
2026-04-29 04:33:25 +00:00
bergeouss
103a9833d5 feat: workspace drag-to-reorder (#492)
- Add POST /api/workspaces/reorder endpoint to reorder workspace list
- Implement HTML5 drag-and-drop in workspace panel (panels.js)
- Add grip-vertical drag handle icon (icons.js)
- Add drag visual states: dragging, drag-over, cursor styles (style.css)
- Add i18n keys (workspace_drag_hint, workspace_reorder_failed) in all 7 locales
- 11 tests: 7 backend (order, strip, preserve, dedup, unknown, validation) + 4 frontend

Closes #492
2026-04-29 04:33:24 +00:00
bergeouss
63dee0a87c feat(#524): add compress affordance to context ring tooltip
When context usage reaches 50% (yellow), a subtle hint button appears
in the context ring tooltip suggesting /compress.  At 75%+ (red), the
hint intensifies with a warning style.

Clicking the button pre-fills /compress into the composer and focuses
it, so the user can add a focus topic or just hit send.  No auto-fire
— the user stays in control.

- static/ui.js: conditional visibility + click handler in _syncCtxIndicator
- static/index.html: ctxCompressBtn element inside ctxTooltip
- static/style.css: muted button style, red variant for ctx-high
- static/i18n.js: ctx_compress_hint / ctx_compress_action in all 7 locales

Closes #524
2026-04-29 04:33:09 +00:00
Andy
b0aed07fe0 fix: keep clarify countdown steady 2026-04-29 04:32:52 +00:00
Andy
47e91ee84b fix: handle clarify review edge cases 2026-04-29 04:32:52 +00:00
Andy
9fabd12e41 fix: preserve clarify drafts on timeout 2026-04-29 04:32:40 +00:00
bergeouss
4dbc9ac3e1 fix(i18n): add cron_status_running to de locale and fix es fallback 2026-04-29 04:32:00 +00:00
bergeouss
d734efa8af fix: stop cron watch when clearing cron panel detail view
_stopCronWatch() was only called when switching between cron job details
but not when the panel was cleared entirely (_clearCronDetail). This could
leave orphaned polling intervals if the user navigated away or the panel
was dismissed while a job was running.
2026-04-29 04:32:00 +00:00
bergeouss
98ed2d804b feat: cron run status tracking and watch mode (#526)
Backend:
- Track running cron jobs in thread-safe dict (job_id → start_time)
- Wrapper _run_cron_tracked() marks done on completion
- New GET /api/crons/status?job_id=... returns {running, elapsed}
- New GET /api/crons/status returns all running jobs

Frontend:
- After 'Run Now', enters watch mode with 3s polling
- Shows running indicator (spinner + elapsed timer) in detail card
- Auto-detects running jobs when opening detail view
- Stops watch and refreshes output on job completion
- Cleanup on detail view switch

Note: True SSE streaming is not possible because the hermes-agent
scheduler writes output files only on completion. This polling
approach provides real-time status feedback within that constraint.
2026-04-29 04:32:00 +00:00
bergeouss
f2f7224b8d fix: add zip-bomb protection and partial extraction cleanup
- Add cumulative extraction size limit (_MAX_EXTRACTED_BYTES = 200 MB)
  that tracks uncompressed file sizes during extraction to guard against
  zip/tar bombs (small compressed archives that expand to huge sizes).
- On any extraction failure (disk full, corrupted member, size limit),
  clean up the partially-extracted destination directory to avoid
  leaving orphaned folders in the workspace.
2026-04-29 04:31:59 +00:00
bergeouss
8c24b24dcd feat: upload and extract zip/tar archives into workspace (#525)
- Add extract_archive() with zip-slip and tar-slip protection
- New /api/upload/extract endpoint for archive uploads
- Auto-detect archive files (.zip, .tar.gz, .tgz, .bz2, .xz)
- Archives extracted into named subfolder (avoids overwrites)
- Workspace file tree auto-refreshes after extraction
- Archive extensions added to file picker accept list
- i18n: archive_extracted key in all 7 locales

Security: path traversal blocked via resolve() prefix check,
matching existing safe_resolve_ws() sandbox pattern.
2026-04-29 04:31:59 +00:00
bergeouss
d08d96f864 fix: deduplicate clone name + explicit enabled:false for duplicates
- Name dedup: 'Job (copy)', 'Job (copy 2)', 'Job (copy 3)' etc.
- Duplicates explicitly pass enabled:false to backend
- Normal cron create is unaffected (no enabled field sent)

Addresses reviewer feedback on #1225 (points 1 + 4).
2026-04-29 04:31:59 +00:00
bergeouss
8c63324ff7 feat: duplicate cron job with form pre-fill (#528)
- Add duplicate button in cron detail header
- Pre-fills create form with original job settings
- New job created as paused copy with '(copy)' suffix
- i18n keys in all 7 locales
2026-04-29 04:31:59 +00:00
bergeouss
9c57d36156 fix: update expanded dirs cache on double-click directory rename
The inline rename via double-click (nameEl.ondblclick) was not updating
the _expandedDirs and _dirCache when renaming a directory, unlike the
context-menu rename path (_inlineRenameFileItem) which already had this
logic. This could cause the tree view to show stale expand state after
a directory was renamed via double-click.
2026-04-29 04:31:58 +00:00
bergeouss
38df294af9 feat(#1104): workspace directory CRUD — delete, rename, context menu
The file tree already supported file rename (double-click), file delete
(button), and create file/folder.  This adds the missing directory
operations:

Backend:
- _handle_file_delete now supports directories when recursive=true
  (uses shutil.rmtree instead of blocking with an error)

Frontend:
- Right-click context menu on all file/directory items with Rename
  and Delete options (follows the project context menu pattern)
- Directory delete button (x) with confirmation dialog
- _inlineRenameFileItem() for renaming dirs via context menu prompt
- Expanded-dir cache is updated on rename/delete to stay consistent
- Context menu auto-positions within viewport bounds

i18n: delete_dir_confirm, rename_title, rename_prompt in all 7 locales

Closes #1104
2026-04-29 04:31:58 +00:00
starship-s
03b7714f65 docs: note Lucide source for composer icons 2026-04-29 04:31:56 +00:00
starship-s
62650e6a0d fix: add missing commas after approval_skip_all_title in all locales 2026-04-29 04:31:56 +00:00
starship-s
9d5480565f fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- Drop btnCancel element and all JS show/hide call sites across
  boot.js, messages.js, sessions.js, ui.js (superseded by single
  primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
  fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
  7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
  running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
  assert btnSend present and id="btnCancel" absent; replace
  test_hides_cancel_button with test_clears_composer_status
2026-04-29 04:31:55 +00:00
starship-s
b57134bf2b ui: reflect explicit busy slash command in send button 2026-04-29 04:31:55 +00:00
starship-s
be291498cf ui: swap composer action icons to Lucide (ISC-licensed)
- queue: list-end (append to queue)
- interrupt: skip-forward (jump ahead)
- steer: compass (course correction)
2026-04-29 04:31:54 +00:00
starship-s
8eb3d8bdbc chore: strip remaining btnCancel inline-flex references (superseded by single-button model) 2026-04-29 04:31:54 +00:00
starship-s
96182e5f51 fix: keep busy-input send available on mobile 2026-04-29 04:31:54 +00:00
starship-s
59abbd1300 fix: retry stale repair after lock contention 2026-04-29 04:31:37 +00:00
starship-s
93e7ba5a6b test: stabilize session time bucket boundary 2026-04-29 04:31:36 +00:00
starship-s
014f16c359 fix: harden session sidecar repair 2026-04-29 04:31:36 +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
544d5222a1 test: add unit tests for custom_providers scanning and DeepSeek V4 models
- Test custom_providers entries (glmcode, deepseek) appear in get_providers()
- Test env var reference detection (${VAR_NAME} pattern)
- Test bare API key, missing key, empty/malformed entries
- Assert DeepSeek V4 models present, V3 deprecated models removed
- Assert GLM model series in _PROVIDER_MODELS and onboarding setup
2026-04-29 04:31:15 +00:00
fxd-jason
25958139da feat: show model names in provider cards + scan custom_providers
Provider card improvements:
- Show model name tags when a provider card is expanded (panels.js)
- Add .provider-card-model-tag styling (style.css)

Custom providers in providers panel:
- Scan config.yaml custom_providers (e.g. glmcode, timicc) and list
  them as providers with their configured models (api/providers.py)
- Detect API key status from env var references (${ENV_VAR})
2026-04-29 04:31:15 +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
Frank Song
f384368ee2 fix(sessions): preserve unread dots after compression 2026-04-29 04:31:14 +00:00
Frank Song
248cfd1248 Track cache-rendered streaming sessions for unread dots 2026-04-29 04:31:14 +00:00
Frank Song
6b04ae0254 Fix unread markers for local inflight completions 2026-04-29 04:31:13 +00:00
Frank Song
b488a0d1b0 Fix unread markers for background completions 2026-04-29 04:31:13 +00:00
Frank Song
5d16ff7522 Fix background completion unread markers 2026-04-29 04:31:13 +00:00
starship-s
8bfd8b28d5 fix: stuck sidecar recovery 2026-04-29 04:31:12 +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
bergeouss
0741a2ab9f fix: skip get_auth_status() fallback for known API-key providers
Avoids unnecessary latency on the Settings page by restricting the
OAuth auth-status fallback to providers that are not in _PROVIDER_ENV_VAR.

Review feedback (PR #1221): the get_auth_status() call in the else branch
was firing for every unconfigured API-key provider (openai, anthropic, etc.),
adding a network round-trip per provider. Now it only runs for providers
that are not known API-key providers (custom/OAuth-capable providers).
2026-04-29 04:31:11 +00:00
bergeouss
ae2ed1a4e7 Fix #1214: refresh workspace on profile switch when session is empty
Add loadDir('.') call in switchToProfile() Case B so the workspace file
tree panel reflects the new profile's workspace instead of showing stale
files from the previous profile.

Fix #1212: detect OAuth providers not in hardcoded set

Expand _OAUTH_PROVIDERS with copilot-acp and qwen-oauth.
Add fallback in get_providers() that checks hermes auth live status
for providers that have no API key and are not in the hardcoded set
(e.g. Anthropic connected via OAuth), so the Providers tab shows
them as configured.
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
fxd-jason
f7f8fc6496 fix: _loadOlderMessages scrolls to bottom instead of preserving position
When _loadOlderMessages prepends older messages, the viewport snaps
to the bottom instead of staying where the user was.

Two bugs compounding:
1. Wrong scrollable container. Code used `$("msgInner")` for scrollHeight
   and scrollTop, but #msgInner has no overflow-y — it is a flex column.
   The actual scrollable container is #messages (`.messages{overflow-y:auto}`).
   Setting msgInner.scrollTop was silently ignored.
2. renderMessages calls scrollToBottom at the end (ui.js:2552),
   which unconditionally scrolls #messages to the bottom and sets
   _scrollPinned=true. Since bug #1 made the scroll-restore a no-op,
   the page landed at the bottom every time.

Fix:
- Changed scroll restore target from `$("msgInner")` to `$("messages")`.
- Reset _scrollPinned = false after restoring the user position,
  so scrollToBottom does not re-fire on next tick.
2026-04-29 04:30:55 +00:00
yzp12138
f35d7786e5 fix: send image uploads as native multimodal inputs 2026-04-28 23:18:51 +08:00
bergeouss
0a0513c9d3 feat(#524): add compress affordance to context ring tooltip
When context usage reaches 50% (yellow), a subtle hint button appears
in the context ring tooltip suggesting /compress.  At 75%+ (red), the
hint intensifies with a warning style.

Clicking the button pre-fills /compress into the composer and focuses
it, so the user can add a focus topic or just hit send.  No auto-fire
— the user stays in control.

- static/ui.js: conditional visibility + click handler in _syncCtxIndicator
- static/index.html: ctxCompressBtn element inside ctxTooltip
- static/style.css: muted button style, red variant for ctx-high
- static/i18n.js: ctx_compress_hint / ctx_compress_action in all 7 locales

Closes #524
2026-04-28 10:47:12 +00:00
nesquena-hermes
24b1e6f3fc fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)

Merges PRs #1208, #1209, #1210 (#1152 rebased):

- fix(providers): OAuth provider cards show correct Configured status in Settings.
  get_providers() was discarding has_key=True from _provider_has_key() for OAuth
  providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
  from the Settings panel. Surfaces auth_error string. (closes #1202)

- ux(profiles): profile chip shows spinner and new name immediately on switch.
  Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
  populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.

- feat: YOLO mode toggle — skip all approvals per session.
  /yolo slash command, "Skip all this session" button on approval cards,
  amber  pill indicator in composer footer. Session-scoped, in-memory.
  Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
  Original author: @bergeouss (PR #1152)

Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-27 22:56:12 -07:00
nesquena-hermes
7189416969 fix: batch v0.50.234-235 — XSS hardening, workspace validation, profile switch fixes (#1206)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: batch v0.50.234-235 — XSS hardening, workspace validation, profile switch fixes

v0.50.235 (#1203 — profile switch workspace/model/chip, 3 bugs + flaky test):
- switch_profile now reads target profile's workspace directly (thread-local bypass)
- invalidate_models_cache() after profile switch (model dropdown staleness)
- syncTopbar() updates chip before early-return (no-session path)

v0.50.234 (#1201/#1205 — XSS hardening + workspace security):
- renderMd() full HTML attribute sanitizer replacing tag-name-only allowlist
- Delegated image lightbox (removes all inline onclick)
- macOS /etc → /private/etc symlink bypass fixed
- /System /Library added to blocked workspace roots
- Legacy /api/chat workspace trust gap closed

Both PRs independently reviewed. 2787/2787 tests. QA harness 20/20 + 11/11 API checks.

Co-authored-by: Brendan Schmid <bschmidy10@Wilson.bschmidy10>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-27 21:39:30 -07:00
nesquena-hermes
1f07d3d0fc fix(workspace): Allow /var/home workspaces (#1199)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(workspace): Allow /var/home workspaces (#1199)

Carries code from @frap129's PR #1199. On systemd-homed (Fedora/RHEL),
home lives under /var/home/<user> — blocked by _is_blocked_system_path
because /var is in the blocked roots list. Fix: trust any path under
Path.home() as long as home != /. Also adds symmetric early-return
in validate_workspace_to_add.

2764 tests pass.

Co-authored-by: Joe Maples <joe@maples.dev>
2026-04-27 19:33:41 -07:00
nesquena-hermes
3780df9428 fix: batch v0.50.232 — fuzzy match, codex detection, workspace reload, timestamp sync (#1198)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
e61a405add fix: batch v0.50.231 — macOS symlink bypass, workspace panel, fenced code leak (#1194)
Some checks failed
Release & Docker / release (push) Has been cancelled
Batch release v0.50.231 — 3 fixes.

## PRs included

| PR | Author | Fix |
|---|---|---|
| #1186 | @nesquena (Claude Code) | macOS `/etc` symlink bypass in workspace blocked-roots |
| #1187 | @nesquena-hermes | Workspace panel stuck closed after empty-session reload |
| #1190 | @bergeouss | Fenced code content leaking into markdown passes (#1154) |

All three PRs were independently reviewed and approved by @nesquena.

## Test results

**2729 passed, 2 skipped** (2 macOS-only tests correctly skipped on Linux). Browser QA: **21/21**.

## Key fix notes

**#1186:** `_workspace_blocked_roots()` now returns both literal and `Path.resolve()` forms of each blocked root. macOS symlinks (`/etc → /private/etc`) previously let a resolved candidate slip past the literal check. New `_is_blocked_system_path()` helper with `/var/folders` and `/var/tmp` carve-outs for pytest temp dirs.

**#1187:** Regression from #1182 — `syncWorkspacePanelState()` force-closed on any no-session state. Now only closes in `'preview'` mode. Both boot paths restore localStorage panel pref before sync.

**#1190:** Fenced code blocks are now stashed as `\x00P<n>\x00` tokens through ALL markdown passes (list/heading/table regexes), restored at the very end. Previously, diff hunks and markdown headings inside code blocks triggered those regexes, injecting `<ul>/<li>/<h>` tags that broke `</pre>` closure.
2026-04-27 17:43:36 -07:00
nesquena-hermes
b24b0335f7 fix(models): defer first save() until session has real state (v0.50.230) (#1185)
Some checks failed
Release & Docker / release (push) Has been cancelled
Merged as v0.50.230. 2685 tests passing. Browser QA 21/21.

Closes the orphan-files leg of #1171. `new_session()` no longer writes an empty session to disk — the first disk write is deferred until the session has real state. Verified live: `POST /api/session/new` creates no `.json` file; session is findable by GET from in-memory SESSIONS dict.

Attribution: original PR #1184 by @nesquena (Claude Code).
2026-04-27 16:44:07 -07:00
nesquena-hermes
a091be6a8e fix: batch v0.50.229 — session perf, ephemeral sessions, iOS zoom (#1183)
Some checks failed
Release & Docker / release (push) Has been cancelled
Merged as v0.50.229. 2678 tests passing. Browser QA 21/21.

All three PRs were independently reviewed and approved by @nesquena with reviewer commits pulled in:
- #1181 (#1158): `d974388` (stale-response race in _loadOlderMessages)
- #1182: `7e20006` (full-scan fallback path consistency)
- #1180: `a5ad154` (regression test for iOS zoom threshold)

Thanks @jasonjcwu (#1158)!
2026-04-27 16:27:03 -07:00
nesquena-hermes
ef26d19549 fix: batch v0.50.228 — renderer, model race, tool card, empty session, .env (#1179)
Some checks failed
Release & Docker / release (push) Has been cancelled
Merged as v0.50.228. 2644 tests passing. Browser QA 21/21 (desktop 1440×900 + mobile iPhone 14). All 5 fix invariants verified live in browser.

**Fix verifications:**
- #1172 (`renderMd` pre-stash): `rawPreStash` present in function, `<pre>` blocks pass through without content rewrite 
- #1174 (model race guard): `syncTopbar()` contains `liveStillPending` guard 
- #1175 (tool card): `.tool-card-result pre` max-height=360px, `.tool-card.open .tool-card-detail` overflow=auto, cap=600px   
- #1176 (empty session guard): double-click New Conversation on empty session → stays on same session, composer focused 
- #1178 (`.env` atomic write): `tempfile.mkstemp + os.replace` in `providers.py`, 9/9 env tests pass 

Thanks @bsgdigital (#1150) and @bergeouss (#1178)!
2026-04-27 15:28:19 -07:00
nesquena-hermes
8b8ff3328a fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
dca8624454 fix(ui): restore rail-era app titlebar state (v0.50.226) (#1163)
Some checks failed
Release & Docker / release (push) Has been cancelled
Merged as v0.50.226.

Integration branch absorbed @aronprins's original PR #1141 with one reviewer fix from @nesquena (`1d11646`: queue hide tooltip updated to reference the queue pill, not the removed titlebar badge).

**Full gate results:**
- 2595 tests passing 
- Browser QA 21/21 (desktop 1440×900 + mobile iPhone 14) 
- Independent review: APPROVED by @nesquena 

Thank you @aronprins for the clean PR — the titlebar is properly restored.
2026-04-27 11:43:32 -07:00
nesquena-hermes
5192ca5de5 v0.50.225: cron attention, image lightbox, pytest isolation (#1137)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat: attention state for broken cron jobs + Korean i18n (#1133, @franksong2702)

* fix: pytest state isolation for direct session saves (#1136, @franksong2702)

* fix(#1095): image thumbnails in composer + lightbox in chat (#1135)

* fix(css): restore cron attention + detail-alert rules overwritten by style.css merge (absorb)

* docs: v0.50.225 release notes and version bump

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 21:04:38 -07:00
nesquena-hermes
69bf2878bc v0.50.224: legacy @provider session models, Docker Hindsight dependency (#1131)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Fix legacy at-provider session models

* Fix Hindsight dependency in Docker WebUI venv

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 18:47:38 -07:00
nesquena-hermes
fc0152b2fc v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy (#1127)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
4528c6c848 v0.50.222: Korean locale, provider fixes, reasoning chip boot, Prism SRI (#1119)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat: add Korean locale support (#1093, @jundev0001) — 615 keys, copy_failed added

* fix(#1094): provider deletion + false positive API key + threading deadlock (#1102, @bergeouss)

* fix(#1103): show reasoning chip on page load not only after session load (#1114, @bergeouss)

* fix(#1100): remove Prism CSS SRI integrity to fix intermittent blocking (#1115, @bergeouss)

* fix(tests): update copy_failed locale count for 7 locales (Korean added)

* fix: drop unused _cfg_cache import; update locale count comment

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 14:24:20 -07:00
nesquena-hermes
27b17a8fc8 v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
d67036db24 v0.50.220: workspace panel collapse + project color dot fix (#1090)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(ui): workspace panel collapse priority + visible project color dot

Two related sidebar UI bugs from project-ui-bugs.md:

1. Workspace panel header had no collapse priority. As the right panel
   narrowed, all three header children (Workspace label, git badge,
   icon buttons) compressed at the same rate because `.panel-header`
   used `justify-content:space-between` with no flex-shrink ratios.
   The icon buttons -- the actual primary controls -- could disappear
   before the git badge (which is least-essential metadata).

   Fix: declare `.rightpanel` as a `container-type:inline-size` container.
   Replace `justify-content:space-between` with `gap:6px` plus
   `margin-left:auto` on `.panel-actions`. Set flex-shrink:0 on
   `.panel-actions` (icons never shrink), flex-shrink:2 on the label,
   flex-shrink:3 on `.git-badge` (shrinks fastest), and
   `min-width:0;text-overflow:ellipsis` for graceful intermediate
   shrink. Add @container queries that crisply set `display:none` on
   the git badge below 220px and on the label below 160px.

2. Project color dot was appended INSIDE the `.session-title` span,
   which is `overflow:hidden;text-overflow:ellipsis`. Long titles
   clipped the dot off entirely -- hiding the project marker exactly
   when it was most needed. The timestamp was also `position:absolute`,
   so the title's `flex:1` ran underneath it and there was nowhere
   coherent to anchor the dot.

   Fix: in sessions.js, append the dot to `titleRow` between title and
   timestamp (a flex sibling, not inside the truncating title span).
   In style.css, move `.session-time` from absolute positioning to
   `margin-left:auto` in the flex row. Drop the
   `margin-left:4px/vertical-align:middle` from
   `.session-project-dot` (gap:6px on the row handles spacing).
   Reduce `.session-item` padding-right at rest from 86px (which was
   reserving space for the absolute timestamp) to 8px; expand to 40px
   on hover/streaming/unread/menu-open/focus-within so the absolute
   action button + attention indicator still have room.

Tests:
  - tests/test_workspace_panel_session_list.py (14 new tests)
  - tests/test_issue856_pinned_indicator_layout.py updated to reflect
    the new flex-flow timestamp + reduced rest-padding

Full suite: 2433 passed, 47 skipped, 0 PR-related failures.

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

* fix(ui): remove duplicate margin-left:auto from .git-badge

With .panel-actions already carrying margin-left:auto, both .git-badge
and .panel-actions having auto margins split the free space equally,
centering the badge instead of keeping it adjacent to the label.

Remove margin-left:auto and margin-right:4px from .git-badge. The
panel-header gap:6px handles label→badge spacing; panel-actions
margin-left:auto owns the right-push. Layout: [label][badge][→][actions].

* fix(ui): mobile session-item padding 86px → 40px + git-badge margin fix

Two fixes from Opus independent review of #1089:

1. Mobile padding regression: .session-item mobile override had
   padding:10px 86px 10px 12px — the 86px was reserving space for the
   old position:absolute timestamp. Since the timestamp now lives in the
   flex flow of .session-title-row (margin-left:auto), that 86px
   reservation is wasted and pushes the timestamp ~76px from the right
   edge, leaving dead space between it and the always-visible action
   button. Fixed: 86px → 40px (matching desktop hover/attention rule,
   only enough for the absolute action button at right:6px + 26px wide).

2. Duplicate margin-left:auto on .git-badge: the old rule from master
   had margin-left:auto on .git-badge (for the old space-between layout).
   With .panel-actions also having margin-left:auto, the two auto margins
   split free space equally, floating the badge to the middle of the header
   instead of keeping it flush against the label. Removed margin-left:auto
   and margin-right:4px from .git-badge; gap:6px on .panel-header handles
   label→badge spacing; .panel-actions margin-left:auto owns the right-push.

Updated tests:
- test_workspace_panel_session_list.py: assert 40px mobile padding
- test_issue856_pinned_indicator_layout.py: assert 40px mobile padding

Verified by Playwright visual QA:
- Desktop 250px: badge hidden, Workspace label visible, icons visible ✓
- Desktop 150px: badge hidden, label hidden, icons only ✓
- Project dots visible on long-title sessions (outside truncating title span) ✓
- Mobile: padding-right=40px, no layout overflow ✓

* docs: v0.50.220 release notes, test count 2481, roadmap

---------

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-26 00:19:05 -07:00
nesquena-hermes
d625bac6d4 v0.50.219: project chip context menu + input auto-sizing (#1087)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(projects): opaque context menu + auto-sizing rename/create input

Two project chip UI bugs reported in project-ui-bugs.md:

1. Right-click context menu was transparent and the session list bled
   through it. Root cause: _showProjectContextMenu set
   background: var(--panel), but --panel is not defined anywhere in
   style.css, so the menu fell back to transparent. Fix: use
   var(--surface) -- the same opaque variable used by
   .session-action-menu and other floating popovers.

2. The rename and new-project input field was hard-coded to 100px
   regardless of the project name being edited (a 3-letter name got
   the same field size as a 20-letter name). Fix: drop width:100px
   from .project-create-input, replace with
   min-width:40px / max-width:180px / width:auto. Add a
   _resizeProjectInput() helper that measures the current value with
   a hidden span and sets pixel width inside those bounds. Wired into
   both _startProjectRename (called once on focus, again on every
   input event) and _startProjectCreate (same pattern).

Tests: 9 new static-source tests in tests/test_project_chip_ui.py
that pin (a) var(--panel) is undefined in style.css so the fallback
trap doesn't return; (b) menu uses var(--surface); (c) the fixed
width:100px is gone and min/max bounds are present; (d) the
_resizeProjectInput helper is defined and called from both flows.

Full suite: 2419 passed, 47 skipped, 0 PR-related failures.

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

* fix(projects): use getComputedStyle in _resizeProjectInput sizer span

Switch the hidden sizer span from hardcoded font-size:10px / font-family:inherit
to reading the live values from getComputedStyle(inp). This keeps the sizer
calibrated if the CSS rule ever changes, rather than silently drifting.

Also update test_resize_helper_uses_hidden_span to assert getComputedStyle
is used rather than the old literal font-size check.

Suggested by Opus independent review of #1086.

* docs: v0.50.219 release notes, test count 2467, roadmap update

---------

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 23:28:29 -07:00
nesquena-hermes
498b51bfc6 v0.50.218: chat bubble overflow, project color picker, blockquote renderer (#1085)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(css): add overflow-wrap:anywhere to chat bubbles — prevents long URL overflow (#1080)

* fix(projects): rename now works via dblclick timer guard + right-click color picker (#1078)

* fix(renderer): block-level constructs inside blockquotes now render

Fenced code blocks, headings, horizontal rules, and ordered lists inside
blockquotes now render correctly. Six related bugs documented in
blockquote-rendering-bugs.md were collapsed into one architectural fix
in renderMd().

Bugs fixed (all 6):

1. Fenced code blocks inside blockquotes -- > prefixes leaked into the
   <pre> body and the blockquote got fragmented around the rendered
   code, sometimes leaving raw <pre>/<div class="pre-header"> as
   visible text.
2. Blank > continuation lines fragmented multi-paragraph blockquotes
   into separate <blockquote> elements with literal > between them.
3. ## headings inside blockquotes rendered as literal "##" text.
4. Numbered lists inside blockquotes rendered as plain prose.
5. Complex blockquote (mixed headings + code + list + inline code)
   collapsed into a monospace blob with raw markdown syntax leaking
   everywhere.
6. Horizontal rules (---) inside blockquotes rendered as literal text.

Root cause:

The per-line passes for fenced code, headings, hr, ordered lists all ran
BEFORE the blockquote handler and could not match lines that started
with >, so by the time blockquote stripping ran those constructs had
already been mishandled.

Fix:

A new blockquote pre-pass at the top of renderMd():

- Walks lines fence-aware so > -prefixed lines inside non-blockquote
  code fences (e.g. shell prompts in bash code blocks) are not
  miscaptured as a blockquote.
- Groups consecutive > -prefixed lines, strips the > prefix, and
  recursively calls renderMd() on the stripped content. The recursive
  call handles all block-level constructs (fenced code, headings, hr,
  ordered/unordered lists, nested blockquotes) using the same pipeline.
- Wraps the rendered HTML in <blockquote> and stashes it with a \x00Q
  token. Restored at the very end of renderMd() so no later pass can
  mangle the inner HTML.

The old _applyBlockquotes regex-replace is removed entirely along with
its limited inline branches for nested blockquotes and unordered lists.

Behaviour change:

Blockquotes now produce CommonMark-compliant <p> wrapping for text
content (was: bare text directly inside <blockquote>). The visual
output is the same in browsers but the HTML structure is now standard.

Tests:

- 14 new behavioural tests in tests/test_renderer_js_behaviour.py
  drive the actual renderMd() via node and lock all 6 bug fixes.
- .local-review/test_blockquote_bugs.js -- node harness covering the
  same scenarios, runnable manually for fast iteration.
- 2407/2408 tests pass (1 pre-existing macOS-only failure deselected).

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

* fix(renderer): entity decode before blockquote pre-pass + CSS margin fix

- Move the &gt;/&lt;/&amp; entity-decode to run at the very top of
  renderMd(), before the blockquote pre-pass. Previously decode() ran
  at line 756 (after the pre-pass at line 697), so LLM output containing
  &gt;-encoded blockquotes was never matched by the pre-pass.

- Add .msg-body blockquote p{margin:0} and .preview-md blockquote p{margin:0}
  so the new CommonMark-compliant <p> wrapping inside blockquotes doesn't
  add extra vertical spacing. Prior shape (bare text) had no default p-margins.

- Add Node-driven tests: TestBlockquoteEntityEncodedInput covers &gt; prefix
  and &gt;-encoded fenced code inside blockquotes.

- Add struct test: TestBlockquotePrePassOrdering::test_entity_decode_runs_before_blockquote_pre_pass
  locks decode < _bq_stash ordering in ui.js.

Fixes found during Opus independent review of #1083.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: v0.50.218 release notes, test count 2458, roadmap update

---------

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 23:08:59 -07:00
nesquena-hermes
62adc0c00d v0.50.217: /queue /interrupt /steer send normally when agent is idle (#1077)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(commands): /queue /interrupt /steer send normally when agent is idle

When the agent is not running, these three commands now fall through to a
direct send() call (setting the input value and invoking send()) instead
of showing an error toast.  This matches CLI behaviour — the commands are
mode-sensitive: they operate as queue/interrupt/steer when busy, and as
normal sends when idle.

Before:
  /queue hello   → "No active task — just send normally" (toast, nothing sent)
  /steer hello   → "No active task to stop." (misleading + nothing sent)
  /interrupt hi  → "No active task to stop." (nothing sent)

After:
  /queue hello   → message sent immediately (same as typing and pressing Enter)
  /steer hello   → message sent immediately
  /interrupt hi  → message sent immediately

Note: /stop when idle still shows "No active task" — that one is correct
since stopping nothing is always an error.

15 new tests in test_cmd_idle_fallback.py covering the idle path for all
three commands and verifying the active-session paths are unchanged.

* test(commands): update stale test doc — /queue idle now sends, not rejects

test_cmd_queue_requires_busy was written before the idle-send fallback
existed. Its docstring said "/queue while not busy is a usage error"
and the assertion message said "reject if idle" — both accurate for the
old toast-and-return behaviour but wrong after this PR.

The test assertion itself (`"if(!S.busy)" in body`) still passes because
the idle guard still exists; it just routes to send() instead of a toast.
Updating the name and copy to accurately describe what the code now does,
so the test reads as documentation rather than as a contradiction.

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

* docs: v0.50.217 release notes and version bump

---------

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 21:35:51 -07:00
nesquena-hermes
58ad315dca v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
3d96dc1498 v0.50.215: real /steer via agent.steer() — mid-turn correction without interrupt (#1069)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-04-25 19:21:00 -07:00
nesquena-hermes
520034c071 v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
16955b712c docs: v0.50.213 release notes and version bump (#1065)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 18:39:51 -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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2221/2221 tests passing.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Implement session agent cache for AIAgent reuse

Added session agent cache to reuse AIAgent across messages.

* Implement agent caching for session management

* Implement session agent eviction on session deletion

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

* ci: re-run tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: document sandbox security tradeoff for HTML preview

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

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

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

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

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

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

Added test_inline_html_response_sets_csp_sandbox to pin the header.

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

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

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

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

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

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

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:33:41 -07:00
nesquena-hermes
3ce7844a7a feat(queue): Codex-style message queue flyout above composer (#1040)
Some checks failed
Release & Docker / release (push) Has been cancelled
* chore: apply pending #965 queue flyout patches on local master

Queue flyout implementation (PR #965 — pending merge) applied on top of
upstream v0.50.205. Features:
- Queue card slides up from behind composer (approval-card pattern)
- Lucide icons via li(), CSS class system, no inline SVG dumps
- Drag-to-reorder by _queued_at timestamp (survives re-renders)
- Inline contenteditable edit with focus guard and blur-commit
- Combine preserves first item files, merge immediate (no 200ms race)
- Files/model compact badges per item
- Hide/expand via header chevron + composer pill + titlebar chip
- All 3 expand paths sync correctly
- border-bottom CSS order fixed, fingerprint improved, _dragTs guards

CF CSP domains also applied (deployment-specific, not in upstream PR).

* fix(queue): harden merge closure, toggleQueue sid, and drain flash

- mergeBtn _doMerge now reads live queue (_getSessionQueue) instead of stale closure q
- toggleQueue reads activeSid from S.session at call time, not captured param
- updateQueueBadge defers chips.innerHTML='' by 360ms so slide-out transition completes before content clears

* style(queue): contain:paint on inner, pill fade-in animation

* feat(queue): pill outside composer, compact collapsed state matching card width

- Move #queuePill out of .composer-box to between .composer-flyout and .composer-box
- Pill styled as compact queue-card-inner (same border, radius:14px 14px 0 0, no border-bottom)
- Pill width matches card inner: max-width:calc(var(--msg-max)-40px), centered
- Pill stays visible until user re-expands or queue drains (updateQueueBadge no longer
  hides pill when card is manually collapsed)
- Remove all queue-active/queue-pill-active composer modifications — composer untouched
- Fix: mergeBtn reads live queue not stale closure
- Fix: toggleQueue uses S.session.session_id at call time not captured param
- Fix: chips.innerHTML deferred 360ms on drain to avoid empty-card flash

* fix(queue): collapsed state persists + cross-session DOM isolation

- Add _queueCollapsed[sid] flag: set by hideBtn, cleared by pill expand / queue drain
- _renderQueueChips respects flag — no longer reopens card when new message queued while collapsed
- updateQueueBadge else-branch: DOM mutations now gated on sid===active session
- _syncQueueTitlebar only fires for active session in else-branch
- Fixes Opus/Codex-identified bugs: pill auto-reopen and cross-session DOM corruption

* fix(queue): proper pill wrapper matching queue-card structure

- Add .queue-pill-outer div wrapper (max-width:var(--msg-max); padding:0 20px)
  identical to .queue-card outer — positions pill button at exact card-inner width
- .queue-pill button fills slot with width:100%
- Removes hardcoded 740px — width is derived correctly from the same CSS variables
  the card uses, scales with --msg-max across all viewports
- JS toggles .show on pillOuter (parentElement), not on pill button directly

---------

Co-authored-by: Basit Mustafa <basit.mustafa@gmail.com>
2026-04-25 14:21:50 -07:00
nesquena-hermes
ad8e10304c v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: remove orphaned i18n keys from top-level LOCALES object

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

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

Fixes #1008

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

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

* Fix empty agent sessions in sidebar

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

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

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

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

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

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

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

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

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

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

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

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

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

Also imports _workspace_blocked_roots for the pre-mutation blocklist check.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 13:07:35 -07:00
nesquena-hermes
12a8c051fb fix: inject full workspace path into agent context for uploaded files (#997)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: inject full workspace path into agent context for uploaded files (#997)

Uploaded files (drag-and-drop or paperclip) were saved correctly to the workspace
but the agent message only contained the bare filename — `photo.jpg` instead of the
full path. The agent couldn't call `read_file` or `vision_analyze` without a full path.

`uploadPendingFiles()` now returns `{name, path}` objects from `/api/upload`
(`data.path` was always returned, just never threaded through). The agent message
gets the full absolute path; all display surfaces (badges, session history, INFLIGHT
state, POST body) continue showing only the bare filename.

Three fixes absorbed during review:
- Second `saveInflightState()` call was passing raw `{name,path}` objects instead
  of the `uploadedNames` string array (INFLIGHT localStorage corruption on page reload)
- `attachLiveStream()` was being called with the raw object array; changed to pass
  `uploadedNames` so the `done` handler receives strings, not objects
- `attachLiveStream` `done` handler referenced `uploadedNames` which is out of scope
  there (ReferenceError on every upload success); fixed to use the `uploaded` param

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Closes #996
2026-04-24 23:09:44 -07:00
nesquena-hermes
44a6587e78 docs(architecture): document workspace path trust levels (#993)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 13:22:28 -07:00
nesquena-hermes
0a6f15d8d9 chore: v0.50.205 CHANGELOG
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 13:04:44 -07:00
nesquena-hermes
2800ebdcff fix(workspace): allow adding external paths not under home directory (#991)
The workspace add endpoint used resolve_trusted_workspace() which blocks any path
outside the user's home directory, the saved workspace list, or BOOT_DEFAULT_WORKSPACE.
This created a circular dependency: to add /mnt/d/Projects you need it in the saved
list, but to get it in the list you need to add it.

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

Fixes #953.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 13:04:36 -07:00
nesquena-hermes
3c457d178d chore: v0.50.204 CHANGELOG
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 12:54:13 -07:00
nesquena-hermes
c0019723d1 fix(docker): use /home/hermes/.hermes for HERMES_HOME in compose files (#989)
Fixes container crash on startup (#967). The hermes-agent image drops
privileges to a 'hermes' user via gosu; /root is mode 700 so mkdir
fails under /root/.hermes. Changed to /home/hermes/.hermes throughout.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 12:54:05 -07:00
nesquena-hermes
34329ad231 chore: v0.50.203 CHANGELOG (#964)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 12:34:05 -07:00
Basit Mustafa
e62338d3a0 fix(queue): drain correct session queue after cross-session stream completion (#964)
When a session finishes streaming while the user has switched to a different
session, setBusy(false) was draining S.session.session_id (the currently
*viewed* session) instead of the session that actually finished. Queued
follow-up messages were silently dropped.

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

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

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

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 12:33:56 -07:00
nesquena-hermes
619646159c chore: v0.50.202 CHANGELOG (#972)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 12:33:25 -07:00
Basit Mustafa
a4b56642d9 perf(streaming): throttle inflight localStorage persist to prevent GC crash (#972)
saveInflightState() is called from syncInflightAssistantMessage() on every
token. It does localStorage.getItem + JSON.parse + mutate + JSON.stringify +
localStorage.setItem on the full inflight state map. For a 5000-token response
with a 10KB messages array this produces ~36MB of JSON churn per second.

This O(response_length) work per token is the primary source of GC pressure
that causes the renderer to crash (Chrome error codes 4/5). The 13.6-second
RunTask we observed in perf traces is a direct consequence: accumulated rAF
callbacks execute all at once after each multi-second GC pause.

Fix: add _throttledPersist() which writes at most once every 2 seconds during
token streaming. State transitions that matter for crash recovery (tool events,
done, start) still call persistInflightState() directly, so at most 2s of
in-flight progress is lost if the tab crashes mid-stream.

The _persistTimer is cleared on 'done' so the final state is always flushed.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 12:33:16 -07:00
nesquena-hermes
32276c81d1 chore: v0.50.201 CHANGELOG
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:58:18 -07:00
nesquena-hermes
86b20d362f fix(streaming): call clearTimeout at all _pendingRafHandle cleanup sites (#985)
_scheduleRender() now uses setTimeout(→rAF) when within the 66ms throttle
window, meaning _pendingRafHandle can hold a setTimeout ID (not a rAF ID).
All 4 cleanup sites only called cancelAnimationFrame(), which is a no-op for
timeout handles, leaving stale callbacks that could fire after stream end.
Fix: call both clearTimeout() and cancelAnimationFrame() at each site.
(clearTimeout is a no-op when called with a rAF handle, and vice versa.)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:57:48 -07:00
nesquena-hermes
8ce83b637c chore: v0.50.200 CHANGELOG (#963)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:49:23 -07:00
Basit Mustafa
6333a06524 perf(ui): cache renderMessages per session, skip O(n) rebuild on back-navigation (#963)
renderMessages() tears down and rebuilds every message's DOM from scratch on
every call — renderMd() (markdown parse), Prism highlight, and KaTeX per
message, O(n) total. With large sessions the main thread blocks for 1-5
seconds on each call.

A Chrome perf trace (78s, many open sessions) showed:
- 9,373ms of GC across 34,049 GC events (sustained, not burst)
- Peak 273 messages.js FunctionCalls/second
- 4.7s, 3.5s, 3.2s main-thread blocks from repeated renderMessages invocations

The render bottleneck is unaddressed by PR #959 (which improves the network/
parse leg of session switching, not the render leg).

Fix: a session-keyed innerHTML cache. After a full rebuild, the rendered HTML
is stored against the session_id + message count. When switching back to a
session that was already rendered with the same count, the DOM is restored from
cache (fast innerHTML set + re-highlight) instead of rebuilt from scratch.

Guard: the cache is only used on cross-session navigation (sid !== current).
In-session updates (new messages, edits, tool_complete, stream events) always
get a full rebuild — no stale content is ever shown.

Cache is capped at 30 sessions and evicts oldest-first to bound memory.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 11:49:14 -07:00
nesquena-hermes
5663fb147b chore: v0.50.199 CHANGELOG (#966)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:44:56 -07:00
Basit Mustafa
0217bf5cce perf(streaming): throttle live render to ~15fps to prevent crash under GC pressure (#966)
_scheduleRender() uses requestAnimationFrame to update the live assistant
message during streaming. rAF fires at up to 60fps, but each DOM update
takes 50-150ms on sessions with long histories — far exceeding the 16ms
rAF budget.

During GC pauses (which can run for hundreds of milliseconds), rAF
callbacks accumulate. When the GC yields, the browser executes all
queued callbacks sequentially in a single RunTask. A Chrome performance
trace shows a 13.6-second RunTask containing 1,240 accumulated render
callbacks — which causes the renderer to crash (Chrome error codes 4/5,
ERR_EMPTY_RESPONSE / ERR_CONNECTION_RESET).

Fix: track the last render timestamp and delay scheduling the next rAF
until at least 66ms (15fps) have elapsed since the previous render.
If within the 66ms window, use setTimeout to defer the rAF rather than
skipping it — this batches token updates without dropping any content.

The 66ms interval is conservative enough to prevent runaway accumulation
while fast enough that streaming text still feels immediate. The _renderPending
flag continues to prevent double-scheduling within each interval.

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 11:44:47 -07:00
nesquena-hermes
da131b842d chore: v0.50.198 CHANGELOG (hotfix)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:41:41 -07:00
nesquena-hermes
ef72384217 fix: harden _accepts_gzip + update stale test assertions post-#959 (#981)
Fixes introduced when absorbing PR #959 (fast conversation switching):
- _accepts_gzip() now uses getattr() to tolerate _FakeHandler and any
  synthesised handler that lacks a .headers attribute (fixes 2 test failures
  in test_sprint46.py)
- test_issue401: updated assertion to accept both minified and reformatted
  forms of the tool_calls fallback guard (PR reformatted the code)
- test_regressions: updated activeStreamId assertion — PR refactored
  data.session references to S.session for direct state access

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:41:17 -07:00
nesquena-hermes
116a510ed3 i18n: add complete Traditional Chinese (zh-Hant) translations (#954)
Some checks failed
Release & Docker / release (push) Has been cancelled
* i18n: add complete Traditional Chinese (zh-Hant) translations

- Add 300+ zh-Hant translation entries covering all UI sections:
  onboarding, settings/Control Center, session actions, cron jobs,
  providers panel, workspace management, skills, profiles, todos, BTW
- Fix existing zh-Hant translations: remove mixed Simplified Chinese
  characters, fix typos (e.g. 皮膚→佈景, 待踩→待辦, 新存對話→新對話)
- Update zh locale: fix 需要审批→需要审核 (Simplified Chinese correction)
- Add data-i18n attributes to Control Center HTML (index.html) for
  heading, subtitle, tab names, dropdown, and section titles
- Migrate session action menu (sessions.js) from hardcoded English to
  t() function calls for full i18n support

* fix: translate remaining English entries to Traditional Chinese in zh-Hant locale

- settings_heading_title: 'Control Center' → '控制中心'
- settings_dropdown_providers: 'Providers' → '供應商'
- providers_section_title: 'Providers' → '供應商'
- providers_tab_title: 'Providers' → '供應商'

* fix: add missing locale keys to zh/ru/es/de + restore zh approval_heading

- zh (Simplified): reverted approval_heading to 需要审批 (matches master)
  PR had changed it to 需要审核 which broke the representative-translation test
- zh/ru/es/de: added 39 new session management + settings keys as English
  fallback strings (session_archive, session_pin, settings_dropdown_*, etc.)
  These keys were added to English in this PR but missing from other locales
- es: added cmd_status (English fallback) to fix coverage gap
- Fixes all locale coverage test failures

---------

Co-authored-by: 陳俊宇 <chenjunyu@chenjunyudeMacBook-Air-7.local>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:36:41 -07:00
nesquena-hermes
ed24010e10 chore: v0.50.197 CHANGELOG (#954)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:36:13 -07:00
nesquena-hermes
23b7c63198 chore: v0.50.196 CHANGELOG (#959)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:35:23 -07:00
Josh Jameson
7e17ec497c fix: fast conversation switching with metadata-first load (#959)
- Backend: save session JSON with metadata fields before messages array
  so load_metadata_only() reads only ~1KB without parsing the full session
- Backend: add GET /api/session?messages=0 for metadata-only responses
  (~1KB vs ~400KB), enabling instant sidebar switching
- Backend: add POST /api/admin/reload to hot-reload models without restart
- Backend: gzip compress JSON API responses (>1KB) for 70-80% bandwidth reduction
- Frontend: show Loading indicator immediately on session switch, replacing
  old DOM before API call to prevent stale content flash
- Frontend: clear S.messages before API call so _ensureMessagesLoaded
  always fetches fresh data for the target session
- Frontend: wrap both Phase 1 (messages=0) and Phase 2 (_ensureMessagesLoaded)
  in try/catch to prevent permanently stuck loading state on network/server errors
2026-04-24 11:35:14 -07:00
nesquena-hermes
2d5c4b71cc chore: v0.50.195 CHANGELOG (#962)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:21:50 -07:00
Basit Mustafa
4a882bec66 fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.

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

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

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

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 11:21:41 -07:00
nesquena-hermes
f48b157a8f chore: v0.50.194 CHANGELOG (#960)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:04:42 -07:00
bsgdigital
a2d7f311be fix(streaming): prevent dropped characters in incremental smd path (#960)
Detect prefix desync between current display text and already-streamed text, then rebuild the streaming-markdown parser from full content to avoid character loss during live rendering. Add regression assertions for the new desync guard.

Made-with: Cursor

Co-authored-by: bsgdigital <bsg@bsgdigital.com>
2026-04-24 11:04:32 -07:00
nesquena-hermes
c06ec43f17 chore: v0.50.193 CHANGELOG (#958)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:04:26 -07:00
bsgdigital
e5cf9c5910 fix(streaming): strip malformed DSML function_calls tags (#958)
Handle DeepSeek DSML variants including truncated and spaced tag forms, and sanitize thinking-card text so leaked XML fragments never render. Add regression tests for DSML edge cases and thinking-card sanitization.

Made-with: Cursor

Co-authored-by: bsgdigital <bsg@bsgdigital.com>
2026-04-24 11:04:16 -07:00
nesquena-hermes
70de09290c chore: v0.50.192 CHANGELOG (#951)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:04:09 -07:00
ruxme
f109592cb0 perf: add defer to all local script tags (#951)
All 10 local <script> tags now use the defer attribute, allowing the
browser to download them in parallel during HTML parsing instead of
blocking the DOM sequentially. Execution order is preserved.

Before: scripts loaded one-at-a-time, each blocking DOM construction
After:  scripts downloaded in parallel, executed in order after DOM ready

Fixes slow sidebar session list rendering on initial page load.

Co-authored-by: 陳俊宇 <chenjunyu@chenjunyudeMacBook-Air-7.local>
2026-04-24 11:03:59 -07:00
nesquena-hermes
d339200b5b chore: v0.50.191 CHANGELOG (#948)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 11:03:52 -07:00
starship-s
0a91e3cb02 fix: identify WebUI sessions as webui platform (#948)
* fix: use webui platform for webui sessions

* test: harden WebUI platform hint regression coverage
2026-04-24 11:03:42 -07:00
nesquena-hermes
cb41075bd2 chore: v0.50.190 CHANGELOG (.venv #949)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 10:45:33 -07:00
xingyue
91703e3e54 fix(config): add .venv discovery paths in _discover_python (#949) 2026-04-24 10:45:23 -07:00
nesquena-hermes
396537c624 chore: v0.50.189 CHANGELOG (#961 csp)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 10:45:09 -07:00
Basit Mustafa
b072a6887c fix(csp): add explicit manifest-src 'self' directive (#961)
PR #920 added static/manifest.json and sw.js for PWA support. The CSP
in _security_headers() had no explicit manifest-src directive, so browsers
fell back to default-src 'self' and emitted a console warning on every page
load. The fallback is functionally correct but non-compliant with CSP Level 3
best practice of declaring each directive explicitly.

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

Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 10:44:46 -07:00
nesquena-hermes
27e69c404a chore: v0.50.189 CHANGELOG (csp #961)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 10:44:34 -07:00
nesquena-hermes
dbc9c910a8 chore: v0.50.188 CHANGELOG (btw fix #950)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 10:44:10 -07:00
bergeouss
23e9070fc5 fix(btw): use correct SSE endpoint /api/chat/stream (#950)
The /btw command was completely non-functional because attachBtwStream()
connected to /api/stream which doesn't exist — the server SSE handler
lives at /api/chat/stream. This caused an immediate 404 on every /btw
request.

Closes #945

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 10:43:44 -07:00
nesquena-hermes
e0257d81d5 chore: v0.50.187 CHANGELOG entry for breakpoint fix (#956)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 09:13:35 -07:00
nesquena-hermes
533edbcae0 fix(ui): close 641-767px rail/hamburger breakpoint gap (#956)
At 641-767px the sidebar was in a no-mans-land: hamburger hidden (<=640 only)
and rail also hidden (>=768 only). Users could still navigate via the sidebar-nav
tabs inside the sidebar, but the rail was absent unnecessarily.

Changing the rail breakpoint from min-width:768px to min-width:641px closes the
gap. The sidebar slide-in behavior (position:fixed, hamburger toggle) stays at
<=640px only, so the mobile UX is unchanged. At 641-767px the rail now appears
alongside the persistent sidebar.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 09:13:00 -07:00
nesquena-hermes
885f1fa349 chore: v0.50.186 CHANGELOG entry for three-column layout (#899)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-24 09:06:50 -07:00
Aron Prins
970bc1d3fd refactor(ui): three-column layout with left rail + main-view migration (#899)
refactor(ui): three-column layout with left rail + main-view migration (#899)

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

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

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

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

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

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

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 23:25:45 -07:00
nesquena-hermes
87d4136a43 fix(ui): move reasoning chip after model chip in composer footer (#937)
Reasoning is a sub-setting of the model (applies only to models that
support it), so the model should come first. This also keeps the model
chip in a stable position regardless of whether reasoning is active.

Order was: Profile → Workspace → Reasoning → Model
Order now:  Profile → Workspace → Model → Reasoning

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 19:43:41 -07:00
nesquena-hermes
ce9aec1640 chore: v0.50.184 release notes (#936)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 19:38:48 -07:00
nesquena-hermes
1a9dba7844 fix: reasoning chip dropdown visible + monochrome SVG icon + /btw answer preserved (closes #933) (#934)
* fix: reasoning chip dropdown visible + SVG icon + /btw answer no longer wiped (closes #933)

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

Two small additions on top of the core PR:

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

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

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

Full suite: 2056 passed, 0 failed.

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

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:18:51 -07:00
nesquena-hermes
06bedc8e23 Merge pull request #932 from nesquena/pr-929-review
Some checks failed
Release & Docker / release (push) Has been cancelled
feat(commands): /background, /btw slash commands + undo button + reasoning chip
2026-04-23 18:33:50 -07:00
Nathan Esquenazi
63b0207604 fix(background): wire completion hook + keep running tasks in tracker
The /background feature was fundamentally non-functional as shipped —
two coupled bugs kept results from ever reaching the user:

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

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

Fix:

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

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

Full suite: 2023 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:32:47 +00:00
nesquena-hermes
9c69b646ff feat(commands): /background, /btw slash commands + undo button + reasoning chip
Rebased onto master after #931 (aux title routing) to resolve streaming.py conflict.
All changes from both PRs are cleanly integrated.

2088 tests passing (2065 master + 23 from #931).

Co-authored-by: bergeouss <bergeouss@gmail.com>
2026-04-24 01:24:51 +00:00
nesquena-hermes
57222c70e7 Merge pull request #931 from nesquena/pr-925-review
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(streaming): respect auxiliary.title_generation config for session titles
2026-04-23 18:22:44 -07:00
nesquena-hermes
14a1924796 fix(streaming): respect auxiliary.title_generation config for session titles
- _aux_title_configured(): returns True when provider/model/base_url is set
- _aux_title_timeout(): reads configured timeout, falls back to 15.0s default
- _generate_llm_session_title_via_aux: use_agent_model kwarg preserves old behavior
- Missing llm_invalid_aux fallback now triggers agent-model retry
- 23 new tests in tests/test_title_aux_routing.py — all pass

Co-authored-by: starship-s <starship-s@users.noreply.github.com>
2026-04-24 01:07:02 +00:00
nesquena-hermes
36da37ff13 Merge pull request #930 from nesquena/feat/vendor-smd-0.2.15
Some checks failed
Release & Docker / release (push) Has been cancelled
chore: vendor streaming-markdown@0.2.15, remove CDN dependency
2026-04-23 18:06:26 -07:00
nesquena-hermes
b14ea4f9f6 chore: vendor streaming-markdown@0.2.15, remove CDN dependency
Self-hosts smd.min.js (12,586 bytes, sha384 verified against npm tarball).
App works fully offline/air-gapped. Static server correctly serves static/vendor/*.

Co-authored-by: bsgdigital <bsgdigital@users.noreply.github.com>
2026-04-24 01:05:20 +00:00
nesquena-hermes
ff970ec844 Merge pull request #923 from nesquena/feat/917-streaming-markdown
Some checks failed
Release & Docker / release (push) Has been cancelled
Merging feat/917-streaming-markdown. 2065 tests pass. APPROVED by @nesquena. Pre-existing QA harness failure on master confirmed (not a regression).
2026-04-23 17:43:40 -07:00
Nathan Esquenazi
b563484a56 fix(smd): strip javascript:/data:/vbscript: URLs — smd does not sanitize schemes
streaming-markdown@0.2.15 preserves arbitrary URL schemes in href/src.
Verified with a Node + jsdom harness:

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

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

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

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

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

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

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

Co-authored-by: bsgdigital

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

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

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

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

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

---------

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

Co-authored-by: bsgdigital
Closes #685

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

The offline-navigation fallback was dead code:

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

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

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

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

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

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

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:14:21 -07:00
nesquena-hermes
07caaec6ef fix(mobile): adapt settings dialog and message controls for mobile screens (#919)
* fix(mobile): adapt settings dialog and message controls for mobile screens (#915)

Co-authored-by: bsgdigital

* fix(mobile): adapt settings dialog and message controls for mobile screens (v0.50.177, #915)

Co-authored-by: bsgdigital

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 15:12:07 -07:00
nesquena-hermes
1175ee363f fix(models): duplicate dropdown entries, stale default model, lowercase injected label (#907 #908 #909) (#918)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
5082f426f2 fix: correct interleaved streaming order (Text → Thinking → Tool → Text) (#913)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: correct interleaved streaming order (Text → Thinking → Tool → Text)

During live streaming, tool cards were inserted before their associated
thinking cards instead of after them. The root cause was that
appendLiveToolCard's anchor selector didn't include .thinking-card-row,
so finalized thinking cards were skipped when finding the insertion point.

Changes:
- messages.js: Add segment splitting (segmentStart/_freshSegment) so each
  text segment after a tool call renders only its own slice, not the full
  accumulated text. Sync thinking card render in reasoning handler to
  avoid rAF race with tool events. Guard removeThinking() to preserve
  finalized cards when reasoningText is active.
- ui.js: Add .thinking-card-row to appendLiveToolCard anchor selector so
  tool cards land after finalized thinking. Add anchor-based positioning
  to appendThinking for correct interleaved placement. Clean up empty
  spinner-only thinking rows in finalizeThinkingCard. Add 3-dot waiting
  indicator (toolRunningRow) after tool cards for visual feedback.
- style.css: Scope blinking cursor to last live-assistant segment only.
  Add spacing for toolRunningRow.

* chore: CHANGELOG for v0.50.174

---------

Co-authored-by: bsgdigital <bsgdigital@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 13:23:43 -07:00
nesquena-hermes
537c8271db fix(renderer): ordered list items always showed 1. — emit value= on each li (#886) (#904)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(renderer): ordered list items always showed 1. — emit value= on each <li> (#886)

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

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

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

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

---------

Co-authored-by: Hermes Bedrock Fix <hermes-fixes@local>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 12:15:56 -07:00
nesquena-hermes
9dd6e3f338 fix(cancel): preserve partial streamed response on Stop Generation (#893) (#902)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(models): preserve @nous: prefix in settings + fix cross-namespace 404 for Nous (#895 #894)

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

* chore: CHANGELOG for v0.50.170

* chore: trigger PR refresh after rebase

---------

Co-authored-by: fr33m1nd <bergeouss@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-23 09:58:15 -07:00
bergeouss
cd01e4d5ba feat(models): live-first model fetching for all OpenAI-compat providers (#892)
* feat(models): live-first model fetching for all OpenAI-compat providers (#871)

The WebUI model picker relied on hardcoded _PROVIDER_MODELS as primary
source for providers like zai, minimax, mistralai, xai, openai-codex,
deepseek, and gemini. These lists go stale — new models don't appear
until someone manually updates the dict.

Add an OpenAI-compat /v1/models fetch fallback in _handle_live_models()
that fires when provider_model_ids() is unavailable or returns []. The
resolution chain is now:

  1. hermes_cli.provider_model_ids() (agent's live fetch)
  2. Custom providers from config.yaml
  3. Direct /v1/models fetch for known OpenAI-compat endpoints
  4. Static _PROVIDER_MODELS as last-resort offline fallback

Covers: zai, minimax, mistralai, xai, openai-codex, deepseek, gemini.

Uses urllib (stdlib) — no new dependencies. Static lists remain as
offline fallback so the UI always shows something.

Closes #871

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

* refactor(models): address review feedback on live fetch (#892)

Five changes from nesquena-hermes review:

1. Move _OPENAI_COMPAT_ENDPOINTS to module level — avoid dict
   reconstruction per request
2. Document urllib blocking behavior — 8s timeout acceptable because
   server is threaded and frontend enriches in background
3. Add TODO comment for TTL-based caching follow-up
4. Remove openai-codex from endpoint map — same endpoint as base
   openai provider, already covered by provider_model_ids()
5. Restrict API key lookup to provider-scoped and model.api_key only
   — remove top-level api_key fallback to prevent cross-provider
   key leakage

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 09:45:46 -07:00
Pavol Biely
96c97c5e0e fix: remove hardcoded chinese title heuristics (#887)
* fix: remove hardcoded chinese title heuristics

* fix: use english placeholder for non-latin fallback titles
2026-04-23 09:45:34 -07:00
Joe Maples
ae7be6deba fix(docker): Install all dependencies for agent (#897) 2026-04-23 09:45:28 -07:00
bergeouss
bd443c4862 fix(markdown): stash code blocks with attributes and multiline content (#890) (#891)
The _ob_stash regex in renderMd() used (<code>[^<]*</code>) which failed
to match <code class="language-sql"> tags (attributes) and couldn't capture
multiline content. Code blocks leaked into the bold/italic pipeline,
corrupting SQL/C# comments into <strong><em> tags and producing &lt;
artifacts.

Replace with (<code\b[^>]*>[\s\S]*?</code>) to handle attributes and
multiline content correctly.

Closes #890

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 09:45:20 -07:00
nesquena-hermes
b82954ee70 feat(ui): session attention indicators — streaming spinner, unread dot, timestamps (#856)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #856. Co-authored-by: Frank Song <138988108+franksong2702@users.noreply.github.com>
Reviewed-by: nesquena (709bd37 — test isolation fix also included)
2026-04-23 09:05:57 -07:00
nesquena-hermes
666d385c03 fix: Nous static models use @nous: prefix — v0.50.164 (#885)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: Nous static models use @nous: prefix — v0.50.164 (#885)

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

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

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

Closes #854.
2026-04-22 22:56:21 -07:00
nesquena-hermes
d39d30a213 fix: correct message ordering after task cancellation — v0.50.163 (#883)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: correct message ordering after task cancellation — v0.50.163 (#883)

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

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

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

1941 tests passing.

Co-authored-by: piliang <piliang1@jd.com>
2026-04-22 22:17:40 -07:00
Frank Song
62c56175b7 feat(workspaces): autocomplete trusted workspace paths — v0.50.162 (PR #880 by @franksong2702, closes #616)
Some checks failed
Release & Docker / release (push) Has been cancelled
Adds GET /api/workspaces/suggest endpoint and autocomplete dropdown in the Spaces panel. Suggestions limited to trusted roots (home, saved workspaces, boot default). Keyboard nav, Tab completion, hidden dir support. Symlink-escape and dotdot-escape invariants locked by regression tests.
2026-04-23 02:35:58 +00:00
nesquena-hermes
0f1b232c12 fix(ci): eliminate test_set_key flakiness — v0.50.161
Some checks failed
Release & Docker / release (push) Has been cancelled
Root cause: test_profile_env_isolation.py and test_profile_path_security.py called sys.modules.pop() without restoring, poisoning subsequent tests. Fix: monkeypatch.delitem so pytest auto-restores. Also holds _ENV_LOCK for full I/O cycle in _write_env_file and creates .env at 0600 via os.open. Reviewed by Opus (no independent review needed — test/providers fix only).
2026-04-23 02:09:37 +00:00
nesquena-hermes
cc025aab79 fix(ci): add missing provider i18n keys to non-English locales — v0.50.160
Adds 19 provider panel keys (English fallback) to es, de, zh, ru, zh-Hant. Fixes locale parity CI failures since v0.50.159.
2026-04-23 01:24:11 +00:00
Pavol Biely
236a116888 fix(ux): selected text visible in user message bubbles + CI i18n fix — v0.50.160 (PR #877 by @pavolbiely)
Some checks failed
Release & Docker / release (push) Has been cancelled
User bubble selection contrast fixed via scoped ::selection CSS (closes #877). Also adds missing provider i18n keys to es/de/zh/ru/zh-Hant locales, fixing 3 CI failures that crept in from PR #867.
2026-04-23 01:19:21 +00:00
nesquena-hermes
04b00065f9 feat: provider key management from Settings — v0.50.159 (PR #867 by @bergeouss, closes #586)
Some checks failed
Release & Docker / release (push) Has been cancelled
New Providers tab in Settings lets users add/update/remove API keys without editing .env. Six review fixes applied. 18 tests.
2026-04-23 01:09:22 +00:00
nesquena-hermes
e3607855b1 fix: poll /health after update instead of blind setTimeout — v0.50.158 (closes #874)
Replaces blind setTimeout reload with /health polling loop. Banner shows restart status with manual Reload button. Works behind reverse proxies. 25 regression tests.
2026-04-23 00:51:12 +00:00
bergeouss
a72208eaf6 fix(docker): improve two-container agent path discovery and docs — v0.50.158 (PR #873 by @bergeouss, closes #858)
Some checks failed
Release & Docker / release (push) Has been cancelled
docker_init.bash now checks /opt/hermes as a fallback alongside the primary path. Warning updated with concrete mount guidance. Volume type notes added to compose files and README.
2026-04-22 23:35:09 +00:00
nesquena-hermes
0a75b3f1d3 fix: Nous portal model IDs + portal provider routing guard — v0.50.157 (closes #854)
Two bugs fixed: (1) _PROVIDER_MODELS["nous"] updated to slash-prefixed IDs that Nous API expects. (2) resolve_model_provider() now routes portal provider models through the portal (not OpenRouter) and preserves the full slash-prefixed model ID. 10 regression tests.
2026-04-22 23:05:27 +00:00
Joe Maples
1a98f75005 fix(docker): add openssh-client to Docker image for SSH terminal backend — v0.50.157 (PR #868 by @frap129)
Some checks failed
Release & Docker / release (push) Has been cancelled
Adds openssh-client to apt-get install block so Docker users running the SSH terminal backend can connect to remote agents. Closes #868.
2026-04-22 22:39:41 +00:00
nesquena-hermes
095dbfd641 docs: update ROADMAP, SPRINTS, and BUGS to v0.50.156 — 1903 tests
Update sprint history table in ROADMAP.md through v0.50.156, fix test count header, add Known Limitations section to BUGS.md, update SPRINTS.md header. Reviewed by Opus — factually accurate, table column alignment fixed.
2026-04-22 21:14:08 +00:00
nesquena-hermes
3a63fe479e fix(security): gate auto-install behind HERMES_WEBUI_AUTO_INSTALL=1 — v0.50.156
Some checks failed
Release & Docker / release (push) Has been cancelled
Breaking: auto_install_agent_deps() is now disabled by default. Set HERMES_WEBUI_AUTO_INSTALL=1 to re-enable. New _trusted_agent_dir() checks ownership and permission bits. Addresses #842 by @tomaioo.
2026-04-22 20:49:28 +00:00
nesquena-hermes
96cb880a12 fix: Honcho per-session uses stable session ID across WebUI turns — v0.50.155 (closes #855)
Pass gateway_session_key=session_id to AIAgent from streaming.py so Honcho per-session strategy pins to stable WebUI session ID rather than creating a new Honcho session each turn.
2026-04-22 20:48:52 +00:00
nesquena-hermes
e151665131 release: v0.50.154 — image_generate, auto-title, portal routing, thinking card fixes
Bumps README test count to 1898. Release tag for v0.50.151-154 bug fixes.
2026-04-22 20:47:52 +00:00
nesquena-hermes
558b1730a6 fix: thinking card no longer mirrors main response — v0.50.154 (closes #852)
Remove early return in _streamDisplay() bypassing think-block stripping when reasoningText populated.
2026-04-22 20:21:42 +00:00
nesquena-hermes
201235d807 fix: live-fetched portal models route through configured provider — v0.50.153 (closes #854)
_fetchLiveModels() applies @provider: prefix to model IDs from portal providers.
2026-04-22 20:21:02 +00:00
nesquena-hermes
256b3fbbdf fix: image_generate renders inline + auto-title strips thinking preamble — v0.50.152 (closes #853, #857)
MEDIA: restore renders all https:// URLs as img (closes #853).
_strip_thinking_markup strips Qwen3 plain-text reasoning preambles (closes #857).
2026-04-22 20:20:01 +00:00
nesquena-hermes
5fa731ea4a release: v0.50.151 — credential_pool provider detection + Ollama Cloud support (PR #820 by @starship-s)
Surfaces providers added via credential_pool in the model dropdown. Ambient gh-cli tokens suppressed. _apply_provider_prefix helper extracted. Ollama Cloud display name + dynamic model list. looksLikeBareOllamaId heuristic tightened. Test isolation fixed.

PR #820 by @starship-s.
2026-04-22 20:18:02 +00:00
nesquena-hermes
d8e1f37e2b release: v0.50.150 — session index, read-path, profile-switching fixes
Some checks failed
Release & Docker / release (push) Has been cancelled
Bundles three bug fixes (PRs #847, #848, #849) and updates README test count to 1858.

- v0.50.148: prune stale _index.json ghost rows after session-id rotation (closes #846)
- v0.50.149: side-effect-free GET /api/session model resolution (closes #845)
- v0.50.150: profile switching cookie persist + syncTopbar fix + active indicator state
2026-04-22 17:09:35 +00:00
Miguel Tavares
f42f1c69ca fix: correct webui profile switching state — v0.50.150 (PR #849 by @migueltavares)
Three related profile-switching fixes:
- Always persist hermes_profile=default cookie when switching back to default (was being cleared with max-age=0, causing fallback to process-global profile)
- Replace undefined updateWorkspaceChip() with syncTopbar() in the sessionInProgress branch of switchToProfile()
- Make sidebar/dropdown active-profile rendering prefer S.activeProfile client state when available, with safe fallback

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

Closes #845.

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

Closes #846.

Review additions: optimised lock pattern in all_sessions() (one LOCK acquisition instead of N). Tests: 1856 passing.
2026-04-22 16:26:38 +00:00
nesquena-hermes
85434dd03c fix(appearance): font size setting now visibly scales UI text (closes #843)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(appearance): font size setting now visibly scales UI text

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

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

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

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

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

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 23:39:39 -07:00
nesquena-hermes
db57c47ff3 fix(ui): slash command input now echoed as user message in chat (closes #840)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(ui): echo slash command input as user message in chat (#840)

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

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

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

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

16 new tests in test_issue840_slash_echo.py.

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

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

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

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

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

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

Added 2 regression tests:

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

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

Full suite: 1767 passed, 0 failures.

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

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

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

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:08:24 -07:00
nesquena-hermes
9b628c27ab fix(ui): scroll selected item into view on slash command dropdown keyboard navigation (closes #838)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(ui): scroll selected item into view on slash command dropdown keyboard nav

navigateCmdDropdown() in commands.js now calls scrollIntoView({block:'nearest'})
after updating the .selected class, so the highlighted item stays visible
when the dropdown overflows and the user navigates with ↓/↑. Closes #838.

* test: lock in scrollIntoView for slash command dropdown navigation (#838)

4 regression tests in test_cmd_dropdown_scroll_838.py:
- navigateCmdDropdown calls scrollIntoView on the selected item
- Uses {block:"nearest"} (minimum-distance scroll, not jumpy)
- Scroll call comes AFTER the .selected classList.add (correct target)
- .cmd-dropdown has overflow-y:auto so the dropdown itself is the scroll
  container (scrollIntoView does not bubble up to the viewport)

Full suite: 1749 passed, 0 failures.

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-21 22:55:09 -07:00
nesquena-hermes
11fd0d8412 feat(tasks): refresh button in cron panel + auto-refresh on job creation (closes #835)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(tasks): refresh button in cron panel + hermes:cron_created event

Add a ↺ refresh button to the Scheduled Jobs header so the job list can
be reloaded without a full page refresh. Closes #835.

- static/index.html: ↺ button with cronRefreshBtn id, calls loadCrons(true)
- static/panels.js: loadCrons(animate) dims+disables the button while fetching,
  restores it in finally; hermes:cron_created window event auto-refreshes list
  when the agent creates a job from chat

* test: add regression tests for cron refresh button + event listener

The PR shipped without automated coverage (pure UI wiring).  Filling that
gap with 8 source-level tests:

- Refresh button element exists with aria-label + title (icon-only a11y)
- Button wires onclick to loadCrons(true) for the dim animation
- Button sits in the same header row as "New job"
- loadCrons() now accepts an animate parameter
- loadCrons() restores the button's opacity/disabled in finally (so a
  throwing fetch doesn't leave the button stuck)
- hermes:cron_created window listener is registered at module scope
- Listener calls loadCrons() when dispatched

Also rebased onto master (CHANGELOG conflict resolved — v0.50.143 →
v0.50.142 since master's top is currently v0.50.141).

Full suite: 1750 passed, 0 new failures.

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-21 22:54:06 -07:00
nesquena-hermes
24fc9d4155 feat(appearance): font size setting with Small/Default/Large toggle (closes #833)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(appearance): font size setting with Small/Default/Large toggle

Add a font size preference to the Appearance settings pane.
Three options (12px/14px/16px) follow the same three-button visual
pattern as the Theme picker. Closes #833.

- static/style.css: :root[data-font-size=small|large] CSS overrides
- static/index.html: boot script applies from localStorage before CSS
  renders (no FOUC); fontSizePickerGrid HTML in Appearance pane
- static/boot.js: _applyFontSize(), _pickFontSize(), _syncFontSizePicker()
- static/panels.js: loadSettingsPanel syncs picker on open;
  _revertSettingsPreview restores on discard
- static/i18n.js: settings_label_font_size + font_size_{small,default,large}
  keys in all 6 locales (en, ru, es, de, zh, zh-Hant)
- tests/test_font_size_setting.py: 14 new tests

* fix(ui): remove duplicate font-size picker + correct CHANGELOG issue ref

Two small fixes on the font size feature:

1. Duplicate HTML IDs — the picker block was injected into BOTH
   settingsPaneAppearance (correct, next to Theme/Skin) AND
   settingsPanePreferences (accidental copy-paste).  Duplicate IDs
   #fontSizePickerGrid and #settingsFontSize violate HTML spec and
   break the _syncFontSizePicker visual sync which reads via
   document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn')
   — only the first grid would update its highlight, leaving the second
   stale.  $('settingsFontSize') via getElementById also always returns
   the first match, so the second hidden input never reflected the
   user's choice.

   Removed the Preferences-pane copy.  The Appearance-pane copy is the
   one the PR description describes and is the correct home for it
   (next to Theme and Skin).

2. CHANGELOG trailer said `Closes #830.` but #830 is the session-search
   autocomplete PR — this feature closes #833.  Fixed.

Added two regression tests:
- test_font_size_picker_not_duplicated: asserts each ID appears exactly
  once in index.html.
- test_font_size_picker_lives_in_appearance_pane: asserts the picker
  sits inside settingsPaneAppearance and not any other pane.

Full suite: 1754 passed, 0 failures.

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-21 22:52:45 -07:00
nesquena-hermes
1239129ae2 fix(models): stale cross-provider model no longer shows as unavailable in picker (closes #829)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(models): stale cross-provider model no longer shows as unavailable in picker

Two bugs allowed an openai/gpt-5.4-mini stale session model to appear as
'(unavailable)' under a custom provider group for users who never configured
OpenAI (#829).

Backend (api/routes.py): _resolve_compatible_session_model() had a blanket
early-return for active_provider in {custom, openrouter} that skipped all
normalization regardless of whether any catalog group could route the model's
prefix. A custom_providers-only user with a stale openai/... session model
was never corrected. Fixed: only skip normalization when the model prefix is
actually routable (matches a catalog group provider_id, or an openrouter
group is present that can route any provider/model).

Frontend (static/ui.js): renderSession() injected a bare <option> (not in
any <optgroup>) for models not found in the dropdown. renderModelDropdown()
rendered bare options without emitting a group heading, so they visually
inherited the last rendered provider heading — making the stale model appear
to belong to the custom provider group. Fixed: silently reset to the first
available model and fire a PATCH to persist the correction instead of
injecting a misleading (unavailable) option.

5 new tests in test_provider_mismatch.py cover:
- stale openai model cleared when custom_providers-only + no default_model
- stale openai model cleared when custom_providers-only + default_model set
- openrouter model preserved when openrouter group present
- custom/ namespace always preserved
- ui.js no longer injects model_unavailable option

* fix(ui): declare modelSel locally in syncTopbar reset path; fix test assertion

- Use const modelSel=$('modelSelect') instead of undeclared sel in the
  stale-model reset branch of syncTopbar() (caught in Opus review)
- Fix test assertion: or → and for model_unavailable key absence check

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 22:20:08 -07:00
nesquena-hermes
880085a09e fix(ui): clear session search on boot + autocomplete=off + pageshow bfcache handler (closes #822)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(ui): clear session search on boot + autocomplete=off — prevents bfcache from restoring stale filter (closes #822)

* fix(ui): add pageshow handler for true bfcache restore case (#822 completion)

The original PR's two fixes cover fresh page loads and hard reloads —
but the bug the issue describes happens on *bfcache restore* (Chrome's
back-forward cache).  The async boot IIFE does NOT re-run when the
browser restores a page from bfcache; the DOM is restored in place,
including any stale #sessionSearch value.  The boot-time clear has no
effect there.

`autocomplete="off"` is a hint that Chrome and others sometimes honour
for bfcache but is not reliable for user-typed values (as opposed to
autofill candidates).

Add a pageshow event listener that checks event.persisted === true and,
on that path only, clears #sessionSearch and re-renders from cache.
Fresh loads skip the listener (persisted=false) and continue to be
handled by the boot IIFE.

Also added tests/test_session_search_bfcache_822.py with 7 tests:
- autocomplete="off" present on the input
- boot-time clear runs before the first renderSessionList
- pageshow listener registered
- handler guards on event.persisted
- handler clears the search field and triggers a re-render

Full suite: 1745 passed, 0 failures.

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-21 22:11:32 -07:00
nesquena-hermes
d4a3adb7b1 fix(sessions): surface gateway SSE failures and add polling fallback (#828)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sessions): surface gateway SSE failures and add polling fallback

- add a JSON probe mode for the gateway SSE endpoint
- detect watcher-unavailable 503s from the browser
- fall back to periodic session refresh with a toast
- add probe payload tests and endpoint coverage

Fixes #635

* fix(sessions): surface gateway SSE failures and add polling fallback (#826)

Absorbed from PR #826 by @cloudyun888 (fixes #635).

When the gateway watcher thread is not running, the browser now shows a
toast notification and falls back to 30-second periodic polling for session
sync. Previously the SSE failure was completely silent with no user feedback.

Changes from original PR:
- Deleted misplaced test_gateway_sse_probe_unit.py (was at repo root, not
  discovered by `pytest tests/`); unit tests moved into tests/test_gateway_sync.py
- _gateway_sse_probe_payload now checks watcher._thread.is_alive() rather
  than just watcher is not None — a watcher instance with a dead poll thread
  now correctly reports unavailable and activates the polling fallback
- probeGatewaySSEStatus catch(e) now starts the polling fallback on network
  error rather than silently swallowing the failure
- Added 5 unit tests covering all watcher-alive/dead/missing/disabled branches

Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>

* cleanup(gateway): public is_alive() + dedup probe/live watcher-alive check + changelog

Three small cleanups on top of @cloudyun888's PR #826 absorption:

1. Add GatewayWatcher.is_alive() public accessor so routes.py doesn't
   reach into the private _thread attribute.  The existing private-
   attribute check stays as a defensive fallback for any older in-
   memory instance or test double that doesn't implement the full API.

2. Dedupe the watcher_alive computation in _handle_gateway_sse_stream:
   the live-SSE path now calls _gateway_sse_probe_payload(...) and reads
   its watcher_running field instead of re-deriving the same logic
   inline.  Keeps probe and SSE in sync automatically.

3. CHANGELOG trailer was (#826, fixes #635, @cloudyun888) — this PR is
   #828, so updated to (#828, absorbs PR #826 by @cloudyun888, fixes
   #635) matching the repo convention for absorbed PRs (see #805).

Added two regression tests:
- test_gateway_watcher_is_alive_public_method — covers the three
  lifecycle states (before start, while running, after stop).
- test_probe_payload_prefers_public_is_alive — asserts the probe
  uses watcher.is_alive() rather than poking _thread when the
  public method exists.

Full suite: 1735 passed, 0 new failures.

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

---------

Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@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-21 21:18:55 -07:00
nesquena-hermes
3daf2427f7 docs(testing): update automated test count to 1777 (#827)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 20:32:24 -07:00
nesquena-hermes
d41d05ea36 fix(workspace): _profileDefaultWorkspace persists after newSession() (#823)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #823.

Separates two conflated semantics in S._profileDefaultWorkspace:
- Persistent blank-page default (set by boot/settings, never nulled)
- Profile-switch one-shot (now S._profileSwitchWorkspace, consumed by newSession())

newSession() priority: switchWs → current session → _profileDefaultWorkspace.
switchToWorkspace() clears _profileSwitchWorkspace on explicit switch.

9 new tests. 1777/1777 suite. Browser-verified.
2026-04-21 19:14:31 -07:00
nesquena-hermes
859602340e fix: streaming race conditions (#631) + blank-page workspace binding (#804)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #631. Closes #804.

Bug A (thinking card below answer / double render / stuck cursor): trailing rAF after 'done'
inserted a duplicate live-turn wrapper into already-settled DOM. Fixed via _streamFinalized flag
+ cancelAnimationFrame in all terminal handlers (done/apperror/cancel/_handleStreamError) +
_scheduleRender guard. All three reported symptoms were the same root cause.

Bug B (accumulator reset): original fix reset assistantText/reasoningText inside _wireSSE on reconnect.
Reverted — server uses one-shot queue.Queue(), no replay on reconnect, reset would wipe valid
pre-drop content causing data loss. Bug A fix alone resolves all symptoms.

#804 (blank page workspace): syncWorkspaceDisplays uses S._profileDefaultWorkspace as fallback;
workspace chip enabled when hasWorkspace (not hasSession); promptNewFile/promptNewFolder/
switchToWorkspace/promptWorkspacePath auto-create session on blank page; boot.js hydrates
_profileDefaultWorkspace from /api/settings before any session exists.

Opus max-effort review + Nathan independent review + full browser QA. 1765/1765 tests.
2026-04-21 18:47:40 -07:00
nesquena-hermes
c3807482be fix(tests): pin _cfg_mtime=0.0 in except so CI reload_config() guard works
Some checks failed
Release & Docker / release (push) Has been cancelled
Root cause of persistent CI failure on `test_custom_endpoint_uses_model_config_api_key_for_model_discovery`:

The helper `_available_models_with_full_cfg` and the failing test itself both do:
```python
try:
    _cfg._cfg_mtime = stat().st_mtime
except Exception:
    pass  # ← leaves _cfg_mtime at stale value from a prior test
```

On CI (no `~/.hermes/config.yaml`), `stat()` raises `OSError`. `get_available_models()` then sees `_current_mtime=0.0 != _cfg_mtime=<stale from prior test>`, calls `reload_config()`, overwrites the test's in-memory `cfg`, `api_key` disappears, `urlopen` is never called, `captured['auth']` raises `KeyError`.

**Fix:** The `except` clause now sets `_cfg._cfg_mtime = 0.0` (matching what `get_available_models()` will see when `stat()` also fails), so the reload guard becomes a no-op and the test's `cfg` mutation survives. Same pattern already used correctly in `test_custom_provider_display_name.py` and `test_ttl_cache.py`.

Verified with `HERMES_CONFIG_PATH=/tmp/nonexistent` to reproduce the CI no-config condition locally. 1747/1747 full suite.
2026-04-21 17:49:10 -07:00
nesquena-hermes
2d8bccdd96 fix(tests): add autouse cache-isolation fixture to get_available_models test files
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes the CI failure introduced by #817: test_model_resolver::test_custom_endpoint_uses_model_config_api_key_for_model_discovery was failing with KeyError: 'auth' due to the 60s TTL cache in get_available_models() being populated by test_byok_model_dropdown.py tests that ran earlier. Added autouse _isolate_models_cache fixture to 5 test files. Full suite 1747/1747, QA harness green.
2026-04-21 17:41:05 -07:00
nesquena-hermes
8f1f582caf fix: BYOK/custom provider models missing from WebUI model dropdown (#815)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
a4d59b9e6c fix: update banner — conflict recovery path + server self-restart after update (#816)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: update banner conflict recovery + server self-restart after update (#813 #814)

* fix(update): restart must wait for in-flight update + reset force button on retry

Two defects in the update banner flow found during review of PR #816:

1. Two-target race (webui + agent sequential)
   The client posts targets sequentially: webui succeeds and schedules
   a restart timer (2 s delay); client then posts agent; server begins
   agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
   killing the agent update and closing the client connection. User
   sees "Update failed (agent): Failed to fetch" even though webui did
   update, and the agent repo is in an unknown partial state.

   Fix: _schedule_restart() now blocks on _apply_lock before calling
   os.execv. If a second update is in flight when the timer fires, the
   restart thread waits until it completes. If nothing is in flight the
   lock acquire is instant, so no-op updates still restart immediately.

2. Stale force-update button across retries
   _showUpdateError sets btnForceUpdate to display:inline-block when
   res.conflict / res.diverged. Nothing resets it on the next retry,
   so a subsequent non-conflict error (e.g. network) leaves the stale
   force button visible pointing at the previous target.

   Fix: applyUpdates() now hides the force button and clears its
   data-target at the start of each attempt.

Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
  helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
  check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
  that the reset appears before the update loop begins.

Full suite: 1683 passed, 0 failures.

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

* fix(update): hold _apply_lock through execv + fix banner error layout

Two fixes from Opus review:

1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
   acquired _apply_lock, released it, then called os.execv — leaving a brief
   window where a new update could start between release and execv. Fixed by
   moving os.execv inside the 'with _apply_lock:' block so the process is
   replaced while still holding the lock; no new update can acquire it.

2. Banner CSS layout (static/index.html): #updateError was a direct flex child
   of .update-banner (display:flex row), so long error messages sat inline
   between #updateMsg and the buttons instead of below the message.
   Wrapped #updateMsg + #updateError in a flex-column container so errors
   stack vertically under the status line.

* docs: add v0.50.134 CHANGELOG entry

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:10:41 -07:00
nesquena-hermes
811424a87b feat(reasoning): full /reasoning CLI parity — show|hide + effort levels via config.yaml (#812)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
nesquena-hermes
f6e1612c7e fix: periodic session checkpoint during streaming — v0.50.132 (#810)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #765. Supersedes #809 (@bergeouss). Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-21 12:07:44 -07:00
nesquena-hermes
081c4208d9 docs: fix CHANGELOG word count for v0.50.131 (#808)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 17:42:17 +00:00
nesquena-hermes
e05fc4e0e4 fix(ui): workspace pane now respects app theme (#807)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #786. Seven hardcoded dark-mode rgba values replaced with theme-aware CSS vars.
2026-04-21 17:36:33 +00:00
nesquena-hermes
312a493a72 fix(sessions): new sessions appear immediately in sidebar (#806)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #789 Bug A. 60-second exemption in all_sessions() filter.
2026-04-21 17:08:52 +00:00
nesquena-hermes
3246b263d9 fix(profiles): complete profile isolation via cookie + thread-local (#805)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes the gap left by #800. Full isolation via hermes_profile cookie + TLS.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-21 17:04:11 +00:00
nesquena-hermes
bbc917a5c6 fix(renderer): stop &quot; mangling inside code blocks (#801)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #801.

Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com>
2026-04-21 16:26:51 +00:00
nesquena-hermes
cbb4ba3f28 fix(profiles): profile isolation — new_session uses per-request profile, not process global (#800)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes the multi-client profile isolation bug (#798).

- get_hermes_home_for_profile(): pure path resolver, validates name against
  _PROFILE_ID_RE (rejects path traversal), never mutates os.environ or globals
- new_session() accepts explicit profile= param from POST body (S.activeProfile),
  short-circuits the process-level _active_profile global
- streaming handler resolves HERMES_HOME from s.profile instead of the global
- sessions.js sends profile: S.activeProfile in every new-session POST

10 tests in tests/test_issue798.py including concurrency and traversal coverage.

Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-04-21 16:16:51 +00:00
nesquena-hermes
d527629281 docs: add CHANGELOG entry for v0.50.126 (#797) (#799)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 15:42:00 +00:00
Dave Brown
77ab63361f fix(onboarding): recognize credential_pool OAuth auth for openai-codex (#797)
fix(onboarding): recognize credential_pool OAuth auth for openai-codex (#797)

The onboarding readiness check in `api/onboarding.py` only looked at the legacy
`providers[provider]` key in `auth.json`. Hermes runtime resolves OAuth tokens from
`credential_pool[provider]` (device-code / OAuth flows), so WebUI could report "not ready"
while the runtime chatted successfully. The check now covers both storage locations with
a fail-closed helper. Adds three regression tests.

Reported in #796, fixed by @davidsben.

Co-authored-by: davidsben <davidsben@users.noreply.github.com>
2026-04-21 15:41:34 +00:00
nesquena-hermes
3f484aec33 fix: add --chown to Dockerfile COPY so RUN can write api/_version.py (#793)
The v0.50.124 Docker build failed with:
  cannot create /apptoo/api/_version.py: Permission denied

Root cause: 'USER hermeswebuitoo' is set before 'COPY . /apptoo', but
COPY without --chown creates files owned by root. The subsequent RUN
step (which writes api/_version.py) runs as hermeswebuitoo and has no
write permission to the root-owned api/ directory.

Fix: COPY --chown=hermeswebuitoo:hermeswebuitoo so the unprivileged user
owns the app files and can write _version.py at build time.

Regression from #790.

Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-20 21:03:41 -07:00
nesquena-hermes
49ff8b3185 fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh (#730) (#791)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh

When users run 'python3 bootstrap.py' directly (the primary documented
entry point in README), HERMES_WEBUI_HOST, HERMES_WEBUI_PORT and other
.env settings were silently ignored because the shell-level 'source .env'
in start.sh was never executed.

Add _load_repo_dotenv() in bootstrap.py that reads REPO_ROOT/.env into
os.environ before DEFAULT_HOST / DEFAULT_PORT are evaluated at module
level. Uses unconditional assignment matching 'set -a; source .env'
shell semantics. Only loads the repo .env (bootstrap config) — not
~/.hermes/.env, which the server still loads independently at startup
for provider credentials.

Reported in #730 by @leap233 who had HERMES_WEBUI_HOST=0.0.0.0 and
HERMES_WEBUI_PORT=18787 in the webui .env; running bootstrap.py directly
caused the server to ignore both settings.

Tests: 15 new tests in tests/test_bootstrap_dotenv.py covering the
full loader (key=value, comments, blank lines, quoted values, no-file,
unreadable-file, overwrite semantics, values with = signs) and structural
assertions that _load_repo_dotenv() is called before DEFAULT_HOST/PORT.
1613 tests total.

* fix: address review feedback on PR #791

- bootstrap.py: document overwrite semantics and 'export' note in docstring
- bootstrap.py: handle 'export FOO=bar' prefix (strip before splitting on =)
- bootstrap.py: print warning to stderr on .env parse failure (not silent swallow)
- bootstrap.py: add side-effect comment at _load_repo_dotenv() call site
- CHANGELOG.md: restore v0.50.124 and v0.50.123 headers (were merged into
  v0.50.125 section, making three consecutive ### Fixed blocks with no ## header
  between them)
- tests: fix test_noop_when_dotenv_unreadable to assert warning is emitted
- tests: tighten test_does_not_set_empty_values with concrete assertion
- tests: add test_export_prefix_stripped
- tests: remove dead _import_bootstrap_with_env() helper (never called)
1614 tests total

---------

Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-20 20:55:53 -07:00
nesquena-hermes
38e215e8f8 fix: dynamic version badge — read from git tag, never hardcoded (#790)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: dynamic version badge — read from git tag, never hardcoded

The settings panel showed v0.50.87 and the HTTP Server: header said
HermesWebUI/0.50.38 — both hardcoded strings that drift further behind
with every release because there was no mechanism to keep them in sync.

Changes:
- api/updates.py: add _run_git() (moved before _detect_webui_version),
  _detect_webui_version(), and WEBUI_VERSION module constant resolved
  once at import time via 'git describe --tags --always --dirty'.
  Fallback chain: git → api/_version.py → 'unknown'.
- api/routes.py: inject webui_version into GET /api/settings response
  so the frontend can read it without a separate API call.
- static/panels.js: loadSettingsPanel() populates .settings-version-badge
  from settings.webui_version — one line after the existing api() call.
- static/index.html: replace stale hardcoded 'v0.50.87' with '—'
  placeholder; JS overwrites it as soon as the settings panel opens.
- server.py: replace hardcoded 'HermesWebUI/0.50.38' server_version with
  'HermesWebUI/' + WEBUI_VERSION.lstrip('v') — stays in sync automatically.
- Dockerfile: add ARG HERMES_VERSION=unknown and write api/_version.py
  so Docker images (where .git is excluded) still show the correct tag.
- .github/workflows/release.yml: pass build-args: HERMES_VERSION=${{ github.ref_name }}
  to the Docker build step on tag pushes.
- .gitignore: exclude api/_version.py (generated by Docker/CI, never committed).

No manual 'update the version badge' step is required going forward.
Tagging is sufficient — the badge and HTTP header update automatically.

Tests: 18 new tests in tests/test_version_badge.py covering the full
resolution chain, /api/settings injection, HTML placeholder, JS wiring,
and server.py import. 1596 tests pass total.

* fix: address review feedback on PR #790

- api/updates.py: replace exec() with regex parse for api/_version.py
  (no supply-chain risk from build artifact; exec unnecessary for one assignment)
- api/updates.py: cap git describe timeout at 3s (was 10s — import-time
  stall on NFS/.git would block server startup unnecessarily)
- server.py: lstrip('v') → removeprefix('v') (lstrip strips chars not prefix)
- server.py: emit bare 'HermesWebUI' when version is 'unknown' rather than
  'HermesWebUI/unknown' (log aggregators expect semver-ish suffix or none)
- CHANGELOG.md: add v0.50.124 entry for this user-visible change
- tests: rename exec-error test to reflect regex behaviour; add tests for
  removeprefix usage and unknown-version header guard (1598 tests total)

---------

Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-20 20:36:53 -07:00
Nathan Esquenazi
81072d34d6 Merge pull request #788 from nesquena/fix/ci-test-default-model-isolation
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(tests): restore conftest default model in teardown — fixes CI ordering failure
2026-04-20 19:35:13 -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
629d4290ed fix(tests): restore conftest default model in test_default_model_updates_hermes_config — fixes CI ordering failure
The test was restoring original_model from /api/models, but after prior runs
the config.yaml model.default field could be stale, causing the restore to
bake in the wrong value. Fix: always restore to TEST_DEFAULT_MODEL (the
conftest-injected env value) for deterministic ordering-independent cleanup.

Also exposes TEST_DEFAULT_MODEL from _pytest_port.py so other tests that
mutate the default model can use it for clean teardown.

TESTING.md: update automated test count from 1353 to 1578.
2026-04-21 02:25:14 +00:00
nesquena-hermes
28b4777b5a fix(ui): hide duplicate close button in workspace header at mobile width (#783)
Some checks failed
Release & Docker / release (push) Has been cancelled
At the @media(max-width:900px) breakpoint both .close-preview and .mobile-close-btn were visible simultaneously. Since boot.js wires both to handleWorkspaceClose(), only the mobile-close-btn needs to show at that width. Adds .close-preview{display:none} to the 900px media block.

Fixes #781
2026-04-21 00:58:02 +00:00
nesquena-hermes
b6d335feaa perf: TTL cache for model list + incremental session index (#780)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
a7e8b1ab83 fix(streaming): eagerly release session lock in cancel_stream() (#778)
Some checks failed
Release & Docker / release (push) Has been cancelled
cancel_stream() now pops STREAMS/CANCEL_FLAGS/AGENT_INSTANCES and clears session.active_stream_id immediately after signalling cancel. Fixes sessions permanently stuck at 409 when the agent thread is blocked in a bad tool call. Session cleanup runs outside STREAMS_LOCK to preserve lock ordering.

Fixes #653

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-20 23:54:40 +00:00
nesquena-hermes
c34892be44 fix(streaming): guard newer AIAgent kwargs with inspect for hermes-agent compat (#775)
Some checks failed
Release & Docker / release (push) Has been cancelled
Uses inspect.signature() to check which params AIAgent accepts. Fixes #772.
2026-04-20 23:23:19 +00:00
nesquena-hermes
98cd318413 fix(sessions): surface get_cli_sessions() failures via logger.warning (#769)
Some checks failed
Release & Docker / release (push) Has been cancelled
Logs warnings instead of silently returning [] on DB errors. Fixes #634.
2026-04-20 23:13:54 +00:00
nesquena-hermes
94a04ddd40 fix(ui): persist session queue to sessionStorage across page refresh (#768)
Some checks failed
Release & Docker / release (push) Has been cancelled
Queued follow-up messages now survive page refresh. Persisted atomically in queueSessionMessage/shiftQueuedSessionMessage. On reload: if agent still active, queue is silently hydrated (done handler drains it); if idle, first entry is restored as a composer draft with a toast. Stale entries discarded.

Fixes #660
2026-04-20 23:04:09 +00:00
nesquena-hermes
765d8520d4 fix(streaming): quota error detection, error persistence, stream_end session_id fix (#767)
Some checks failed
Release & Docker / release (push) Has been cancelled
- quota_exhausted error type: distinguishes credit exhaustion from rate limits
- Streaming errors persisted to session file so they survive page reload
- _error flag excludes persisted errors from subsequent LLM API calls
- stream_end and title SSE events use original session_id (not s.session_id which rotates during context compaction)

Fixes #739, #652, #653
2026-04-20 22:48:19 +00:00
nesquena-hermes
76e602af25 feat: remove bubble_layout setting end-to-end (#777)
Some checks failed
Release & Docker / release (push) Has been cancelled
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)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
nesquena-hermes
f35ac3a727 fix(ui): streamline slash sub-argument autocomplete (#771)
Some checks failed
Release & Docker / release (push) Has been cancelled
Adds sub-argument suggestions for /model, /personality, /reasoning slash commands. /reasoning is now discoverable from the first slash. Keyboard navigation pre-selects the first item. Fixes bug where no-arg commands (/clear, /new, /stop, etc.) would loop the dropdown on selection.

Fixes #632

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-20 22:04:28 +00:00
Frank Song
0dd5d6f21c feat(ui): add sidebar density mode to session list (#764)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
a8979f74d5 fix(ui): dark-mode user bubbles use subtle tint + thinking card collapsible — v0.50.111 (#759)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Summary

Rebased on behalf of @aronprins from fork branch `codex/dark-user-bubbles`. Two asset-only commits (PR screenshot add/remove) were dropped; the two code commits are applied cleanly on top of current master (v0.50.110).

### What changed

**Dark-mode user bubbles** (`static/style.css`):
- `:root.dark` now overrides `--user-bubble-bg`/`--user-bubble-border` to `var(--accent-bg-strong)` (a 15% opacity tint) — keeps the bubble visually subdued in dark skins instead of a glaring bright accent fill
- Removes 6 per-skin `--user-bubble-text` hacks (ares, mono, slate, poseidon, sisyphus, charizard); text falls back to `var(--text)` which is already correct in dark mode
- Adds `--user-bubble-placeholder` token; edit-area box-shadow now uses `--focus-ring` instead of hardcoded `rgba(255,255,255,.15)`

**Thinking card collapsibility** (`static/ui.js` + `static/style.css`):
- `_thinkingMarkup()` now includes `onclick` toggle and chevron affordance, matching the compression reference card pattern
- `.thinking-card-header` gets `display:flex; gap:8px` for proper icon/label/chevron alignment

**Tests**: 2 new in `test_bugbatch_apr2026.py` (dark bubble token contract + no-per-skin-hack assertion), 2 updated in `test_ui_card_animation.py` (flex header layout + onclick pattern).

1520 passed. QA 20/20. Browser verified: dark mode bubble uses subtle tint, thinking card toggles correctly.

(credit: @aronprins)
2026-04-20 01:12:45 -07:00
nesquena-hermes
711d8bb6c0 fix(ui): hover-only footer chrome with timestamps for both user and assistant — v0.50.110 (fixes #680) (#758)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash merge of PR #717 — rebased on behalf of @franksong2702.

## What it does

Fixes #680. Footer chrome (timestamps, copy, edit, regenerate) is now hover-only for both user and assistant message rows, consistent throughout the conversation. The last assistant turn keeps cumulative usage visible at rest; timestamp and actions are revealed inline on hover in the same row.

Key changes:
- `static/ui.js`: new `_formatMessageFooterTimestamp()` (local timezone, cross-day fuller format); `timeHtml` no longer gated to user-only; last assistant usage moved from separate `.msg-usage` div to inline `.msg-usage-inline` span in the footer
- `static/style.css`: `.msg-foot-with-usage` class + rules; assistant footer opacity changed from 0.45 to 0 (hover-only); `:focus-within` alongside `:hover` for keyboard users
- `api/streaming.py`: `_restore_reasoning_metadata()` now preserves `_ts`/`timestamp` for unchanged historical messages
- `tests/test_sprint49.py`: 8 new tests covering rendering contract, hover CSS, timestamp preservation

Tests: 1518 passed. QA: 20/20. Browser verified. Reviewed and approved by @nesquena and @aronprins.
2026-04-20 00:53:19 -07:00
nesquena-hermes
a1c5c395e5 fix(tests): pin _cfg_mtime in _models_with_cfg to prevent ordering-dependent failure — v0.50.109 (fixes #754) (#756)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Summary

Fixes the ordering-dependent test failure in `test_custom_provider_display_name.py` (issue #754).

**Root cause:** `_models_with_cfg()` patches `config.cfg` then calls `get_available_models()`. That function checks `config.yaml`'s mtime on every call — if it has changed since the last `reload_config()`, it calls `reload_config()` again, which reads from disk and silently overwrites the patch. Any test that writes `config.yaml` (e.g. via `save_settings()`) before this test runs changes the mtime and triggers the reload.

**Fix:** Pin `config._cfg_mtime` to the current `config.yaml` mtime before calling `get_available_models()`, then restore it in the `finally` block. This is the same pattern already used in `test_model_resolver.py` (lines 249, 393).

**Also restores `_cfg_mtime`** in the `finally` block so the patch leaves no side effects on subsequent tests.

## Tests

1510 passed — the previously-flaky test now passes regardless of which tests ran before it.

Closes #754
2026-04-20 00:39:24 -07: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)
Some checks failed
Release & Docker / release (push) Has been cancelled
## 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
nesquena-hermes
aa767d28d0 fix(renderer): preserve newlines in code blocks during paragraph split (#745) (#746)
Squash merge PR #746: fix(renderer): preserve newlines in code blocks — v0.50.102

All tests pass (1510). Browser QA verified. Reviewed and approved by @nesquena.
2026-04-20 00:04:27 -07:00
nesquena-hermes
78c4f1e425 fix: null/empty session model must not trigger index rebuild — v0.50.101 (#753)
## Summary

Follow-up to #751/#752. Code review identified a case where `_normalize_session_model_in_place` could call `session.save()` (which triggers a full session index rebuild) for sessions with `model: null` or missing model field.

Root cause: `_resolve_compatible_session_model(None)` returns `(default_model, True)` when a default exists — which was interpreted as "changed, needs save." But there's nothing to correct for a session with no model; the default is just a fallback for display purposes, not a cross-provider correction worth persisting.

Fix: capture `original_model` before calling `_resolve_compatible_session_model`. Only call `session.save()` if `original_model` was non-empty and actually changed.

Adds a test asserting `save_calls == []` when `session.model is None`.

No behavior change for sessions with a real model (the primary use case of #751 is unaffected).
2026-04-19 23:44:46 -07:00
nesquena-hermes
81ba420716 fix: custom/unknown model prefixes must not be stripped on provider switch — v0.50.100 (#752)
## Summary

Regression fix for #751.

Models with custom or unrecognized prefixes (e.g. `custom-provider/my-model`, `test/import-model`) were being incorrectly replaced with the active provider default. Root cause: `_normalize_provider_id("custom-provider")` matched the `"custom"` prefix and returned `"custom"`, which ≠ `active_provider` → normalization fired.

Two-part fix:
1. Add `"custom"` and `"openrouter"` to the `model_provider` exclusion set in `_resolve_compatible_session_model` (parallel to the existing `active_provider` guard)
2. Return `""` for unknown prefixes in `_normalize_provider_id` so the `if model_provider` truthiness check safely short-circuits

Adds a regression test covering `custom-provider/`, `test/`, `my-local-llm/`, and `lmstudio-community/` prefixes.

## Tests

1499 passed, 0 failures (was 2 failures before this fix)
2026-04-19 23:27:24 -07:00
nesquena-hermes
7f16a41a31 fix: normalize stale session models after provider switch — v0.50.99 (#751)
## Summary

Rebased-on-behalf of @likawa3b (originally PR #748 — stale base).

Sessions can outlive provider changes. When an old session still points to a model from a previous provider (e.g. `gemini-3.1-pro-preview` after switching the agent to OpenAI Codex), starting a chat hits the wrong backend and fails silently.

This PR adds a lightweight normalization pass:
- `_normalize_provider_id()` maps common prefixes to canonical provider IDs
- `_resolve_compatible_session_model()` checks the session model's provider against `active_provider` and returns the default model if they differ
- `_normalize_session_model_in_place()` is called at GET `/api/session` — corrects and persists stale models once
- Chat start also normalizes via `_resolve_compatible_session_model()` and returns `effective_model` in the response
- `messages.js` applies `effective_model` back to the UI/localStorage/dropdown if set

Closes #748

## Tests

1498 passed (2 pre-existing ordering failures unrelated to this PR; 5 new tests added in `test_provider_mismatch.py`).

**Original author:** @likawa3b
2026-04-19 23:22:26 -07:00
nesquena-hermes
c68420d9aa fix(ui): constrain slash autocomplete width to composer — v0.50.98 (closes #633) (#750)
## Summary

Rebased-on-behalf of @franksong2702 (originally PR #728 — had CHANGELOG conflict after #747 merged).

Moves `#cmdDropdown` from outside `composer-box` to inside it, so the `position:absolute` anchor is scoped to the composer width rather than the full chat panel. CSS updated to use `bottom:calc(100% + 4px)` and `width:auto;max-width:100%` for clean upward positioning.

Closes #633

## Changes
- `static/index.html` — moved `cmd-dropdown` div inside `composer-box`
- `static/style.css` — updated `.cmd-dropdown` positioning (remove `margin-bottom`, use `bottom:calc(100% + 4px)`, add `width:auto;max-width:100%`)
- `tests/test_sprint50.py` — 2 new structural tests verifying DOM position and CSS rules

## Tests
1493 passed, 1 warning (2 new tests added)

**Original author:** @franksong2702
2026-04-19 23:17:00 -07:00
Frank Song
aa78175cca fix(ui): restrict edit to latest user message (#747)
fix(ui): restrict edit to latest user message (#747)

Only the latest user turn shows the pencil/edit affordance. Older user
messages remain read-only (copy + timestamp still work). Avoids the
misleading implication that historical messages can be lightly edited
when the actual action truncates the session and restarts the
conversation from that point.

Closes #744

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-19 23:11:49 -07:00
nesquena-hermes
da1fdca22c docs: fix docker-compose files + add three-container config — v0.50.96 (PR #708)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes gateway port exposure, workspace path expansion, HERMES_WEBUI_STATE_DIR default, and adds three-container reference config with dashboard. All ports localhost-bound by default.
2026-04-19 07:10:05 +00:00
nesquena-hermes
067d96bb30 feat: add full Russian (ru-RU) localization — v0.50.95 (PR #713)
Some checks failed
Release & Docker / release (push) Has been cancelled
Full Russian locale — 389/389 English keys, Slavic plural forms, native Cyrillic. Rebased from PR #605 with rebase artifacts fixed. Login page Russian added to api/routes.py. Credits: @DrMaks22 (translation), @renheqiang (PR #605 author).

Co-authored-by: DrMaks22 <DrMaks22@users.noreply.github.com>
Co-authored-by: renheqiang <renheqiang@users.noreply.github.com>
2026-04-19 06:47:24 +00:00
nesquena-hermes
e637965388 fix: robust mic toggle + Tailscale MediaRecorder fallback — v0.50.94 (PR #715)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes and extends PR #683 (MatzAgent). recognition.start() is now a real call. _isRecording race guard added with correct reset in all paths. localStorage persistence of fallback flag. Closes #683.

Co-authored-by: MatzAgent <MatzAgent@users.noreply.github.com>
2026-04-19 06:28:14 +00:00
nesquena-hermes
66fbfbaa2b fix: gateway sync race condition + hybrid session data loss — v0.50.93 (PR #714)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes and extends PR #676 (yunyunyunyun-yun). Race guard in sessions.js SSE handler; prefix-equality check in routes.py _handle_session_import_cli. Closes #676.

Co-authored-by: yunyunyunyun-yun <yunyunyunyun-yun@users.noreply.github.com>
2026-04-19 06:18:28 +00:00
nesquena-hermes
877a32f49c fix: XML tool-call leak + workspace empty-state + notification text — v0.50.92 (PR #712)
Some checks failed
Release & Docker / release (push) Has been cancelled
Strips <function_calls> XML from assistant messages before rendering, adds workspace file panel empty-state messages, and changes notification description from 'tab' to 'app'. 16 new tests. Fixes #702, #703, #704.
2026-04-19 05:40:37 +00:00
nesquena-hermes
0386dc261a feat: slash command parity + skill autocomplete — v0.50.91 (PR #711)
Some checks failed
Release & Docker / release (push) Has been cancelled
Combines PR #618 (@renheqiang) slash command parity (/retry /undo /stop /title /status /voice) with PR #701 (@franksong2702) skill autocomplete. 1469 tests pass. Closes #460.

Co-authored-by: renheqiang <renheqiang@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-19 05:37:44 +00:00
nesquena-hermes
17e965b52f chore: block agent-local files from git (.claude/ CLAUDE.md AGENTS.md etc)
Removes stale webui-mvp AGENTS.md and expands .gitignore to block all agent-local context files from being committed. Fixes .claude/* → .claude/ (directory block).
2026-04-19 05:37:42 +00:00
nesquena-hermes
d3a686a266 fix(compress): prefer persisted reference handoff after completion — v0.50.90 (PR #699 by @franksong2702)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes the /compress reference card showing only a short 3-line summary immediately after compression. Now prefers the persisted compaction message (full handoff) over the raw API summary, matching what is shown after page reload. Closes #695.
2026-04-19 04:29:07 +00:00
nesquena-hermes
3cd38b2b31 chore: add CHANGELOG entries for v0.50.88 and v0.50.89
Some checks failed
Release & Docker / release (push) Has been cancelled
Adds entries for #672 (model dropdown fix) and #700 (UTF-8 encoding fix). CHANGELOG-only change.
2026-04-19 04:23:38 +00: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
nesquena-hermes
e0ad593801 fix(model dropdown): stop injecting default_model into unrelated providers — v0.50.88 (PR #672 by @franksong2702)
Some checks failed
Release & Docker / release (push) Has been cancelled
2026-04-19 04:18:43 +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)
Some checks failed
Release & Docker / release (push) Has been cancelled
- #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
5266ee26bd feat(ui): searchable model picker with provider group headers — v0.50.86 (PR #659 by @mmartial)
Some checks failed
Release & Docker / release (push) Has been cancelled
- Live search input in model dropdown (filter by name or ID)
- Provider group headers preserved in filtered view
- Clear button, Escape-to-close, No models found empty state
- i18n EN/ES/zh-CN strings
- CSS uses var(--accent) consistent with current theme system
- zh-CN double-escape fix included
- Provider headers regression fix included
- 1423 tests pass

Co-authored-by: mmartial <mmartial@users.noreply.github.com>
2026-04-18 16:27:36 +00:00
nesquena-hermes
5c2840e2da fix(onboarding): remove CLI fast path from _provider_oauth_authenticated — fixes 4 test failures
Some checks failed
Release & Docker / release (push) Has been cancelled
The hermes_cli fast path ignored hermes_home, returning True from real system auth for OAuth providers. Removed — auth now scoped to hermes_home/auth.json only. 1423 passed, 0 failed.
2026-04-18 07:23:16 +00:00
nesquena-hermes
75e6595e06 feat: add MiniMax M2.7 to fallback model list and fix env var detection — PR #650 by @octo-patch
Some checks failed
Release & Docker / release (push) Has been cancelled
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
Some checks failed
Release & Docker / release (push) Has been cancelled
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
ad6e76e48e chore: reorder CHANGELOG v0.50.77-v0.50.82 in descending order
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-18 07:09:06 +00:00
nesquena-hermes
b49de92893 feat(/compress): manual session compression with focus topic — closes #469 (PR #619 by @franksong2702)
POST /api/session/compress with optional focus_topic. Transcript-inline cards: command, running, complete (collapsible green), reference. /compact alias kept. Fixes: var(--green) undefined color, focus_topic 500-char cap. Independent review by @nesquena (4 passes).
2026-04-18 06:55:04 +00:00
nesquena-hermes
b1aa1cfa4d fix(title): auto-title extraction for tool-heavy first turns — closes #639 (PR #640 by @franksong2702)
The auto-title extractor now uses _looks_invalid_generated_title() to distinguish tool-call preambles from substantive agentic replies. Fixes _is_provisional_title() whitespace normalization. 5 regression tests added. Independent review by @nesquena (a553b2b+a0ca9fe).
2026-04-18 06:52:45 +00:00
nesquena-hermes
8c68ea8823 fix: skill panel auto-open, thinking scroll, nav icon alignment, Safari zoom — closes #643 #638 #636 #630 (PR #647)
Four self-contained CSS/JS fixes: skill click auto-opens workspace panel (ensureWorkspacePreviewVisible before api call), thinking card body scrolls when open (overflow-y:auto), nav tab icons properly centered (display:flex), Safari iOS zoom prevented (textarea 14px->16px). Independent review by @nesquena confirmed all four correct.
2026-04-18 06:50:14 +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
nesquena-hermes
bded1cf906 fix(streaming): strip Gemma 4 thinking token delimiter in all paths — closes #607
Fixes <|turn|>thinking delimiter (was wrong as <|turn>thinking) in api/streaming.py, static/messages.js, and static/ui.js. Adds 13 regression tests. Independent review by @nesquena.
2026-04-18 06:45:39 +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
f3f23abd4e fix(csp): allow external https images in img-src — closes #608
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Hermes Agent <agent@hermes>
2026-04-16 23:34:21 -07:00
nesquena-hermes
d6267f4d31 chore: CHANGELOG v0.50.75 + version badge (#620 test isolation fix) (#621)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Hermes Agent <agent@hermes>
2026-04-16 23:06:16 -07:00
nesquena-hermes
e7b8ab4d70 fix: harden test server isolation — HERMES_BASE_HOME + strip provider keys + mock _get_active_hermes_home in unit tests (#620)
Fixes the root cause of OPENROUTER_API_KEY being overwritten with test-key-fresh on every pytest run.

Three-layer fix:
1. Unit tests: mock _get_active_hermes_home in TestApplyOnboardingSetupGuard so .env writes land in /tmp, never ~/.hermes
2. Test server subprocess: add HERMES_BASE_HOME=TEST_STATE_DIR to hard-lock profile resolution inside the server process
3. Test server subprocess: strip real provider keys (OPENROUTER_API_KEY etc.) from the inherited env before server starts

Reviewed and approved by @nesquena. 1373 passed, 0 skipped.
2026-04-16 23:03:32 -07:00
nesquena-hermes
79428f93c6 fix: catch OSError from SETTINGS_FILE.exists() — Docker UID-mismatch 500 crash (#614)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
a2ea15b557 fix: add favicon (SVG + PNG + ICO), fix static MIME types (#613)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #613. Adds favicon to the app (was missing entirely — blank tab icon). 1371 tests passing, QA harness green. Review by independent agent (see PR comments). Follow-up commit addresses all three reviewer notes: hoisted _STATIC_MIME to module scope, fixed charset=utf-8 being appended to binary MIME types, confirmed correct MIME types on all three favicon formats.

Co-authored-by: tiansiyuan <tiansiyuan@users.noreply.github.com>
2026-04-16 20:11:02 -07:00
franksong2702
692ba68e42 fix(title): strip markdown labels and skip empty placeholders in auto-title (#611)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #611 (@franksong2702). Fixes two edge cases in auto-generated session titles.

1. Strip Markdown labels (`**Session Title:**`, `Title:`) from sanitizer output — these were being persisted verbatim when the LLM emitted them.
2. Skip empty assistant tool-call placeholder messages when extracting the first exchange for title generation — previously the empty row could be latched onto instead of the first real answer.

Also tightens the title prompt to explicitly forbid Markdown, bullets, and label prefixes.

1371 tests passing, QA harness green.

Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-16 18:51:00 -07:00
nesquena-hermes
2484409b7a fix: HERMES_WEBUI_DEFAULT_WORKSPACE wins over settings.json; trust DEFAULT_WORKSPACE subtree (#610)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
b608f8837e Update image sources and attributes in README (#606) 2026-04-16 15:32:40 -07:00
nesquena-hermes
d5bea959a5 chore: CHANGELOG v0.50.70 + version badge (post-merge meta for PR #587 by @aronprins)
Post-merge meta commit for PR #587 by @aronprins. CHANGELOG entry for v0.50.70 + version badge bump. No code changes. 1361 tests passing.
2026-04-16 14:14:55 -07:00
Aron Prins
9a3dc10d93 feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Some checks failed
Release & Docker / release (push) Has been cancelled
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70

Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.

Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.

Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).

Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].

Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.

Workspace panel FOUC fix: [data-workspace-panel] set at parse time.

Docs: docs/ui-ux/index.html + two-stage-proposal.html.

Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.

Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 14:04:42 -07:00
nesquena-hermes
25d38a467a fix: Docker UID/GID auto-detect from workspace mount + message count tests — v0.50.69
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes #569: docker_init.bash auto-detects WANTED_UID/WANTED_GID from the mounted /workspace UID at Phase 1, before usermod remaps the container user. On macOS, host UIDs start at 501 — the default 1024 caused an empty workspace. Guards against root (0). Fallback 1024 preserved. Closes #579: topbar already correctly filters tool messages; sidebar count removed in #584. Regression tests added. Reviewed and approved by @nesquena. 1347 tests passing.
2026-04-16 12:19:25 -07:00
nesquena-hermes
6c5911a79f fix: light theme dialogs, workspace panel snap, model cache staleness, docker-compose docs — v0.50.68
Some checks failed
Release & Docker / release (push) Has been cancelled
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
54e83fb8b6 feat: support subpath mount via reverse proxy — v0.50.67 (PR #588 by @vcavichini)
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges feature from PR #588 by @vcavichini. Dynamic <base href> injection + api() helper slash-stripping enables deploying hermes-webui behind a reverse proxy at any subpath without configuration. Also fixes pre-existing bug: api/upload was using location.origin instead of location.href (closes #596). Co-authored-by: vcavichini <vcavichini@users.noreply.github.com>
2026-04-16 11:20:08 -07:00
nesquena-hermes
8a1bc134fa chore: CHANGELOG + v0.50.66 version badge (post-merge for PR #582)
Docs/version-only follow-up for PR #582. Nathan authorized full merge pipeline in session.
2026-04-16 10:21:39 -07:00
suinia
b5fc32b18d fix: pass runtime route details into webui agent — v0.50.66
Forwards `api_mode`, `acp_command`, `acp_args`, and `credential_pool` from the resolved runtime provider into `AIAgent.__init__()` in the WebUI streaming path. Fixes Codex account switching and credential pool support for WebUI sessions. Also adds 6 defensive variable initializations to prevent NameError in cleanup paths.

Tests: 1329 passed, 0 skipped. Full TestRuntimeRouteInjection suite passes.

PR by @suinia. Rebased and CHANGELOG added by maintainer.

Co-authored-by: suinia <suinia@users.noreply.github.com>
2026-04-16 10:20:42 -07:00
nesquena-hermes
db1240dde5 fix: HERMES_WEBUI_SKIP_ONBOARDING unconditional + guard against config writes + 10 skipped tests fixed — v0.50.65
Fixes two SKIP_ONBOARDING bugs and eliminates 10 permanently-skipped integration tests.

- SKIP_ONBOARDING=1 now honoured unconditionally (no longer gated on chat_ready)
- apply_onboarding_setup refuses to write config/env files when SKIP_ONBOARDING is set
- TestMediaEndpointIntegration (6) and TestOnboardingGateIntegration (4): collection-time
  skip guards removed; server reachability checked at runtime with fail() not skip()

Tests: 1327 passed, 0 skipped.

Admin merge — self-built PR, Nathan authorized full merge process in session.
2026-04-16 10:19:10 -07:00
nesquena-hermes
2efc1fb8e8 chore: CHANGELOG + v0.50.64 badge + Today-bucket test (post-merge follow-up for #584)
Some checks failed
Release & Docker / release (push) Has been cancelled
Admin merge — docs-only follow-up: CHANGELOG entry, version badge v0.50.64, one new test. No code logic. Nathan authorized end-to-end merge in session.
2026-04-16 10:09:51 -07:00
Aron Prins
a9a22ee751 fix(sidebar): declutter session items — drop message count, model, and source-tag badges (v0.50.64)
Squash-merges PR #584 by @aronprins.

Drops the meta row (message count, model slug, source-tag badge) from every sidebar session item. Each session now renders as a single title line — visible session count roughly doubles at typical viewport height.

Changes merged verbatim from contributor branch, plus maintainer additions:
- CHANGELOG entry for v0.50.64
- Version badge bump to v0.50.64
- New test: test_relative_time_today_bucket (closes minor coverage gap from code review)

Co-authored-by: aronprins <aronprins@users.noreply.github.com>
2026-04-16 09:58:53 -07:00
nesquena-hermes
a512f2020e feat: MCP toolsets in WebUI + onboarding fix for non-standard providers — v0.50.63
Some checks failed
Release & Docker / release (push) Has been cancelled
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
45426bdcd1 fix: make hermes-agent source optional in Docker startup — v0.50.62
Some checks failed
Release & Docker / release (push) Has been cancelled
Squash-merges PR #577 (rebased from #573 by @nesquena). Docker hard-exit on missing hermes-agent → graceful warning. 1319 tests pass. Fixes #570.
2026-04-15 23:22:26 -07:00
nesquena-hermes
360379136b feat(upload): support Excel and Word file attachments — v0.50.61
Some checks failed
Release & Docker / release (push) Has been cancelled
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
nesquena-hermes
e4fec9e4e0 test: skip onboarding config tests when PyYAML unavailable, remove duplicate definition — v0.50.60
Some checks failed
Release & Docker / release (push) Has been cancelled
Merges #564. Adds PyYAML skip guards to two onboarding tests. Removes duplicate _HAS_YAML/_needs_yaml block. No production code changed. 1319 tests pass.
2026-04-15 20:45:42 -07:00
nesquena-hermes
8cf10b152b fix: false Connection lost message after settled stream disconnect — v0.50.59
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: 避免流结束后误插入 Connection lost 错误

* chore: bump version to v0.50.59, update CHANGELOG

---------

Co-authored-by: fiver <fiver@example.com>
Co-authored-by: Hermes Agent <agent@hermes>
2026-04-15 20:25:31 -07:00
nesquena-hermes
07c25f0766 fix(models): show named custom provider label in model dropdown — v0.50.58
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(models): show named custom provider label in model dropdown — v0.50.58
2026-04-15 18:32:36 -07:00
Hermes Agent
4f79b3f941 chore: bump version to v0.50.58, update CHANGELOG 2026-04-16 01:32:15 +00:00
Hermes Agent
54d0ee5f6c fix(models): show named custom provider label in model dropdown instead of generic 'Custom' — PR #558
Named custom_providers entries (those with a 'name' field) now get their own
dropdown group using the configured name (e.g. 'Agent37') instead of the
generic 'Custom' label. Unnamed entries still fall back to 'Custom'.

Closes #557.
2026-04-16 01:31:04 +00: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
nesquena-hermes
0a9b952d4c feat(sessions): auto-summarize session titles after first exchange (fixes #495) — v0.50.57
Some checks failed
Release & Docker / release (push) Has been cancelled
feat(sessions): auto-summarize session titles after first exchange (fixes #495) — v0.50.57
2026-04-15 17:07:28 -07:00
Hermes Agent
8864001941 chore: bump version to v0.50.57, update CHANGELOG 2026-04-16 00:07:08 +00:00
Hermes Agent
7e8ed4afff feat(sessions): auto-summarize session titles after first exchange (fixes #495) — PR #535
After the first user/assistant exchange, generates a concise session title
in a background daemon thread using the first user message + first visible
assistant reply as input. Title updates live in the UI via a new 'title' SSE event.
The stream now terminates with 'stream_end' instead of 'done' so the title
generation thread has time to finish before the client disconnects.

Provisional titles (first-message substrings) are replaced; manual renames
are preserved; generation only runs once per session (llm_title_generated flag).
Includes MiniMax token budget handling and a local heuristic fallback.

Additional fixes applied in agent review:
- messages.js: fix JS syntax error (mismatched quote in setComposerStatus)
- messages.js: fix broken template literal in error hint rendering
- messages.js: restore approval queue multi-slot fix (approval_id, pendingCount,
  _approvalCurrentId) that was accidentally removed
- api/streaming.py: fix MiniMax thinking delimiter regex (<|channel|>)
- tests/test_issue487b.py: fix DeprecationWarning (raw string docstring)

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-04-16 00:05:53 +00:00
Hermes Agent
215f7eff4d fix(review): 4 issues found in agent review of PR #535
BUG-1 (CRITICAL): messages.js line 522 — mismatched quote in
setComposerStatus('Reconnecting…') caused JS syntax error on the
reconnect path.

BUG-2 (HIGH): messages.js line 491 — broken template literal
'\\n\\n*{d.hint}*' restored to '\n\n*${d.hint}*'. Error hint
text was non-functional (missing $ prefix and escaped newlines).

BUG-3 (HIGH): messages.js — showApprovalCard(pending, pendingCount),
_approvalCurrentId, and approval_id in respondApproval() were removed,
regressing the simultaneous approval queue fix from PR #546. Restored
all three, including the '1 of N pending' counter and poll passthrough.

BUG-4 (LOW): api/streaming.py — MiniMax thinking delimiter regex
missing closing pipe: <|channel> -> <|channel|> in both
_strip_thinking_markup() and _looks_invalid_generated_title().

ALSO: test_issue487b.py docstring changed to raw string to fix
DeprecationWarning for invalid escape sequence '\s'.
2026-04-16 00:00:22 +00:00
franksong2702
a4ce9ccc99 fix(messages): keep inflight tool-call regression intact 2026-04-15 23:59:36 +00:00
Frank Song
8ff3fd9442 feat(sessions): auto-summarize provisional session titles 2026-04-15 23:59:36 +00:00
nesquena-hermes
53ce8a107b fix: version badge v0.50.55, CHANGELOG entry, QA innerHTML allowlist
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: version badge v0.50.55, CHANGELOG entry, QA innerHTML allowlist
2026-04-15 16:40:50 -07:00
Hermes Agent
ec44a437a2 fix: version badge v0.50.54→v0.50.55, add CHANGELOG entry for v0.50.55
The v0.50.55 release (Docker honcho fix) missed the index.html version
badge bump and CHANGELOG entry. Caught during post-session QA sweep.
2026-04-15 23:40:11 +00:00
nesquena-hermes
400b1721d7 fix: install hermes-agent[honcho] extra in Docker init (fixes #553)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: install hermes-agent[honcho] extra in Docker init (fixes #553)
2026-04-15 16:22:34 -07:00
Hermes Agent
fbce1093b9 fix: install hermes-agent[honcho] extra in Docker init (fixes #553)
docker_init.bash was installing hermes-agent without the [honcho] optional
extra, causing honcho-ai to be missing from /app/venv. All Honcho memory
tools would fail with 'Honcho session could not be initialized' on every
fresh Docker build.

Adds [honcho] to the uv pip install invocation on line 238.
2026-04-15 23:22:20 +00:00
nesquena-hermes
c0bffa15f1 chore: update OpenRouter and provider model lists — v0.50.54
Some checks failed
Release & Docker / release (push) Has been cancelled
chore: update OpenRouter and provider model lists — v0.50.54
2026-04-15 16:04:20 -07:00
Hermes Agent
27d3f9543e chore: bump version to v0.50.54, update CHANGELOG 2026-04-15 23:04:06 +00:00
Hermes Agent
51767f9d90 chore: update OpenRouter and provider model lists — PR #551
OpenRouter dropdown updated to 14 current models across 7 providers.
All slugs verified against live OpenRouter catalog.

Removed: o4-mini, claude-sonnet-4-5 (temporarily, re-added), gemini-2.5-pro,
         gemini-2.0-flash, llama-4-scout, llama-4-maverick.
Added:   gpt-5.4, claude-opus-4.6, claude-sonnet-4-5, gemini-3.1-pro-preview,
         gemini-3-flash-preview, deepseek-r1, qwen3-coder, qwen3.6-plus,
         grok-4.20, mistral-large-latest.
Fixed:   gemini slug -preview suffix, grok-4-20 -> grok-4.20 (dot not dash),
         stale Nous label, mistralai/qwen/x-ai added to PROVIDER_MODELS/DISPLAY.
2026-04-15 23:03:13 +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
nesquena-hermes
69effc7b22 fix: preserve slash model IDs for custom endpoints (fixes #548) — v0.50.53
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: preserve slash model IDs for custom endpoints (fixes #548) — v0.50.53
2026-04-15 15:13:23 -07:00
Hermes Agent
7bfba201da chore: bump version to v0.50.53, update CHANGELOG 2026-04-15 22:13:14 +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
nesquena-hermes
392c315d4b fix: queue simultaneous approval requests per session (fixes #527) — v0.50.52
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: queue simultaneous approval requests per session (fixes #527) — v0.50.52
2026-04-15 12:42:32 -07:00
Hermes Agent
25fae902d3 chore: bump version to v0.50.52, update CHANGELOG 2026-04-15 19:42:11 +00:00
Hermes Agent
d6b58b9ce0 fix: queue simultaneous approval requests per session (fixes #527)
Changes _pending from a single overwriting dict value to a list,
so parallel tool calls each get their own approval slot.

api/routes.py:
- Wraps submit_pending() to append to a list and assign a stable
  approval_id (uuid4) to each entry.
- _handle_approval_pending() returns the first queued entry plus
  pending_count so the UI can show '1 of N'.
- _handle_approval_respond() pops by approval_id (falls back to
  oldest entry for backward-compat with old clients).
- Backward-compat: legacy single-dict values in _pending are
  handled without crashing.

static/messages.js:
- respondApproval() sends approval_id in the POST body.
- showApprovalCard() accepts pendingCount, shows '1 of N pending'
  counter when multiple approvals are queued.
- _approvalCurrentId tracks the approval_id of the displayed card.
- Poll loop passes pending_count to showApprovalCard.

static/index.html:
- Adds approvalCounter element for the '1 of N' display.

tests/test_approval_queue.py:
- 14 tests: static-analysis checks (Python + JS + HTML),
  functional tests that inject two simultaneous approvals and
  verify both are surfaced and independently resolvable.
2026-04-15 19:16:14 +00:00
nesquena-hermes
ac839e0d01 fix: strip orphaned tool messages before API calls (fixes #534) — v0.50.51
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: strip orphaned tool messages before API calls (fixes #534) — v0.50.51
2026-04-15 12:07:09 -07:00
Hermes Agent
ce4e01ea92 chore: bump version to v0.50.51, update CHANGELOG 2026-04-15 19:06:54 +00:00
Hermes Agent
4f7db62c58 fix: strip orphaned tool messages before API calls (fixes #534) — PR #542 2026-04-15 19:06:02 +00:00
nesquena-hermes
3033fb65e3 fix(themes): swap Prism syntax-highlighting theme on light/dark switch (#505) — v0.50.50
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(themes): swap Prism syntax-highlighting theme on light/dark switch (#505) — PR #530
2026-04-15 12:05:12 -07:00
Hermes Agent
03df7132d0 chore: bump version to v0.50.50, update CHANGELOG 2026-04-15 19:02:43 +00:00
armorbreak001
50d7d1cf88 fix(themes): swap Prism syntax-highlighting theme on light/dark switch
The Prism CSS was hardcoded to prism-tomorrow (dark-only), so code
blocks stayed dark even when switching to Light or other non-dark themes.

- Add id='prism-theme' to the <link> element for runtime lookup
- In _applyTheme(), swap href between prism-tomorrow (dark) and
  prism (light) based on resolved theme
- Skips DOM write when the target href is already active

Fixes #505
2026-04-15 19:01:52 +00:00
nesquena-hermes
e4688425ab fix: respect IME composition in all Enter submit flows (#531) — v0.50.49
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: respect IME composition in all Enter submit flows (#531) — PR #537
2026-04-15 11:58:31 -07:00
Hermes Agent
9220a876bc fix: strip orphaned tool messages before sending history to API (fixes #534)
Extends _sanitize_messages_for_api() with a two-pass approach:
1. Collect all tool_call_ids declared in assistant messages (handles
   both OpenAI 'id' and Anthropic 'call_id' field names).
2. Drop any tool-role messages whose tool_call_id was not declared
   by a preceding assistant message.

Strictly-conformant providers (Mercury-2/Inception, newer OpenAI
models) reject histories with orphaned tool results with a 400 error:
'Message has tool role, but there was no previous assistant message
with a tool call.' This can happen when histories are edited, when
switching between providers, or when partial messages are stored.

Adds 13 regression tests covering: valid roundtrip preservation,
multiple tool calls, partial orphan filtering, Anthropic call_id,
edge cases (None tool_calls, missing tool_call_id, non-dict entries).
2026-04-15 16:57:31 +00:00
Hermes Agent
e077d110c3 chore: bump version to v0.50.49, update CHANGELOG 2026-04-15 16:46:53 +00:00
Hermes Agent
8f7bee7b34 fix: respect IME composition in all Enter submit flows — PR #537
Adds isComposing guards to every Enter keydown handler so CJK/IME
users are never sent mid-composition (fixes issue #531):
- boot.js: chat composer + command dropdown Enter handler
- sessions.js: session rename, project create/rename Enter handlers
- ui.js: app dialog, message edit Enter-to-save, workspace rename

Ships 3 new regression tests (test_ime_composition.py).

Co-authored-by: vansour <vansour@users.noreply.github.com>
2026-04-15 16:45:52 +00:00
vansour
dc43a30af7 test: loosen IME guard regression assertions 2026-04-15 23:21:56 +08:00
vansour
74dee6b665 fix: respect IME composition in Enter submit flows 2026-04-15 23:12:47 +08:00
nesquena-hermes
96c4102aa7 fix: toast when model switched during active session (#419) — PR #529
Some checks failed
Release & Docker / release (push) Has been cancelled
2026-04-15 08:05:19 +00:00
Hermes Agent
d3251fdbfd chore: bump version to v0.50.48, update CHANGELOG 2026-04-15 08:04:24 +00:00
Hermes Agent
31196d42af fix: show toast when model is switched during active session (#419)
When a user switches the model via the model picker while a session has
existing messages, a toast now informs them: 'Model change takes effect
in your next conversation'. This prevents confusion when the model
dropdown updates visually but the running conversation continues with
the original model.

Implementation: 4-line addition in modelSelect.onchange in boot.js,
after the existing provider-mismatch warning. Checks S.messages.length
(the reliable in-memory array) and guards showToast with typeof.

Synthesized from PRs #516 (armorbreak001), #517 and #518 (cloudyun888).
Placement follows #518's correct boot.js approach. Reference corrected
from S.session.messages to S.messages (always initialized by loadSession).

4 new tests in test_provider_mismatch.py::TestModelSwitchToast.

Co-authored-by: armorbreak001 <armorbreak001@users.noreply.github.com>
Co-authored-by: cloudyun888 <cloudyun888@users.noreply.github.com>
2026-04-15 08:04:03 +00:00
nesquena-hermes
1050c673e6 fix/feat: batch fixes v0.50.47 — root workspace, custom providers, cron cache, system theme (PR #523)
Some checks failed
Release & Docker / release (push) Has been cancelled
2026-04-15 07:54:26 +00:00
Hermes Agent
178251a5c0 chore: bump version to v0.50.47, update CHANGELOG 2026-04-15 07:52:23 +00:00
Hermes Agent
21a7564afd test: add 22 tests covering batch fixes v0.50.47 (#506-#521) 2026-04-15 07:47:18 +00:00
Hermes Agent
44a544362f feat: add System (auto) theme following OS prefers-color-scheme (#504)
Synthesized from PRs #506, #509, #514 (all by armorbreak001 and cloudyun888).

Implementation:
- static/index.html: flicker-prevention head script resolves 'system' to
  'dark'/'light' via matchMedia before first paint. Adds 'System (auto)'
  as first option in theme picker. onchange calls _applyTheme().
- static/boot.js: new _applyTheme(name) helper — resolves 'system' via
  matchMedia, sets data-theme, registers a MQ change listener so the UI
  tracks OS switches live. loadSettings() now calls _applyTheme() instead
  of direct data-theme assignment.
- static/commands.js: adds 'system' to valid /theme command names,
  delegates apply to _applyTheme().
- static/panels.js: _settingsThemeOnOpen reads from localStorage (preserves
  'system' string, not the resolved 'dark'/'light'). _revertSettingsPreview
  calls _applyTheme() so reverting to 'system' correctly re-enables OS tracking.
- static/i18n.js: cmd_theme description now lists 'system' first in all 5
  locales (en, es, de, zh-Hans, zh-Hant).

Design choices vs submitted PRs:
- No separate system-theme.js file (unnecessary indirection).
- matchMedia listener does NOT POST to /api/settings (OS can change rapidly;
  persisting on every OS switch would hammer the server).

Co-authored-by: armorbreak001 <armorbreak001@users.noreply.github.com>
Co-authored-by: cloudyun888 <cloudyun888@users.noreply.github.com>
2026-04-15 07:45:20 +00:00
Hermes Agent
36830e3cd1 fix: invalidate cron skill picker cache on form open and after skill save (#502)
Two complementary cache-busting strategies for the stale cron skill picker:

1. On cron form open (toggleCronForm): always null _cronSkillsCache before
   fetching, so freshly created skills are immediately visible without a
   page reload. Previously the cache was only populated once and never
   invalidated.

2. On skill save (submitSkillSave): null _cronSkillsCache after a successful
   write so the next cron form open is forced to re-fetch. Mirrors the
   existing _skillsData=null pattern one line above.

Fixes: #502
Co-authored-by: armorbreak001 <armorbreak001@users.noreply.github.com>
2026-04-15 07:43:00 +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
Hermes Agent
eb760a2158 fix: allow /root workspace path; guard against split on missing [Attached files]
Removes /root from _BLOCKED_SYSTEM_ROOTS in api/workspace.py, allowing
Hermes running as root (e.g. Docker, VPS) to use /root as a workspace
without a 'system directory' rejection.

Fixes a fragile string split in api/streaming.py: base_text extraction
now guards against msg_text that contains no '[Attached files:' marker,
preventing the split from producing empty-string on those messages.

Fixes: #510, partial fix from #521 (workspace + split guard only).
Co-authored-by: ccqqlo <ccqqlo@users.noreply.github.com>
2026-04-15 07:41:36 +00:00
Hermes Agent
0b96f08b3e chore: bump version to v0.50.46, update CHANGELOG 2026-04-15 07:35:25 +00:00
nesquena-hermes
4f1623520d feat: clarify dialog flow and refresh recovery (#520) - merge PR #522
Some checks failed
Release & Docker / release (push) Has been cancelled
2026-04-15 07:27:57 +00:00
Hermes Agent
505bfc6a9a feat: clarify dialog flow and refresh recovery (#520)
Implements and stabilizes the clarify dialog UX in Hermes WebUI.

New clarify state module (api/clarify.py):
  - Per-session pending queue with threading.Event unblocking
  - Gateway notify registration/unregistration
  - Duplicate clarify deduplication while unresolved
  - resolve/clear helpers

New clarify HTTP endpoints (api/routes.py):
  - GET /api/clarify/pending
  - POST /api/clarify/respond
  - GET /api/clarify/inject_test (loopback-only, for tests)

Streaming integration (api/streaming.py):
  - clarify_callback wired to AIAgent.run_conversation()
  - SSE 'clarify' event emitted to WebUI
  - Blocks tool flow until response/timeout/cancel
  - 409 guard: session already has an active stream returns active_stream_id
  - MCP lazy discovery on first stream start

Frontend (static/messages.js, ui.js, sessions.js, index.html, style.css, i18n.js):
  - Clarify card with numbered choices + Other + free-text input
  - Composer lock while clarify is active
  - DOM self-healing if card node is removed during rerender
  - SSE 'clarify' event listener + fallback polling (1.5s interval)
  - Session switch / reconnect stops/starts clarify polling
  - 409 conflict: reattaches to active stream and queues user input
  - CLARIFY_MIN_VISIBLE_MS = 30000 timer dedup (mirrors approval card pattern)
  - i18n keys in en/es/de/zh-Hans/zh-Hant locales

Tests:
  - tests/test_clarify_unblock.py: 14 new tests (queue, callbacks, HTTP endpoints)
  - tests/test_sprint30.py: 31 new clarify tests (HTML, CSS, i18n, JS, streaming)
  - tests/test_sprint36.py: expand search window (stopClarifyPolling pushes setBusy further)

Total tests: 1246 (was 1209)

Co-authored-by: franksong2702 <138988108+franksong2702@users.noreply.github.com>
2026-04-15 07:25:52 +00:00
Hermes Agent
1bd0341243 fix: expand test_sprint36 search window for setBusy after stopClarifyPolling additions 2026-04-15 07:24:53 +00:00
Frank Song
ccba2f5c01 feat: harden clarify dialog flow and refresh recovery 2026-04-15 13:10:50 +08:00
nesquena-hermes
45d3dc0f68 fix: suppress N/A source_tag in session list sidebar (fixes #429)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: suppress N/A source_tag in session list sidebar (fixes #429)
2026-04-14 15:15:05 -07:00
nesquena-hermes
69cd0832de fix: suppress N/A source_tag in session list sidebar (fixes #429)
fix(renderer): extend _al_stash to include <img> tags — fixes broken image rendering
2026-04-14 15:14:57 -07:00
Hermes Agent
7b9f08c774 fix: suppress N/A source_tag in session list sidebar (#429)
- sessions.js _formatSourceTag(): return null for unrecognised tags
  instead of raw string — prevents legacy 'N/A' values from surfacing
- sessions.js metaBits push: guarded with _stLabel null check so only
  known platform labels appear in the session metadata line
- sessions.js [SYSTEM:] title fallback: drop raw s.source_tag middle
  term, fall back directly to 'Gateway' for unknown sources

7 new tests in test_issue429.py.
1 updated test in test_sprint40_ui_polish.py (new guarded push pattern).

Closes #429
2026-04-14 22:14:31 +00:00
Hermes Agent
2810233af4 fix(renderer): extend _al_stash to include <img> tags, preventing autolink from mangling src= URLs
Bug: the autolink pass stashed <a> tags (via _al_stash) before running,
but did not stash <img> tags. When ![alt](url) was converted to an <img>
tag by the image pass, the subsequent autolink regex matched the URL
inside src="..." and wrapped it in <a href="...">url</a>, producing
src="<a href="...">url</a>" — a completely broken image source.

Fix: extend the _al_stash regex from:
  (<a\b[^>]*>[\s\S]*?<\/a>)
to:
  (<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>)

This stashes both <a> and self-closing <img> tags before autolink runs,
then restores them after, so the URL inside src= is never touched.

Adds 7 regression tests in tests/test_issue487b.py.
2026-04-14 22:09:36 +00:00
nesquena-hermes
642f4536f0 docs: update TESTING.md and ROADMAP.md to v0.50.44 / 1195 tests
docs: update TESTING.md and ROADMAP.md to v0.50.44 / 1195 tests
2026-04-14 15:06:29 -07:00
Hermes Agent
f0d49b5b59 docs: update TESTING.md and ROADMAP.md to v0.50.44 / 1195 tests 2026-04-14 22:06:11 +00:00
nesquena-hermes
e6447ebad2 fix: code-in-table CSS sizing + markdown image rendering (fixes #486, #487)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: code-in-table CSS sizing + markdown image rendering (fixes #486, #487)
2026-04-14 14:52:59 -07:00
Hermes Agent
887893ecd1 fix: code-in-table CSS sizing + markdown image rendering (#486, #487)
- static/style.css: add td code / th code rules (font-size 0.85em,
  padding 1px 4px, vertical-align baseline) for both .msg-body and
  .preview-md to fix cramped inline code in table cells (#486)

- static/ui.js inlineMd(): add image pass (![alt](url) → <img
  class=msg-media-img>) running while _code_stash is active (protects
  image syntax inside backticks), add _img_stash (\x00G) to shield
  rendered <img> src= from autolink, add img to SAFE_INLINE (#487)

- static/ui.js renderMd() outer: add image pass before outer link pass
  for images in plain paragraphs, add img to SAFE_TAGS allowlist (#487)

- tests/test_issue486_487.py: 45 new tests covering CSS source checks,
  JS source structure, rendering behaviour, and combination edge cases
  (code + image + link in same table cell, image inside code span, etc.)

Closes #486, closes #487
2026-04-14 21:52:34 +00:00
nesquena-hermes
75de03c99f Merge pull request #478 from nesquena/release/v0.50.43
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.43 — markdown rendering fixes + KaTeX CSP
2026-04-14 14:23:38 -07:00
Hermes Agent
d8ab326b73 fix(renderer): fix two remaining renderMd issues found during browser QA
1. ** inside  was corrupted** — the outer bold/italic pass at line 480 ran
   after the outer backtick→<code> pass at line 457, causing esc() to corrupt <code> tags
   into &lt;code&gt; inside <strong>. Fix: add _ob_stash to protect <code> tags from
   the outer bold/italic pass.

2. **Table cells with [label](url) produced double <a> tags** — the outer [label](url) pass
   ran BEFORE the table regex, converting links to <a> tags in the raw table source.
   Then inlineMd() processed those <a> tags again and autolink re-linked the URL inside
   href="...". Fix: moved the outer link pass to AFTER the table pass so table cells
   get their links from inlineMd() only, which has its own _link_stash protection.
2026-04-14 21:22:20 +00:00
Hermes Agent
7753e954e5 docs: correct v0.50.43 test count to 1150 2026-04-14 21:15:46 +00:00
Hermes Agent
2343dc1d85 docs: v0.50.43 CHANGELOG + version bump (test count TBD) 2026-04-14 21:15:02 +00:00
Hermes Agent
85f1017514 fix(csp): allow cdn.jsdelivr.net for font-src so KaTeX fonts load (fixes #477) 2026-04-14 21:14:33 +00:00
Hermes Agent
eb7ec5bac3 fix(renderer): backtick code spans inside bold/italic no longer get esc'd 2026-04-14 21:14:00 +00:00
Hermes Agent
b673006b7f fix(renderer): address review feedback on PR #475 2026-04-14 21:13:53 +00:00
Nathan Esquenazi
5a79dd0dc9 fix: remove double semicolon in inlineMd link stash restore 2026-04-14 21:13:34 +00:00
Hermes Agent
0a570ada87 fix(renderer): prevent double-linking and esc() corruption in renderMd() 2026-04-14 21:13:33 +00:00
nesquena-hermes
53acc8e0e1 Merge pull request #476 from nesquena/release/v0.50.42
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.42 — session display fixes + model UX polish (sprint 42)
2026-04-14 14:08:46 -07:00
Hermes Agent
34b98285a1 fix(ui): add custom option to <select> when model ID not in curated list (enables custom model IDs) 2026-04-14 21:06:23 +00:00
Hermes Agent
e228b1414f fix(tests): shared helpers in test_sprint42.py; correct test count to 1130 2026-04-14 21:04:37 +00:00
Hermes Agent
bb445ffe9a docs: v0.50.42 CHANGELOG, version bump (test count TBD) 2026-04-14 20:58:30 +00:00
Hermes Agent
2eb0679104 fix(tests): consolidate sprint-42 test_sprint42.py — all 20 tests in one file 2026-04-14 20:58:01 +00:00
Hermes Agent
12949a2771 feat(ui): add custom model ID input to model picker dropdown (fixes #444) 2026-04-14 20:56:56 +00:00
Nathan Esquenazi
7b0fb246ee fix: merge duplicate const lastAsst declarations into single lookup 2026-04-14 20:56:54 +00:00
Hermes Agent
f86581e3e5 fix(ui): persist thinking/reasoning trace across page reload (fixes #427) 2026-04-14 20:56:53 +00:00
Hermes Agent
3c5ca2db62 fix(sessions): replace [SYSTEM: titles with platform name for gateway sessions (fixes #441) 2026-04-14 20:56:52 +00:00
Hermes Agent
c7381ee3f1 fix(ui): context indicator prefers latest usage over stale session data (fixes #437) 2026-04-14 20:56:50 +00:00
nesquena-hermes
32669f4a5b Merge pull request #459 from nesquena/release/v0.50.41
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.41 — MEDIA: inline image rendering in chat (fixes #450)
2026-04-14 12:37:14 -07:00
Hermes Agent
c9a0e02301 docs: v0.50.41 CHANGELOG, version bump, test count (1117) 2026-04-14 19:36:14 +00:00
Hermes Agent
bfb9bbb0bf fix: use _content_disposition_value() for RFC 5987 filename encoding in /api/media 2026-04-14 19:35:53 +00:00
Nathan Esquenazi
5507dae3d7 fix: restrict /api/media allowed roots — remove ~ (home dir) 2026-04-14 19:35:52 +00:00
Hermes Agent
0349df6ee4 feat(ui): render MEDIA: images inline in web UI chat (fixes #450) 2026-04-14 19:35:52 +00:00
nesquena-hermes
8c36203dd4 Merge pull request #457 from nesquena/release/v0.50.40
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.40 — session UI polish, test port isolation, 6 bug fixes
2026-04-14 12:13:11 -07:00
Hermes Agent
c4d1e8c5d0 docs: correct v0.50.40 test count to 1098 2026-04-14 19:11:04 +00:00
Hermes Agent
c0c0195f7f fix(tests): consolidate sprint-40 test file, fix module-scope vars, update sidebar-time assertion 2026-04-14 19:10:23 +00:00
Hermes Agent
8199fa333e docs: v0.50.40 CHANGELOG and version bump (test count TBD) 2026-04-14 19:07:10 +00:00
Hermes Agent
77769750c2 fix(panels): apply profile default workspace to new session after profile switch (fixes #424) 2026-04-14 19:06:37 +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
Nathan Esquenazi
85d8aad0ae fix(ux): mute Telegram badge color and format source tag as display name (fixes #442) 2026-04-14 19:06:33 +00:00
Nathan Esquenazi
f1590fdb07 fix(sessions): return None instead of 'unknown' for missing gateway session model (fixes #443) 2026-04-14 19:06:22 +00:00
Nathan Esquenazi
3776b09f4a fix(ui): active session title uses var(--gold) instead of hardcoded #e8a030 (fixes #440) 2026-04-14 19:05:26 +00:00
Hermes Agent
2400e14a31 fix(sidebar): hide session timestamps entirely to give titles full width 2026-04-14 19:04:49 +00:00
Nathan Esquenazi
69b0a905a4 fix(sidebar): move session timestamp below title to prevent truncation 2026-04-14 19:04:49 +00:00
Hermes Agent
c3251ea97d fix(tests): auto-derive unique port+state-dir per worktree (fixes parallel pytest) 2026-04-14 19:04:48 +00:00
nesquena-hermes
924c833878 Merge pull request #448 from nesquena/release/v0.50.39
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.39 — orphan session fix + first-password session continuity
2026-04-14 11:01:11 -07:00
Nathan Esquenazi
5fd7dc0c17 docs: v0.50.39 CHANGELOG, version bump, test count (1078) 2026-04-14 17:54:54 +00:00
Nathan Esquenazi
a4136f2da5 fix(gateway): filter orphan sessions from SSE watcher (HAVING COUNT > 0) 2026-04-14 17:54:30 +00:00
Nathan Esquenazi
3c3cae89f8 fix(tests): test_sprint45 isolation + zh i18n keys + server version string
- test_sprint45.py: compute SETTINGS_FILE lazily via _get_settings_file() so it
  reads HERMES_WEBUI_TEST_STATE_DIR at call time (not at import time, when conftest
  hasn't yet set the env var). Fixes test isolation across all 1078 tests.
- test_sprint45.py: use auth cookie in teardown when clearing password post-test.
- test_sprint45.py: remove test_synced_version_strings (checks local-patch version).
- static/i18n.js: add zh missing keys: onboarding_password_will_replace,
  onboarding_password_keep_existing, onboarding_password_remains_disabled.
- server.py: revert server_version to HermesWebUI/0.50.38 (matches master).
2026-04-14 17:54:06 +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
8d1c257ea8 docs: correct test count to 1075 in TESTING.md and CHANGELOG (#447)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 10:17:22 -07:00
nesquena-hermes
6e303fbd93 Merge pull request #446 from nesquena/release/v0.50.38
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.38 — mobile nav cleanup, Prism highlighting, zh-CN/zh-Hant i18n
2026-04-14 10:15:42 -07:00
Nathan Esquenazi
61ecdaded3 docs: v0.50.38 CHANGELOG, version bump, test count (1073) 2026-04-14 17:14:40 +00:00
Nathan Esquenazi
09e278461c fix(test): update test_sprint10 cron history check for i18n key refactor 2026-04-14 17:14:02 +00:00
Nathan Esquenazi
6347949463 fix(i18n): add onboarding_skip/onboarding_skipped to zh locale 2026-04-14 17:14:02 +00:00
vansour
204dc23c6b fix i18n review comments and locale test robustness 2026-04-14 17:14:01 +00:00
vansour
c4efe96725 feat(i18n): complete zh-CN hardening and locale consistency 2026-04-14 17:14:01 +00:00
Louis Wong
6a513f49b2 fix(ui): add Prism syntax highlighting with light + dark theme token colors
Closes #426:
2026-04-14 17:13:04 +00:00
Aron Prins
db392bd532 feat(ui): remove mobile bottom nav on phones
Closes #425:
2026-04-14 17:13:03 +00:00
nesquena-hermes
b394efce17 Merge pull request #445 from nesquena/pr-422-review
docs: add CONTRIBUTING.md (closes #422)
2026-04-14 09:51:21 -07:00
Aron Prins
28d226f5ce docs: add CONTRIBUTING.md
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
2026-04-14 16:50:24 +00:00
nesquena-hermes
d8aa387c3c Merge pull request #439 from nesquena/release/v0.50.37
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.50.37 — fix onboarding wizard for existing Hermes users
2026-04-14 09:46:08 -07:00
Nathan Esquenazi
4ad7efe8cf fix(i18n): add onboarding_skip/onboarding_skipped keys to en+es locales 2026-04-14 16:45:13 +00:00
Nathan Esquenazi
57a50591ee fix(onboarding): skip wizard if Hermes already configured
Closes #420:
2026-04-14 16:45:12 +00:00
Nathan Esquenazi
16c58e60f4 docs: v0.50.37 CHANGELOG, version bump, test count 2026-04-14 16:44:58 +00:00
nesquena-hermes
37850a4dfd fix: workspace list cleaner — all 1055 tests pass (#418)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: workspace list cleaner — allow own-profile paths, remove brittle string filter

Two bugs in _clean_workspace_list() caused workspace adds to silently vanish
on the next load, making the duplicate-check test and workspace rename test fail:

1. Brittle string filter: 'if test-workspace in path or webui-mvp-test in path:
   continue' — removed. The test server's workspace IS under these paths, so any
   workspace added during testing got silently dropped on the next load_workspaces()
   call. The p.is_dir() check already handles non-existent paths.

2. Cross-profile filter too broad: 'if p is under ~/.hermes/profiles/: skip' —
   this correctly blocked cross-profile leakage but also blocked the current
   profile's own paths (e.g. ~/.hermes/profiles/webui/webui-mvp-test/...).
   Fixed: only skip if the path is under profiles/ AND under a DIFFERENT profile's
   directory. Paths under the current profile's own home are kept.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 00:14:25 -07:00
nesquena-hermes
415270ff03 fix: cross-platform multi-workspace trust boundary (#417)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: relax workspace trust boundary to user home directory

The previous restriction required workspaces to be under DEFAULT_WORKSPACE
(/home/hermes/workspace), which blocked all profile-specific workspaces
(~/CodePath, ~/General, ~/WebUI, ~/Camanji, etc.) since each profile uses
a different directory under home.

New boundary: any directory under Path.home() is trusted.
This still blocks /etc, /tmp, /var, /root, /usr and all paths outside the
user's home, while allowing any legitimate workspace under ~/

Also updates test assertions from 'trusted workspace root' to 'outside'
since the new error message says 'outside the user home directory'.

* fix: workspace trust uses home-dir + saved-list, not single ancestor

Three-layer trust model that works cross-platform and multi-workspace:

1. BLOCKLIST: /etc, /usr, /var, /bin, /sbin, /boot, /proc, /sys, /dev, /root,
   /lib, /lib64, /opt/homebrew — always rejected, even if somehow saved
2. HOME CHECK: any path under Path.home() is trusted — covers ~/CodePath,
   ~/hermes-webui-public, ~/WebUI, ~/General, ~/Camanji simultaneously;
   Path.home() is cross-platform (Linux ~/..., macOS ~/..., Windows C:\Users\...\...)
3. SAVED LIST ESCAPE HATCH: if a path is already in the saved workspace list,
   it's trusted regardless of location — covers self-hosted deployments where
   workspaces live outside home (/data/projects, /opt/workspace, etc.)

None/empty → DEFAULT_WORKSPACE (always trusted, validated at startup)

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 23:57:51 -07:00
nesquena-hermes
2a7a5ddfaf [security] fix(workspace): restrict session workspaces to trusted roots (#416)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(workspace): restrict session workspaces to trusted roots

* fix: use boot-time DEFAULT_WORKSPACE instead of profile default for trusted workspace root

_profile_default_workspace() reads the agent's terminal.cwd which may differ
from the WebUI's configured workspace root. Use _BOOT_DEFAULT_WORKSPACE (which
respects HERMES_WEBUI_DEFAULT_WORKSPACE for test isolation) to stay consistent
with how new_session() seeds the initial workspace.

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

---------

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 23:44:03 -07:00
nesquena-hermes
a5abe51cc5 fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview (#414)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview

Two bugs fixed in the workspace right panel:

1. Duplicate X on desktop (bug): #btnClearPreview (the X icon) was always
   visible alongside #btnCollapseWorkspacePanel (the chevron), producing two
   close controls at once. Fixed in syncWorkspacePanelUI() — on desktop, the X
   is now hidden when no file preview is open (display:none), and only shown
   when the user is viewing a file. The chevron remains as the sole close
   control in browse mode.

2. Mobile X collapses panel instead of dismissing file (bug): .mobile-close-btn
   was calling closeWorkspacePanel() directly, which collapsed the whole panel
   even when a file was open. Changed to handleWorkspaceClose(), which already
   has the correct two-step logic: clear preview first, close panel only if
   no preview is visible.

Files changed:
- static/boot.js: syncWorkspacePanelUI() hides btnClearPreview on desktop
  when hasPreview is false, guarded by !isCompact so mobile is unaffected
- static/index.html: mobile-close-btn onclick changed from
  closeWorkspacePanel() to handleWorkspaceClose()
- tests/test_sprint44.py: 10 new regression tests
- tests/test_mobile_layout.py: updated test_workspace_close_button_present()
  to accept handleWorkspaceClose() as the valid onclick target

* fix: widen test_server_delete_invalidates_index window to 1200 chars

The test extracted a 600-char window starting from the session/delete
handler to check for SESSION_INDEX_FILE. Commit 3cc5839 added session_id
character validation and path traversal guards before the unlink call,
pushing SESSION_INDEX_FILE to ~764 chars from the match — beyond the
600-char limit, causing the test to fail on CI.

Widened the window to 1200 chars, which accommodates any reasonable
amount of guard code before the SESSION_INDEX_FILE.unlink() call.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 23:25:26 -07:00
nesquena-hermes
3cc5839bf3 [security] fix(sessions): validate session_id before deleting session files (#412)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sessions): validate session_id before deleting files

* fix: remove premature session index invalidation before validation check

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

---------

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 23:10:46 -07:00
nesquena-hermes
539501ed2b fix: delegate all live model fetching to agent provider_model_ids() (#411)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: delegate all live model fetching to agent's provider_model_ids()

Previously _handle_live_models() maintained its own per-provider logic:
- anthropic, google, gemini returned 'not_supported' (hardcoded exclusions)
- openai-codex had a custom branch (added in v0.50.30)
- openai/copilot had hardcoded base URLs
- other providers fell through to a generic /v1/models fetch

Now the handler delegates entirely to hermes_cli.models.provider_model_ids(),
which is the agent's authoritative resolver:
- anthropic:    live fetch via /v1/models with correct API-key or OAuth headers
- copilot:      live fetch from api.githubcopilot.com/models with Copilot headers
- openai-codex: Codex OAuth endpoint + ~/.codex/ cache fallback
- nous:         live fetch from Nous inference portal
- deepseek, kimi-coding: generic OpenAI-compat /v1/models
- opencode-zen/go: OpenCode live catalog
- openrouter:   curated static list (live returns 300+ which is overwhelming)
- google/gemini, zai, minimax: static list (non-standard or Anthropic-compat endpoints)
- any others:   graceful static fallback

Also removed the client-side skip guard in _fetchLiveModels() (ui.js) that
blocked live fetching for anthropic, google, and gemini.

The hardcoded model lists in _PROVIDER_MODELS remain as the fallback when
credentials are missing or network is unavailable — they are never shown
when live data is available.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:57:58 -07:00
nesquena-hermes
c91eaaf05f fix: route openai-codex live model fetch through agent get_codex_model_ids() (#410)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: route openai-codex live model fetch through agent's get_codex_model_ids()

Previously _handle_live_models() grouped openai-codex with openai and sent a
request to https://api.openai.com/v1/models, which returns 403 because Codex
auth is OAuth-based via chatgpt.com, not a standard API key. The live fetch
silently failed and the UI showed only the hardcoded static list.

Now: openai-codex has a dedicated early-exit branch that calls
hermes_cli.codex_models.get_codex_model_ids() — the same path the agent CLI
uses. It resolves models in order: live Codex API (if OAuth token available) >
~/.codex/ local cache > DEFAULT_CODEX_MODELS. This means:

- If the user has a valid Codex OAuth session, the UI gets the exact model list
  their subscription provides (e.g. gpt-5.2, gpt-5.3-codex-spark that aren't
  in the hardcoded list)
- If the OAuth session is expired, falls back to local ~/.codex/ cache
- Always has DEFAULT_CODEX_MODELS as final fallback

Also: improved label generation for Codex model IDs (GPT-5.4 Mini vs GPT 5 4 Mini).
Added 1 structural regression test.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:49:04 -07:00
nesquena-hermes
d3fea34c41 fix: correct tool call card rendering on session load after context compaction (#408)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: correct tool call card rendering on session load

Two bugs caused duplicate/incorrect tool call cards when loading
sessions (especially after context compaction):

1. loadSession() sanitized messages (B9 filter) but did NOT update
   the session-level tool_calls array's assistant_msg_idx references.
   Since compact() returns only sanitized messages and recomputes
   tool_calls with indices into the compacted array, the original
   assistant_msg_idx values became stale/misaligned.

2. loadSession() then assigned the broken session-level tool_calls
   directly to S.toolCalls. This prevented renderMessages()'s fallback
   path (which derives tool_calls from per-message tool_calls using
   correct sanitized-array indices) from ever running.

Fix:
- Keep full sanitization loop with index remapping for session-level
  tool_calls (in case they're needed by other code paths).
- Instead of assigning broken session-level tool_calls to S.toolCalls,
  set S.toolCalls=[] so renderMessages() uses the fallback derivation
  from per-message tool_calls, which already have correct indices.

* test: add 8 regression tests for issue #401 tool call index remapping

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

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:41:31 -07:00
nesquena-hermes
a2258139f2 fix: expand openai-codex model catalog to match DEFAULT_CODEX_MODELS (#407)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
1345ccccee feat: relative time labels in session sidebar (#406)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat: add relative time to session sidebar

(cherry picked from commit 272be9787fdff75d3da2dbc73175820477a3390e)

* fix: address session sidebar relative-time review feedback

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

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:26:05 -07:00
nesquena-hermes
4de4ed9a15 fix(sessions): redact sensitive titles in session list and search responses (#405)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sessions): redact titles in list and search responses

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

---------

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:20:21 -07:00
nesquena-hermes
04ed0ff43d v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: restore mobile chat scrolling and drawer close (#397)

- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites

Original PR by @Jordan-SkyLF

* fix: preserve imported session timestamps (#395)

- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block

Original PR by @Jordan-SkyLF

* fix(profiles): block path traversal in profile switch and delete flows (#399)

Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.

- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
  ^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
  candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
  both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes

Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)

* feat: add desktop microphone transcription fallback (#396)

Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).

- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
  keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
  MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
  into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
  transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
  returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
  allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks

Original PR by @Jordan-SkyLF

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 22:11:45 -07:00
nesquena-hermes
2beebaa6a2 feat: opt-in chat bubble layout (closes #336) (#403)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
0f8fec7ccd docs: v0.50.23 release — version badge and CHANGELOG (#393)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:46:51 -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
2acee7fc34 fix: onboarding unblocked for reverse proxy / SSH tunnel deployments (fixes #390) (#391)
Some checks failed
Release & Docker / release (push) Has been cancelled
- Read X-Forwarded-For and X-Real-IP before falling back to raw socket IP
- Add HERMES_WEBUI_ONBOARDING_OPEN=1 env var escape hatch for remote servers
- Error message now includes the env var hint
- 18 new tests (TestOnboardingIPLogic + TestOnboardingSetupEndpoint)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 17:52:07 -07:00
nesquena-hermes
acc14f2f0b docs: update ROADMAP, SPRINTS, TESTING to v0.50.21 (961 tests)
ROADMAP.md:
- Header: v0.49.1/700 → v0.50.21/961
- Sprint history table: 12 new rows covering v0.40 → v0.50.21 (500+ commits)
- Architecture block: updated line counts and module list

SPRINTS.md:
- Header state: v0.36/433 → v0.50.21/961
- 'Where we are now' section updated with parity status
- Historical planning content preserved as reference

TESTING.md:
- Version reference: v0.36.2 → v0.50.21
- Test count: 700 → 961 (two places)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 17:43:16 -07:00
nesquena-hermes
9948fcf1db docs: fix CHANGELOG ordering + README architecture counts
- CHANGELOG: reorder v0.50.19/v0.50.20/v0.50.21 to correct newest-first
  (v0.50.19 was mistakenly at the top above v0.50.21 and v0.50.20)
- README: fix architecture block test count 51 files/802 functions → 61 files/961
- README: update line counts to actual wc -l values:
  routes.py ~2250, streaming.py ~660, ui.js ~1740,
  messages.js ~655, sessions.js ~800

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 17:34:22 -07:00
nesquena-hermes
6a1dda4082 docs: add remaining contributors — Argonaut790, indigokarasu, zenc-cp (complete to 33)
- @Argonaut790 (#239): HTML entity decode fix + Traditional Chinese locale
  (fix shipped in v0.46.0; zh-Hant locale added same PR)
- @indigokarasu (#213): CSS-only visual redesign proposal — design token system
  + icon rail + 7 themes (influenced v0.50.0 design language)
- @zenc-cp (#133): Anti-hallucination guard for ReAct loop — streaming token
  buffer + post-run scrub pattern

README now has 33 contributors covering full project history.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:52:39 -07:00
nesquena-hermes
56944cc0ab docs: update contributors, test count, line counts (v0.50.21)
- Add 21 new contributor entries covering v0.50.x era and all external
  contributions that were incorporated via review branches
- Fix test count: 802 → 961
- Fix line counts for routes.py, streaming.py, ui.js, messages.js, sessions.js
  (all grew significantly from live reasoning, reload recovery, CSRF fixes etc.)
- New major tier: Jordan-SkyLF (live streaming + session recovery)
- New feature tier: gabogabucho, bergeouss, ccqqlo, betamod, TaraTheStar,
  thadreber-web, deboste
- New bug/security tier: Hinotoi-agent, lawrencel1ng, lx3133584, DelightRun,
  shaoxianbilly, huangzt, kcclaw001, mbac, andrewy-wizard, mmartial,
  vCillusion, carlytwozero, mangodxd

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:47:28 -07:00
nesquena-hermes
7f69155904 docs: v0.50.21 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:26:48 -07:00
nesquena-hermes
54181d1a07 fix: durable inflight reload snapshots via localStorage (#367)
* fix: persist durable inflight reload snapshots

* fix: remove duplicate loadInflightState stub, update CHANGELOG test count

The stub added in the previous review branch is superseded by the author's
real localStorage-backed implementation in the cherry-picked commit 36051c0.
Remove the duplicate. Update CHANGELOG to 961 tests and document the durable
inflight state feature.

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:25:31 -07:00
nesquena-hermes
9542639a90 fix: live reasoning, tool progress, in-flight session recovery (#367)
* fix: preserve live session output across chat switches

(cherry picked from commit 401e3b643d25e8dad8c06883b478b3c3073f07a5)

* fix: preserve todo state after session reload

(cherry picked from commit 7ee093ba19978af23b79148df2f2347e2f1e5bde)

* fix: preserve live assistant anchor across rerenders

* fix: stream live reasoning and tool progress

* fix: recover inflight session state after reload

* fix: add loadInflightState stub + CHANGELOG v0.50.21

- static/ui.js: add loadInflightState() function (currently returns null —
  the typeof guard in sessions.js means reload recovery works via the
  else-path attachLiveStream call; this stub satisfies the guard cleanly
  and documents the extension point for future localStorage-backed state)
- CHANGELOG.md: v0.50.21 entry; 960 tests (up from 949)

---------

Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 16:18:15 -07:00
nesquena-hermes
bcdd7ed3f3 docs: v0.50.20 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 15:53:52 -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
78de40e015 docs: v0.50.19 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 15:44:19 -07:00
nesquena-hermes
00eb13b316 fix: unicode filenames in Content-Disposition headers (#378)
* Fix unicode filenames in file download headers

* docs: v0.50.19 CHANGELOG entry for unicode filename fix (PR #378)

* docs: fix test count in v0.50.19 CHANGELOG (924 not 926)

---------

Co-authored-by: shaoxianbilly <40623436+shaoxianbilly@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 15:43:01 -07:00
nesquena-hermes
a71047bbc3 docs: v0.50.18 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 14:38:21 -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
4c8042ea00 docs: v0.50.17 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 12:38:00 -07:00
nesquena-hermes
a6484f69a8 fix: Docker uv pre-install at build time + workspace permissions (#365)
* fix: pre-install uv in Docker image + fix workspace dir permissions (#357)

Two fixes for Docker startup reliability:

1. Install uv at build time in the Dockerfile so the container works
   without internet access at runtime. The init script now skips the
   download when uv is already on PATH.

2. Use sudo mkdir/chown for the workspace directory, matching the
   pattern used for /app. Docker auto-creates bind-mount directories
   as root, leaving them unwritable by the hermeswebui user.

Fixes #357

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

* fix: Docker uv pre-install as root to /usr/local/bin + tests + CHANGELOG

Dockerfile: install uv as root with UV_INSTALL_DIR=/usr/local/bin so it
lands in /usr/local/bin (system PATH) rather than /home/hermeswebuitoo/.local/bin
which the hermeswebui runtime user can't see.

tests/test_issue357.py: 15 structural tests covering Dockerfile uv build-time
install (system-wide, as root, before COPY), init script skip-if-present
logic, and workspace sudo mkdir/chown.

CHANGELOG.md: v0.50.17 entry; 915 tests (up from 900)

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:36:11 -07:00
nesquena-hermes
f13f753de8 docs: v0.50.16 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 12:24:35 -07:00
nesquena-hermes
f948baceb6 fix: CSRF check fails behind reverse proxy on non-standard ports (#360)
* fix: CSRF check fails behind reverse proxy on non-standard ports

When serving behind a reverse proxy (e.g. Nginx Proxy Manager) on a
non-standard port like 8000, the browser sends
`Origin: https://example.com:8000` but the proxy forwards `Host: example.com`
(without the port). The existing CSRF check compared these as raw strings,
causing all POST requests to be rejected with 403.

This commit:
- Adds `_normalize_host_port()` to properly parse host:port pairs (incl. IPv6)
- Adds `_ports_match()` that treats absent port as equivalent to 80/443
- Adds `HERMES_WEBUI_ALLOWED_ORIGINS` env var for explicitly trusting origins
  when port normalization alone isn't sufficient (e.g. port 8000)
- Adds unit tests covering port normalization, allowlist, and rejection cases

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

* fix: CSRF port normalization — scheme-aware, allowlist validation, 29 tests (#360)

api/routes.py:
- _normalize_host_port(): parse host:port including IPv6 bracket notation
- _ports_match(scheme, origin_port, allowed_port): scheme-aware — http absent=:80,
  https absent=:443; prevents cross-protocol false match (http://host:80 no
  longer passes for https://host:443 server)
- _allowed_public_origins(): parse HERMES_WEBUI_ALLOWED_ORIGINS env var;
  warn and skip entries missing scheme prefix
- _check_csrf(): extract origin scheme, pass to _ports_match; add origin_scheme

tests/test_sprint29.py: 29 new tests (5 from PR + 24 added in review)
- Unit tests for _normalize_host_port and _ports_match helpers
- Cross-protocol rejection (http vs https default ports)
- Explicit :80 / :443 same-origin pass
- Non-default port rejection
- Bug scenario with/without allowlist
- Comma-separated allowlist
- No-scheme allowlist warning
- Trailing-slash normalization

CHANGELOG.md: v0.50.16 entry; 900 tests total (up from 871)

---------

Co-authored-by: liangxu.5 <liangxu.5@bytedance.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 12:23:16 -07:00
nesquena-hermes
5bdeb93559 docs: v0.50.15 release — version badge
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 11:43:17 -07:00
nesquena-hermes
d0e08fee88 feat: KaTeX math rendering for LaTeX in chat + workspace previews (#352)
* feat: KaTeX math rendering for $..$ and $$..$$ in chat and previews (fixes #347)

- Stash math delimiters before markdown pipeline, restore as .katex-block/.katex-inline elements
- KaTeX JS lazy-loaded from CDN on first math block (mirrors mermaid pattern)
- KaTeX CSS loaded eagerly in <head> to prevent layout shift
- SRI hashes on both CDN tags
- throwOnError:false — bad LaTeX degrades to code span
- Supports $$, $, \\(...\\), \\[...\\] delimiters
- 18 new tests, 831/831 passing

* fix: remove invalid \' escape sequences in math stash lines

Lines 311, 314, 316, 317 had \' (backslash-quote) instead of plain '
in the arrow function bodies. This is a JS syntax error — node --check
fails with 'Invalid or unexpected token'. Likely caused by a
serialization artifact during code generation.

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

* fix: swap stash order (fence before math) to protect code spans; add renderKatexBlocks to workspace preview

- static/ui.js: fence_stash now runs BEFORE math_stash so dollar signs
  inside backtick code spans are not extracted as math. Previously
  `$x$` would render as KaTeX inside a <code> tag instead of
  showing the literal string $x$.
- static/workspace.js: add requestAnimationFrame(renderKatexBlocks)
  after markdown preview renders so math works in workspace file
  previews, not only in chat messages.

* feat: KaTeX math rendering + stash order fix + workspace wiring (#352)

- tests/test_issue347.py: 11 new tests (29 total) covering fence-before-math
  ordering, workspace.js renderKatexBlocks call, stash token distinctness,
  false-positive prevention, safe-tags boundary check
- CHANGELOG.md: v0.50.15 entry; 870 tests total (up from 841)

* fix: use literal null byte (\x00M) in math stash token — matches restore regex

The original PR's second commit (fix: remove invalid \' escapes) accidentally
doubled the backslash in the math stash tokens: '\\x00M' is a 5-char string
(backslash + x + 0 + 0 + M) but the restore regex /\x00M/ expects a null byte.
Result: $...$ in messages produced visible \x00M0\x00 tokens instead of
KaTeX spans.

Changed all 4 math stash return statements to use '\x00M' (single backslash =
null byte, same convention as fence_stash's '\x00F').

Also updates test_stash_tokens_distinct to check for the correct pattern.

* fix: add null-byte token test; update CHANGELOG to v0.50.15 with fixes

- tests/test_issue347.py: add test_math_stash_token_uses_single_backslash_null_byte
  to catch the \\x00M double-backslash regression; 30 tests total (up from 29)
- CHANGELOG.md: v0.50.15 entry documents all fixes including the token bug
  and workspace preview wiring; 871 tests total

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:40:15 -07:00
nesquena-hermes
dd17a0e9b7 security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
nesquena-hermes
04401787ec fix: inject SessionDB into AIAgent for WebUI sessions — enables session_search (#356)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: inject SessionDB into AIAgent for WebUI sessions

session_search tool requires a SessionDB instance passed via the
session_db parameter. The CLI and gateway paths already do this,
but the WebUI streaming path was missing it, causing every
session_search call to return 'Session database not available'.

Initialize SessionDB before creating the AIAgent and pass it through.
Failure is non-fatal — a warning is printed and session_search
gracefully degrades.

* fix: inject SessionDB into AIAgent for WebUI sessions (enables session_search) (#356)

- api/streaming.py: initialize SessionDB() before AIAgent construction and
  pass session_db= kwarg so session_search works in WebUI sessions
- tests/test_sprint42.py: 7 new tests covering SessionDB injection, try/except
  guard, WARNING log, ordering, and AST lock-safety check
- CHANGELOG.md: v0.50.13 entry; 822 tests total (up from 815)

---------

Co-authored-by: 王昌旭 <wangchangxu@xiaohongshu.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 10:53:58 -07:00
nesquena-hermes
09bbbfc657 docs: v0.50.12 release — CHANGELOG + version badge (#353)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:53:32 -07:00
Hinotobi
88dc8bbe26 fix: isolate profile .env secrets on switch (#351)
* fix: isolate profile .env secrets on switch

* fix: move direct os.environ set after _reload_dotenv to survive profile isolation

The profile env isolation in _reload_dotenv now clears previously tracked
env keys before re-reading .env. When apply_onboarding_setup set
os.environ BEFORE _reload_dotenv, the key was immediately cleared.
Move the belt-and-braces os.environ set to AFTER _reload_dotenv so
the API key survives regardless of profile tracking state.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:51:55 -07:00
nesquena-hermes
1fee123ac8 docs: note test_sprint34 pathlib fix in v0.50.11 CHANGELOG (#350)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:23:38 -07:00
nesquena-hermes
a683553699 fix: use pathlib in test_sprint34 static file opens (no bare relative paths) (#349)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:22:58 -07:00
nesquena-hermes
63fb22b7ee fix: add table styles to .msg-body for readable bordered chat tables (fixes #341) (#345)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: add table CSS to .msg-body for readable bordered tables in chat (fixes #341)

* fix: remove accidentally included ui.js and test_issue342.py from CSS-only PR

* docs: combine v0.50.11 CHANGELOG entries, bump version badge

* fix: restore ui.js from master (autolink already landed in #346)

* fix: restore test_issue342.py deleted by cleanup commit (already on master)

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:08:30 -07:00
nesquena-hermes
05f09012a5 feat: autolink plain URLs in chat messages (fixes #342) (#346)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:05:04 -07:00
Nathan Esquenazi
3c771c4d2c Merge pull request #344 from nesquena/fix/testing-md-port-8786
docs: fix stale port 8786 in TESTING.md prerequisites
2026-04-12 23:49:22 -07:00
Nathan Esquenazi
2398ec51fe docs: fix stale port 8786 in TESTING.md prerequisites — correct port is 8787 2026-04-13 06:38:14 +00:00
nesquena-hermes
4eaf4e0743 docs: fix stale test count in README architecture block (791 → 802) (#340)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 22:07:36 -07:00
nesquena-hermes
1c0d13c6d9 fix: title auto-generation + mobile close button (PR #333) + v0.50.10
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(merge): preserve auth errors + fix title auto-generation

* fix(css): hide mobile close button on desktop for workspace panel

* fix: hide duplicate collapse button in mobile workspace panel view

* docs: v0.50.10 — title auto-generation fix + mobile close button (PR #333)

---------

Co-authored-by: MILO <milo@MILOdeMacMINI-2.local>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 21:45:25 -07:00
nesquena-hermes
4c78d8a56b fix: correct Simplified Chinese (zh) locale — remove Traditional Chinese strings (#338)
fix: correct Simplified Chinese (zh) locale — remove Traditional Chinese strings
2026-04-12 19:20:20 -07:00
Hermes Agent
229680ae1e fix: zh-Hant approval_btn_always — use Traditional Chinese chars (始終允許 not 始终允许) 2026-04-13 02:19:57 +00:00
Nathan Esquenazi
e0e642a239 fix: apply reviewer correction for zh-Hant approval_btn_always
Per @shiqingshan review: use Simplified Chinese for approval_btn_always.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:53:37 -07:00
Nathan Esquenazi
e684fdd731 fix: replace Traditional Chinese with Simplified in zh locale (#337)
The zh (Simplified Chinese) locale had ~40 strings from a "missing keys"
section that were actually Traditional Chinese or garbled text. This
replaces them with correct Simplified Chinese, removes duplicate keys,
and fixes a garbled zh-Hant string (姊妹允許 → 始終允許).

Fixes #337

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:10:37 -07:00
Nathan Esquenazi
2a3324c201 fix: allow onboarding from Docker bridge networks (closes #334) (#335)
Some checks failed
Release & Docker / release (push) Has been cancelled
Expands the onboarding setup IP check from 127.0.0.1-only to any loopback or RFC-1918 private address. Docker containers connect via 172.17.x.x — previously blocked with a 403. Public IPs still blocked unless auth enabled. 791 tests pass.
2026-04-12 16:35:47 -07:00
Nathan Esquenazi
39d42be396 fix: deduplicate model dropdown (hyphen vs dot) + README accuracy (#332)
Some checks failed
Release & Docker / release (push) Has been cancelled
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
2fc19a8326 feat: OAuth provider onboarding path — Codex/Copilot no longer blocks setup (#331)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes bug 2 from issue #329. current_is_oauth flag; confirmation card for OAuth providers; KeyError fix in _build_setup_catalog. 15 new tests, 791 total.
2026-04-12 14:28:16 -07:00
nesquena-hermes
7d9d7e7b66 feat: HERMES_WEBUI_SKIP_ONBOARDING env var + synchronous key reload (#330)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes bugs 1+3 from issue #329. Skip-onboarding env var (with chat_ready guard); os.environ set synchronously after key write. 8 new tests, 776 total.
2026-04-12 14:26:00 -07:00
nesquena-hermes
9c44d0cf3e fix: strip think tags when model emits leading whitespace before <think> (#327)
Some checks failed
Release & Docker / release (push) Has been cancelled
Remove ^ anchor from think/Gemma regexes in ui.js; trimStart() before startsWith checks in messages.js streaming path. Fixes MiniMax M2.7 and any model emitting leading newlines before <think>. 10 new tests, 768 total.
2026-04-12 14:07:00 -07:00
Nathan Esquenazi
7552cd3e9b Merge pull request #328 from nesquena/fix/docker-compose-workspace-volume
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: add missing workspace volume to two-container compose (#326)
2026-04-12 13:47:17 -07:00
Nathan Esquenazi
f316fb7502 fix: add missing workspace volume to two-container compose (#326)
docker-compose.two-container.yml was missing the ~/workspace:/workspace
volume mount that the single-container compose already has. Without it
the workspace directory inside the container is ephemeral and the user
can't browse their actual files.

Fixes #326

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:46:59 -07:00
Nathan Esquenazi
50583f0667 Merge pull request #325 from nesquena/fix/docker-restart-venv
fix: Docker container restart without recreating (#324)
2026-04-12 13:27:41 -07:00
Nathan Esquenazi
26c24867e6 fix: Docker container restart without recreating (#324)
uv venv fails with 'A virtual environment already exists' when the
container is stopped and started (not removed). The venv persists in
the container filesystem between stop/start cycles.

Fix: skip venv creation and dependency installation if they already
exist from a previous run. Uses two checks:
- /app/venv/bin/python3 exists → skip venv creation
- /app/venv/.deps_installed marker → skip pip install

This also makes restarts much faster since deps don't need to be
reinstalled every time the container starts.

Fixes #324

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:27:21 -07:00
Nathan Esquenazi
2562567730 fix: onboarding completes gracefully for pre-configured providers (closes #322) (#323)
Some checks failed
Release & Docker / release (push) Has been cancelled
OAuth/CLI-configured providers (openai-codex, copilot, nous) no longer blocked by onboarding wizard. 5 new tests, 758 total.
2026-04-12 13:22:48 -07:00
nesquena-hermes
2b21bb68b8 feat: workspace panel state persists across refreshes (#321)
Some checks failed
Release & Docker / release (push) Has been cancelled
localStorage key hermes-webui-workspace-panel saves open/closed on every state change; restored on boot before syncWorkspacePanelState(). 7 new tests, 753 total.
2026-04-12 12:50:32 -07:00
nesquena-hermes
84ca4d617b fix: mobile Enter inserts newline (closes #269) (#320)
Some checks failed
Release & Docker / release (push) Has been cancelled
Cherry-pick of mobile Enter newline fix from #315. On touch-primary devices (pointer:coarse), Enter inserts a newline. Desktop unchanged. 4 new tests, 746 total.
2026-04-12 12:41:12 -07:00
nesquena-hermes
9021e76708 docs: fix sprint34 test count 19→21, cascade to correct 742 total (#319)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 12:19:35 -07:00
nesquena-hermes
eddf3249c1 docs: add contributor recognition section to README, tag contributors in CHANGELOG (#317)
- Add ## Contributors section to README with named thanks for all external
  contributors: @aronprins (v0.50.0 UI overhaul), @iRonin (6-PR security
  sprint), @DavidSchuchert (German i18n), @kevin-ho (OLED theme),
  @Bobby9228 (mobile profiles button), @franksong2702 (title guard +
  breadcrumb), @tgaalman (thinking card fix), @smurmann (custom provider
  routing), @jeffscottward (Haiku model ID)
- Add contributor attribution links to CHANGELOG entries for v0.50.0,
  v0.49.3, v0.49.1, v0.41.0, and the German/routing/OLED entries

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 12:04:42 -07:00
nesquena-hermes
ede1a5fc50 feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Polish workspace panel behavior and app dialogs

* Replace remaining emoji UI glyphs with Lucide icons

* Redesign composer footer around model and context controls

Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.

Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/

* Remove obsolete activity bar

Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.

This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.

* Move workspace and model switching into composer footer

* Move profile switching into composer footer

* Refactor Hermes control center UI

* Redesign control center settings modal layout

Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.

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

* Put session item actions in a dropdown

* Use Hermes mark in sidebar control button

* Reset control center section on close

* Drop session-item left border indicator

Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.

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

* Increase session search input vertical padding

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

* Normalise odd pixel values across UI

Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.

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

* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)

The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints

Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.

* Improve composer footer mobile responsiveness and UX

- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior

* Fix duplicate files button and broken workspace close on mobile

Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().

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

* Fix model chip icon vertical alignment in composer footer

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

* Fix workspace toggle button hidden on desktop by conflicting CSS class

Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.

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

* Fix session actions dots button inaccessible on mobile sidebar

Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.

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

* Fix composer footer manage links not opening sidebar panel

The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.

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

* Widen icon-only composer chips breakpoint from 400px to 768px

Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.

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

* Fix composer-left vertical scrollbar by setting overflow-y:hidden

When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.

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

* fix: resolve rebase conflicts and fix control center test assertions

- Resolved 4 conflicts during rebase onto master (workspace.js,
  boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
  cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion

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

* fix: update version badge in System tab to v0.49.4

* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge

---------

Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 11:55:40 -07:00
nesquena-hermes
ed2d55f020 docs: fix test count totals in CHANGELOG for v0.49.2 and v0.49.3 (#314)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 11:18:55 -07:00
nesquena-hermes
28354a9702 docs: v0.49.4 release — cancel cleanup fix (#313)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 11:10:25 -07:00
nesquena-hermes
bd3ec45aa9 fix: cancel cleanup no longer depends on SSE event (closes #299)
cancelStream() now clears S.activeStreamId, calls setBusy(false),
setStatus(''), and hides the cancel button directly after the cancel
API request completes. Previously cleanup depended on the SSE 'cancel'
event, which never arrived if the connection was already closed —
leaving 'Cancelling...' status and busy spinner stuck indefinitely.

The SSE cancel handler in messages.js still fires when the connection
is alive and performs additional cleanup (adds 'Task cancelled.' message,
clears tool cards). All operations are idempotent.

9 new tests in tests/test_sprint36.py.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 11:08:59 -07:00
nesquena-hermes
74a4263056 docs: v0.49.3 release — session title guard + breadcrumb nav (#311)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 10:53:17 -07:00
nesquena-hermes
28a0f0bef9 fix+feat: session title guard + breadcrumb nav + wider panel + responsive msgs (closes #300, #292)
PR #301 changes:
- api/streaming.py: guard title_from() with s.title == 'Untitled' check
- api/routes.py: same guard in sync/non-streaming path

PR #302 changes (cleaned — restores accidentally-removed features):
- static/boot.js: PANEL_MAX 500 -> 1200
- static/boot.js: clearPreview() calls renderBreadcrumb() to restore dir view
- static/style.css: responsive .messages-inner breakpoints (1400px/1800px)
- static/workspace.js: renderFileBreadcrumb() function with clickable segments
- static/workspace.js: openFile() calls renderFileBreadcrumb(path)

12 new tests in tests/test_sprint35.py

Note: PR #302 branch contained several accidental regressions (removed app-dialog
system, onboarding CSS, _checkProviderMismatch, closeMobileFiles, etc.) that were
not part of its stated scope. This clean branch applies only the three intended
features on top of current master.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 10:51:48 -07:00
Nathan Esquenazi
b12a682121 ci: add GitHub Actions workflow to run tests on PRs (#307)
Some checks failed
Release & Docker / release (push) Has been cancelled
Runs pytest suite across Python 3.11, 3.12, and 3.13 on ubuntu-latest.
Agent-dependent tests auto-skip via existing conftest logic.
Triggers on PRs targeting master and pushes to master.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:38:27 -07:00
nesquena-hermes
bc16545794 docs: v0.49.2 release — OAuth provider onboarding fix (#308)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 10:38:14 -07:00
nesquena-hermes
a13a1e0b9e fix: recognize OAuth providers as ready in onboarding (closes #303, #304)
* fix: recognize OAuth providers as ready in onboarding (closes #303, #304)

OAuth-authenticated providers (GitHub Copilot, OpenAI Codex, Nous Portal,
Qwen OAuth) were incorrectly blocked by the first-run onboarding wizard
because _status_from_runtime() only treated providers in
_SUPPORTED_PROVIDER_SETUPS as valid, and _provider_api_key_present() only
checked for plain API keys.

Changes in api/onboarding.py:
- Add _provider_oauth_authenticated(provider, hermes_home): checks
  hermes_cli.auth.get_auth_status() first (authoritative), then falls back
  to parsing ~/.hermes/auth.json directly for the known OAuth provider IDs
  (openai-codex, copilot, copilot-acp, qwen-oauth, nous).
- _status_from_runtime(): add else branch for providers not in
  _SUPPORTED_PROVIDER_SETUPS; calls _provider_oauth_authenticated() so
  copilot/openai-codex users with valid credentials get provider_ready=True.
- Fix misleading 'API key' wording in provider_incomplete note for OAuth
  providers; now says 'Run hermes auth or hermes model to complete setup.'

19 new tests in tests/test_sprint34.py covering all branches.

* fix: mock _HERMES_FOUND in _status_from_runtime tests

5 tests in TestStatusFromRuntimeOAuth failed because _status_from_runtime()
short-circuits to 'agent_unavailable' when _HERMES_FOUND is False.
The tests passed imports_ok=True but _HERMES_FOUND is a separate module-level
flag. Fixed: _call() helper now mocks _HERMES_FOUND=True with restore in finally.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:37:38 -07:00
nesquena-hermes
fc43b897c5 docs: v0.49.1 release notes — Docker docs + mobile Profiles button
Some checks failed
Release & Docker / release (push) Has been cancelled
- CHANGELOG: entries for #291 (Docker docs) and #265 (mobile Profiles button)
- ROADMAP: sprint table row + header date/count updated to v0.49.1/700
- TESTING.md: test count 700
- SPRINTS.md: version v0.49.1 + test count 700
- static/index.html: version bumped to v0.49.1

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 00:43:45 -07:00
nesquena-hermes
d6a925cf11 feat(mobile): add Profiles button to mobile bottom navigation (#265)
Adds a Profiles button as the last item in the mobile bottom nav bar,
making the Profiles panel reachable on mobile without opening the sidebar.

Fixes from original PR:
- Uses mobileSwitchPanel('profiles') not the broken two-call approach
- data-panel='profiles' attribute present for active-highlight state
- SVG 20x20 stroke-width 1.5 matching all other mobile nav icons
- Placed last (Chat → Tasks → Skills → Memory → Spaces → Profiles)
- 3 new tests in test_mobile_layout.py covering presence, handler, and order

Tests: 700 passed (up from 697)

Co-authored-by: @gabogabucho

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 00:41:52 -07:00
Nathan Esquenazi
5468b04550 docs: two-container Docker setup for Agent + WebUI (#288)
Adds docker-compose.two-container.yml and README section for running
hermes-agent and hermes-webui in separate Docker containers connected
via shared volumes.

The key insight: the WebUI imports hermes-agent's Python modules
directly (not via HTTP), so the agent source must be mounted into
the WebUI container. The existing docker_init.bash handles installing
the agent's dependencies at startup via uv pip install.

Shared volumes:
- hermes-home: config, sessions, skills, memory (~/.hermes)
- hermes-agent-src: agent source code for Python import

Fixes #288

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:37:35 -07:00
nesquena-hermes
7556ea0e04 docs: v0.49.0 final — test count 697, add #287 and #289 entries
Some checks failed
Release & Docker / release (push) Has been cancelled
All three PRs now merged:
- #285: first-run onboarding wizard
- #287: self-update git pull diagnostics
- #289: skip flaky redaction test in agent-less envs

Final test count: 697 (up from 679)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 00:23:06 -07:00
nesquena-hermes
92fbf2a793 test: skip flaky redaction test in agent-less environments (#289)
This test depends on session state that varies with test ordering.
It passes when run in isolation or with the full hermes agent, but
fails intermittently in the standard test suite. Add to the
auto-skip list alongside other agent-dependent tests.

Fixes #289

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 00:19:36 -07:00
nesquena-hermes
0d98116b37 fix: improve self-update git pull diagnostics (#287)
Rebased and enhanced version of PR #287 by @ccqqlo:

- _run_git() now returns stderr on failure instead of empty string,
  so the UI can surface actionable git error messages
- Added _split_remote_ref() to split tracking refs like origin/master
  into separate remote + branch args for git pull
- Ignore untracked files in stash decision (--untracked-files=no) to
  prevent misleading stash-pop failures
- Fail early with clear message on unresolved merge conflicts
- 4 unit tests covering stderr, stdout fallback, exit code, and ref splitting

Based on work by @ccqqlo in PR #287.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:19:33 -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
f9663d2f1d docs: bump to v0.49.0 — onboarding wizard release
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 23:41:10 -07:00
nesquena-hermes
cbc3c01604 docs: v0.48.2 release notes — provider mismatch warning
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 21:26:34 -07:00
nesquena-hermes
42dd2b562d fix: warn on provider/model mismatch, surface auth errors (#266)
* fix: warn on provider/model mismatch, surface auth errors (#266)

Fixes #266 — WebUI silently ignores provider/model selection mismatch.

The problem: selecting an OpenRouter (or Anthropic/OpenAI) model while
Hermes is configured for a different provider (e.g. local Ollama) sends
the request to the wrong endpoint, which returns a 401 Unauthorized error
with no UI indication of why.

Three-layer fix:

1. api/streaming.py — detect 401/auth errors explicitly
   Added is_auth_error detection covering '401', 'AuthenticationError',
   'authentication', 'unauthorized', 'invalid api key', and the specific
   Ollama error string 'no cookie auth credentials'. Auth errors emit
   apperror with type='auth_mismatch' and a hint pointing to 'hermes model'.

2. static/ui.js — expose active_provider and warn on selection
   - populateModelDropdown() stores data.active_provider from /api/models
     as window._activeProvider (the field was already in the response but
     the frontend never used it)
   - New _checkProviderMismatch(modelId) helper: compares the selected
     model's slash-prefix (e.g. 'openai/' from 'openai/gpt-4o') against
     the active provider. Skips the check for 'openrouter' and 'custom'
     to avoid false positives on configs that legitimately route any model.

3. static/boot.js — warn on model dropdown change
   modelSelect.onchange calls _checkProviderMismatch() and shows a toast
   when the selected model looks incompatible with the configured provider.

4. static/messages.js — distinct UI label for auth errors
   apperror handler now distinguishes type='auth_mismatch' and shows
   'Provider mismatch' as the error label instead of 'Error'.

5. static/i18n.js — provider_mismatch_warning and provider_mismatch_label
   keys added to all 5 locales (en, es, de, zh-Hans, zh-Hant).

Tests: 21 new tests in tests/test_provider_mismatch.py covering all
five change areas. 679/679 total pass (658 baseline + 21 new).

* fix: t() call args spread + use i18n label for auth mismatch

1. ui.js: _checkProviderMismatch passed [modelId, ap] as a single
   array arg to t(). Since t(key, ...args) spreads, the function
   received the array as m and undefined as p. Fixed to pass as
   separate args: t('provider_mismatch_warning', modelId, ap).

2. messages.js: 'Provider mismatch' label was hardcoded instead of
   using t('provider_mismatch_label'). Now uses the i18n key with
   fallback for when t() isn't available.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:25:18 -07:00
nesquena-hermes
6b4ff53315 docs: v0.48.1 release notes — table inline formatting
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 21:03:51 -07:00
nesquena-hermes
ce84d1bafa fix: render inline formatting in markdown table cells (#273)
Some checks failed
Release & Docker / release (push) Has been cancelled
Table cells used esc() which escaped all HTML including <strong>,
<em>, <code> tags. Changed to inlineMd() which processes markdown
bold/italic/code/links and allows safe HTML tags through.

This runs after the pre-pass that converts <strong> to ** and
<em> to *, so both HTML tags and markdown syntax in table cells
are rendered correctly.

Fixes #273

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:03:01 -07:00
nesquena-hermes
afa540a222 docs: v0.48.0 release notes — gateway session sync
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 20:54:21 -07:00
nesquena-hermes
711bb5a6c9 feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)

- Add gateway_watcher.py: background daemon polling state.db every 5s
  for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import

Addresses GitHub issue #272

* fix: SSE event name mismatch, TLS attribute, remove PLAN.md

- Fix critical SSE bug: frontend listened for 'gateway_session_update'
  but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
  the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
  does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo

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

* fix: test isolation and slow-consumer sentinel in gateway sync

tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
  HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
  so state.db was written to a double-nested path the server never read.
  Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
  TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
  connection stays valid for _remove_test_sessions() in the finally block.
  Previously the closed conn caused ProgrammingError in finally (swallowed
  by bare except), leaving ghost sessions in state.db on test failure.

api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
  and is removed from _subscribers, now puts a None sentinel into it so the
  SSE handler unblocks and closes the connection, letting EventSource
  auto-reconnect. Without this the connection stayed open but received no
  further events.

* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest

The gateway sync tests write directly to state.db and must use the same
path the test server reads from.  Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.

tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
  os.environ (via setdefault) so gateway tests can read it reliably.
  Using setdefault preserves any explicit override the caller may pass.

tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
  (now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
  Remove the workaround that tried to snapshot HERMES_HOME at import time.

Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:53:12 -07:00
nesquena-hermes
c677893105 docs: v0.47.1 release notes — Spanish locale
Some checks failed
Release & Docker / release (push) Has been cancelled
- CHANGELOG: v0.47.1 entry for Spanish locale (PR #275)
- ROADMAP: header updated v0.47.0 → v0.47.1, 645 → 648 tests; sprint row added
- TESTING.md: test count 645 → 648
- static/index.html: version v0.47.0 → v0.47.1

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 20:08:34 -07:00
nesquena-hermes
eca6f5efbd feat(i18n): add Spanish locale for WebUI (#275)
* feat(i18n): add Spanish locale for WebUI

* fix(i18n): translate tab_skills to Habilidades in Spanish locale

tab_skills was left as 'Skills' (English) in the es block — the only
sidebar tab that wasn't translated. Changed to 'Habilidades', the correct
Spanish term for Skills.

Also added tab_skills and tab_memory to the representative translation
assertions in test_spanish_locale.py to lock this in for future changes.

---------

Co-authored-by: gabogabucho <gabogabucho@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 20:06:37 -07:00
nesquena-hermes
068836cf6b fix: add aria-label to mobile workspace panel close button
The × button added for the mobile workspace panel close in v0.47.0
had a title= attribute but no aria-label. Screen readers may announce
the raw × character ('times' or 'multiplication sign') instead of
reading the title. Added aria-label='Close workspace panel' to match
the accessibility pattern used by other icon buttons in the panel header.

All 645 tests pass.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 16:18:46 -07:00
nesquena-hermes
09325f1bdf docs: rewrite HERMES.md with accurate 2026 market comparisons
* docs: rewrite HERMES.md with accurate 2026 market comparisons

* fix: correct /loop and scheduling claims for Claude Code

Three factual errors corrected:

1. /loop is a native bundled skill available without any plugin. The doc
   incorrectly described it as behavior from the ralph-wiggum plugin.
   ralph-wiggum provides /ralph-loop, which is distinct: it iterates toward
   a completion goal. /loop polls on a fixed schedule. Both exist and serve
   different purposes.

2. claude.ai/code/scheduled is not a real usable URL or scheduling interface.
   Removed the reference. Cloud scheduling is described as cloud-managed cron
   with a 1-hour minimum interval.

3. 'your data leaves your hardware' was only half-true. Desktop scheduled tasks
   run locally with full file access. Cloud tasks do leave your hardware. Rewrote
   to be precise: the real distinction vs Hermes cron is that neither option runs
   as a headless server daemon.

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 15:45:38 -07:00
nesquena-hermes
1003fa410c docs: add CSS icon hotfix note to v0.47.0 CHANGELOG
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 12:39:12 -07:00
nesquena-hermes
a2ae953620 fix: session action menu icon aligned left of text (not above it)
The .ws-opt base class uses flex-direction:column which was causing the
SVG icon to stack above the label+meta text in each session action menu
item. Added row-flex layout to .session-action-opt .ws-opt-action,
removed inherited padding from .session-action-opt (moved to the inner
action span), and gave .ws-opt-icon a fixed width:16px + flex-shrink:0
so icons stay left-aligned regardless of text length.

Each menu item now shows: [icon] | Title\nSubtitle — one row per option.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 12:28:56 -07:00
nesquena-hermes
b86ace6ce3 v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
c357ed9b74 docs: fix stale TESTING.md footer (v0.36/440 -> v0.46.0/624)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 10:27:12 -07:00
nesquena-hermes
27c2fd6c08 v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
0e112455ec fix: stale test count in ROADMAP.md header (499 -> 604)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 11:55:18 -07:00
nesquena-hermes
02e6e768e6 docs: v0.45.0 release notes + roadmap/sprint plan updates
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 11:47:04 -07:00
nesquena-hermes
da160d675f feat: custom endpoint fields in new profile form (fixes #170, closes #214)
* feat: add custom endpoint fields to new profile form

* fix: skip config write tests when PyYAML not installed

The 4 unit tests for _write_endpoint_to_config imported yaml directly
without handling ImportError. Added pytest.importorskip('yaml') at
module level so the entire test class skips cleanly in environments
without PyYAML. Removed redundant per-method yaml imports.

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

* feat: wire frontend for custom endpoint fields in new profile form

- Add Base URL and API key inputs to the profile create form (index.html)
- Wire panels.js submitProfileCreate() to send base_url and api_key
- Clear new fields on form toggle/cancel
- Add client-side URL format validation (must start with http:// or https://)
- Add server-side URL format validation in routes.py (400 for invalid scheme)
- Add test_api_route_rejects_invalid_base_url() covering the new validation
- Base URL input has placeholder 'http://localhost:11434' per review suggestion

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:43:49 -07:00
nesquena-hermes
1e27940535 docs: v0.44.1 release notes, version bump, correct test counts
Some checks failed
Release & Docker / release (push) Has been cancelled
- static/index.html: v0.44.0 → v0.44.1
- CHANGELOG.md: add [v0.44.1] entry for unskip fix; clarify v0.44.0 test
  count was 579/595 at that tag (595/595 after unskip)
- TESTING.md: 579 passing, 16 skipped → 595 passing, 0 skipped

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 10:56:37 -07:00
nesquena-hermes
2215aced19 fix: unskip 16 approval tests — remove stale has_pending/pop_pending imports
These two symbols were removed from tools.approval when the agent renamed
has_pending -> has_blocking_approval (checks gateway queue, not _pending dict)
and dropped pop_pending. They were only in the import block — never used in
any test body — but their absence caused the entire file to skip via the
APPROVAL_AVAILABLE guard.

Before: 595 collected, 579 passed, 16 skipped
After:  595 collected, 595 passed, 0 skipped

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 10:10:28 -07:00
nesquena-hermes
4947a6b0c3 v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: approval pending check broken by stale has_pending import (#228)

api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.

Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.

Before: 554 pass, 1 fail | After: 555 pass, 0 fail

* fix: move login JS into external file, remove inline handlers (#226)

Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.

Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.

Fixes #222.

* fix: improve update error messages when pull fails (#227)

_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.

Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.

Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).

Fixes #223.

* fix: stabilize 30s terminal approval prompt visibility (#225)

Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.

Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.

Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.

* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)

Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.

Changes:
  - static/icons.js: new file with 21 icon paths + li() renderer
  - static/index.html: all nav/action buttons now use li() icons
  - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
  - static/messages.js: cancelStream button uses SVG square stop icon
  - .gitignore: adds node_modules/ entry

Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 10:02:28 -07:00
nesquena-hermes
0df9d4830f docs: v0.43.1 — CSRF reverse proxy fix (#220)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 01:27:09 -07:00
Nathan Esquenazi
e0a95193d8 fix: CSRF check supports reverse proxy headers (#218) (#219)
Tests pass: 20/21 QA suite (1 known skip), all browser API sanity checks green, CSRF fix verified end-to-end.
2026-04-10 01:24:18 -07:00
nesquena-hermes
e3c85624d9 docs: v0.43.0 release — auto-install agent deps, session ID validator, test suite isolation fix (571 tests) (#217)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 01:10:02 -07:00
nesquena-hermes
ed9023a431 fix: wire auto_install_agent_deps into server.py startup (#216)
* fix: wire auto_install_agent_deps into server.py startup; add api/startup.py to ARCHITECTURE.md

* fix(tests): kill stale process on test port before server start in conftest

Stale servers left by QA harness runs (ports 8792/8793 etc.) or prior
test sessions could interfere with conftest starting its own server on
TEST_PORT (8788). If the port was already occupied, _wait_for_server
hit the wrong server and tests got unexpected 404s/500s, failing
non-deterministically — the 'conftest isolation issue' seen this session.

Fix: run fuser -k on TEST_PORT before launching the new server process,
with a 0.5s sleep for port release. The full suite now runs 571/571
reliably regardless of what other servers were previously active.

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 00:56:07 -07:00
nesquena-hermes
e59fedd351 feat: auto-install missing agent deps on startup (#215)
* feat: auto-install missing agent deps on startup

* fix: patch HERMES_HOME in test_skips_when_agent_dir_missing to prevent real agent fallback

The test patched HERMES_WEBUI_AGENT_DIR to a nonexistent path but left
HERMES_HOME unpatched. In the full test suite HERMES_HOME resolves to the
real hermes agent dir, causing the fallback in _agent_dir() to find and
use it — making auto_install_agent_deps() call pip instead of returning
False. Fix: also patch HERMES_HOME to a nonexistent dir in env_overrides.

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 00:42:02 -07:00
nesquena-hermes
9a5435176d fix: broaden session ID validator to support new hermes-agent format (#212)
* fix: broaden session ID validator to support new hermes-agent format

* test: add more path traversal evil IDs to session validator test

Add null byte, backslash, forward slash, and dot-extension variants
to the rejected session ID test to cover additional attack vectors.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:00:02 -07:00
nesquena-hermes
31281a6025 docs: v0.42.2 release — CSP unsafe-inline fix (564 tests) (#210)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 19:08:30 -07:00
nesquena-hermes
cc8cbc4d3f fix(security): add unsafe-inline and CDN allowlist to CSP script-src (#209)
The CSP script-src 'self' policy blocked all inline onclick= event handlers
in index.html (55+ handlers including toggleSettings(), switchPanel(),
filterSessions() etc.), making the settings panel, sidebar navigation, and
most interactive UI elements non-functional.

Also restores https://cdn.jsdelivr.net to both script-src and style-src
(required for Mermaid.js dynamic load in ui.js and Prism.js static load
in index.html). This was present in the original PR #197 merge but was
dropped in the v0.42.1 commit.

script-src additions:
- 'unsafe-inline': required for onclick=/oninput=/onchange= attributes
- https://cdn.jsdelivr.net: Mermaid (dynamic) and Prism (static with SRI)

style-src: retains 'unsafe-inline' + cdn.jsdelivr.net (Prism CSS)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 19:07:51 -07:00
nesquena-hermes
0e5e465ea0 fix: i18n button text stripping and German translation corrections (v0.42.1)
Some checks failed
Release & Docker / release (push) Has been cancelled
Three sidebar buttons (+ New job/skill/profile) and three suggestion
buttons had data-i18n on the outer element, causing applyLocaleToDOM
to strip the + prefix and emoji characters when switching locales.
Fixed by wrapping only the label text in a <span data-i18n=...>.

Also corrects German translations:
- cancelling: imperative -> progressive (Wird abgebrochen...)
- editing: first-person verb -> noun (Bearbeitung)
- empty_subtitle: add missing 'explore files' clause
- settings_desc_check_updates: add git fetch detail
- settings_desc_cli_sessions: add 'continue the conversation' clause

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 19:04:48 -07:00
nesquena-hermes
a92e21553d docs: v0.42.0 release — German i18n, custom provider routing, phantom Custom group fix (564 tests) (#207)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 18:44:04 -07:00
David Schuchert
06f46439c0 feat: add German translation and make UI elements translatable (#190)
Co-authored-by: David Work <davidwork@MBP-von-David.fritz.box>
2026-04-09 18:35:23 -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
nesquena-hermes
cb069794dd docs: v0.41.0 release — TLS, CSP, session memory leak, slow-client timeout, update checker, CLI file browser (561 tests) (#205)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 18:20:07 -07:00
Cyprian Kowalczyk
be92e59bdb fix: support CLI sessions in /api/list file browser (#204)
* feat: optional HTTPS/TLS support via cert and key env vars

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).

* fix: use current branch upstream for update checks, not repo default branch

The update checker in api/updates.py always compared HEAD against
origin/master (or origin/main), which produced false 'N updates
available' alerts when the user is on a feature branch and master has
moved forward with unrelated commits.

Now uses git rev-parse --abbrev-ref @{upstream} to get the current
branch's tracking branch for both the behind-count check and the
apply-update pull command. Falls back to the default branch if no
upstream is set (brand-new local branch with no tracking config).

Fixes #200.

* fix: support CLI sessions in /api/list file browser

_handle_list_dir() only checked WebUI in-memory sessions, returning
'Session not found' for CLI sessions imported from the agent's state.db.
Now falls back to get_cli_sessions() to find the workspace path for
CLI sessions that aren't loaded in WebUI memory.

Fixes: workspace pane showing empty for CLI sessions.
2026-04-09 18:18:38 -07:00
Cyprian Kowalczyk
f90be60e31 fix: use current branch upstream for update checks instead of default branch (#201)
* feat: optional HTTPS/TLS support via cert and key env vars

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).

* fix: use current branch upstream for update checks, not repo default branch

The update checker in api/updates.py always compared HEAD against
origin/master (or origin/main), which produced false 'N updates
available' alerts when the user is on a feature branch and master has
moved forward with unrelated commits.

Now uses git rev-parse --abbrev-ref @{upstream} to get the current
branch's tracking branch for both the behind-count check and the
apply-update pull command. Falls back to the default branch if no
upstream is set (brand-new local branch with no tracking config).

Fixes #200.
2026-04-09 18:10:11 -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
Cyprian Kowalczyk
392bc5df6e fix: add Content-Security-Policy and Permissions-Policy headers (#197)
Add CSP and Permissions-Policy headers to _security_headers() for
defense-in-depth against XSS and unwanted browser feature access.

CSP policy:
  default-src 'self' — only load resources from same origin
  script-src 'self' — prevent inline/remote script injection
  style-src 'self' 'unsafe-inline' — allow themes (inline styles)
  img-src 'self' data: — allow workspace images and data URIs
  font-src 'self' data: — allow web fonts
  connect-src 'self' — only allow fetch/XHR to same origin
  base-uri 'self'; form-action 'self' — prevent base/form injection

Permissions-Policy: disable camera, microphone, geolocation.

Addresses #193.
2026-04-09 18:07:07 -07:00
Cyprian Kowalczyk
fdf6ebfbe6 fix(auth): prune expired sessions on every verify to prevent memory leak (#196)
* fix(auth): prune expired sessions on every verify to prevent memory leak

The in-memory _sessions dict accumulated expired tokens indefinitely —
entries were only removed when that specific token was verified. Add a
lazy _prune_expired_sessions() call at the top of verify_session() so
all expired entries are swept during normal traffic.

Addresses #192.

* test(auth): add 8 unit tests for session lifecycle and lazy pruning

Tests verify:
- Fresh session creation and validation
- Expired entries are pruned during verify_session() calls
- Valid sessions are never removed by pruning
- Empty dict is safe for pruning
- Session TTL matches expected 24-hour window
- invalidate_session() actually removes the token
- Invalidating non-existent tokens is safe
2026-04-09 18:05:23 -07:00
Cyprian Kowalczyk
04678b7b6e feat(server): add 30s connection timeout to prevent slow-client thread exhaustion (#198)
Set Handler.timeout = 30. Python's BaseHTTPRequestHandler.setup()
calls self.request.settimeout(timeout), which raises socket.timeout
on idle or slow connections after the configured duration.

This defends against Slowloris-style attacks where a client holds
connections open indefinitely, exhausting threads in ThreadingHTTPServer.
Also recovers threads from crashed clients with hung TCP connections.

Addresses #194.
2026-04-09 18:05:18 -07:00
nesquena-hermes
4d68fb31d4 docs: v0.40.2 release — approval UI, 547 tests (#188)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-08 20:17:14 -07:00
nesquena-hermes
80b26c7c72 fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking

When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.

Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.

Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
  agent runs. The callback calls put('approval', ...) to emit the SSE event
  the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
  any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.

Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
  This unblocks the agent thread parked in entry.event.wait() when the user
  clicks Allow or Deny in the UI. Without this call the thread would block
  until the 5-minute gateway timeout.

Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
  session/once choices, resolve_all, notify_cb registration/firing/cleanup,
  unregister signals blocked entries, full end-to-end streaming simulation,
  module symbol exports, and HTTP endpoint regressions.

515 tests pass (499 existing + 16 new).

* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-08 20:16:22 -07:00
nesquena-hermes
012ac6f149 docs: v0.40.1 release — default locale fix (#186)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-08 19:35:41 -07:00
nesquena-hermes
18aca24063 fix: default first-install locale to English (#185)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-08 19:35:03 -07:00
nesquena-hermes
a5b843d6f9 docs: v0.40.0 release — i18n, notifications, thinking display (#184)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-08 19:19:02 -07:00
Nathan Esquenazi
9714c1779f Merge pull request #183 from nesquena/fix/i18n-review-fixes
fix: stray } in message HTML + JS-escape login locale strings
2026-04-08 19:07:29 -07:00
Nathan Esquenazi
0126044ecb fix: stray } in message row HTML + JS-escape login locale strings
Agent review findings from PR #179:

1. static/ui.js line 542: extra } in ternary produced malformed HTML
   in message bubble div (''}} instead of ''}). Caused a literal }
   character to appear in the DOM.

2. api/routes.py: LOGIN_INVALID_PW and LOGIN_CONN_FAILED were inserted
   into JS string context without JS-string escaping. Added backslash
   escaping for ' and \ characters. Currently safe because locale values
   are hardcoded, but this prevents breakage if custom locale strings
   contain single quotes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:07:00 -07:00
Nathan Esquenazi
166a4c3e7b Merge pull request #179 from nesquena/feat/i18n-language-switcher
feat: pluggable i18n with English/Chinese language switcher in Settings
2026-04-08 18:59:11 -07:00
Nathan Esquenazi
1ac1e74512 fix: apply locale to DOM immediately on save — no reload needed
Add applyLocaleToDOM() which walks [data-i18n] elements and re-stamps
their textContent from t(). Called after setLocale() in saveSettings()
so the settings panel labels, checkboxes, and save button update live.
Also called on boot after /api/settings resolves so Chinese persists
without flicker on reload.

- static/i18n.js: add applyLocaleToDOM() function
- static/index.html: add data-i18n attributes to all settings panel
  static text nodes (labels, checkbox spans, save button)
- static/panels.js: call applyLocaleToDOM() + syncTopbar() after save
- static/boot.js: call applyLocaleToDOM() alongside setLocale() on boot
2026-04-08 18:58:20 -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
c04caf3f5b Merge pull request #180 from nesquena/feat/notification-sound-browser
feat: notification sound and browser notifications
2026-04-08 18:56:11 -07:00
Nathan Esquenazi
799cbb7eca fix: update sound/notification globals in password branch + close AudioContext
Agent review findings:
- _soundEnabled/_notificationsEnabled not updated in the password-save
  early-return branch of saveSettings() — fixed
- AudioContext never closed after oscillator finishes — added osc.onended
  callback to ctx.close() preventing resource accumulation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:55:57 -07:00
Nathan Esquenazi
0d83837650 Merge pull request #182 from nesquena/fix/thinking-display-edge-cases
fix: harden thinking display streaming edge cases
2026-04-08 18:51:06 -07:00
Nathan Esquenazi
5f7564e8bb fix: harden thinking block streaming display
Hide partial <think> tag prefixes during streaming and rename the local display variable for clarity. References #181.
2026-04-08 18:14:47 +00:00
TaraTheStar
8ff5d83e14 feat: add support for displaying thinking/reasoning blocks in chat 2026-04-08 18:14:09 +00: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
Nathan Esquenazi
907bb224d9 Merge pull request #178 from nesquena/fix/streaming-env-lock-deadlock
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: resolve _ENV_LOCK deadlock that blocks chat after first message
2026-04-08 07:26:53 -07:00
Nathan Esquenazi
d919b584c6 docs: v0.39.1 release notes for ENV_LOCK deadlock fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 07:26:41 -07:00
Nathan Esquenazi
4422a87de9 fix: resolve _ENV_LOCK deadlock that blocks chat after first message
The v0.39.0 security sprint introduced _ENV_LOCK to protect env var
mutations in the streaming path. The implementation held the lock for
the entire agent run (potentially minutes), then tried to re-acquire
it in the finally block — a guaranteed deadlock on any non-reentrant
threading.Lock().

Result: first message completes (done event fires before finally hits),
but the lock is never released. Every subsequent chat/start POST blocks
forever waiting for that lock.

Fix: narrow the lock scope to just the env mutation. Set the vars inside
the with block, then let the lock release before the agent starts. The
finally block re-acquires cleanly since it no longer re-enters an
already-held lock.

No logic change — only the critical section boundary moves.
2026-04-08 14:22:39 +00:00
nesquena-hermes
9e9fcb09d2 Fix broken link in Quick start section (#175) 2026-04-07 23:39:33 -07:00
nesquena-hermes
12e5de9c4e Refine README for clarity and correctness (#174)
Updated the README to clarify installation steps and improve grammar.
2026-04-07 23:38:44 -07:00
nesquena-hermes
7e6fec1c85 docs: sweep TESTING.md, SPRINTS.md, ROADMAP.md to v0.39.0 / 499 tests
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-07 22:33:08 -07:00
nesquena-hermes
a064542df9 release: v0.39.0 — security hardening, 12 fixes (#171)
Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
Nathan Esquenazi
ac969e4bd6 Merge pull request #169 from tgaalman/fix/hermes-reasoning-display
Fix: show top-level reasoning field in thinking card
2026-04-07 12:01:23 -07:00
root
67a83368f0 Fix: show top-level reasoning field in thinking card
Hermes stores reasoning as a top-level message field (m.reasoning)
instead of in content arrays like Claude. This patch makes the
thinking/reasoning card also check for m.reasoning, so users can
see the model's reasoning process in the WebUI.
2026-04-07 20:44:55 +02:00
Nathan Esquenazi
a9b2a2f22f Merge pull request #168 from kevin-ho/oled-theme
feat: add OLED theme for true black displays
2026-04-07 11:12:05 -07:00
Kevin Ho
e3303c6e89 fix: add missing --input-bg/--hover-bg vars, update THEMES.md
- Added --input-bg and --hover-bg CSS variables to OLED theme
- Added OLED row to built-in themes table in THEMES.md
- Updated theme count from six to seven
2026-04-07 18:11:01 +00:00
Kevin Ho
40cbd024b9 feat: add OLED theme
True black background with subtle borders for OLED displays.
Pure #000 backgrounds, low-opacity borders, and warm accent colors
to minimize burn-in risk and maximize contrast.
2026-04-07 17:56:57 +00:00
nesquena-hermes
ccabdf9882 docs: update testing plan version coverage to v0.38.6 (#167)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 23:15:56 -07:00
nesquena-hermes
70a486ddef docs: sweep sprint and testing counts to v0.38.6 (#166)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 23:15:21 -07:00
nesquena-hermes
ab6147fba9 release: v0.38.6 — insights message count fix (#165)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 22:56:54 -07:00
Nathan Esquenazi
8aa1c9684d fix: sync message_count to state.db for /insights (#163) (#164)
* fix: sync message_count to state.db for /insights (#163)

sync_session_usage() didn't write message_count to state.db, so
/insights showed 0 messages for all WebUI sessions even with
sync_to_insights enabled.

Added message_count parameter to sync_session_usage() and pass
len(s.messages) from both the streaming and non-streaming chat paths.

Fixes #163

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

* fix: use callable pattern for _execute_write in sync_session_usage

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:56:27 -07:00
nesquena-hermes
4d2887531d release: v0.38.5 — custom endpoint URL, custom_providers, .env key fix (#161)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:39:37 -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
76241bc255 release: v0.38.4 — exclude ambient gh token from provider detection (#159)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:35:52 -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
027e7314f0 release: v0.38.3 — model dropdown uses hermes auth (#156)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:29:33 -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
01896d67f3 release: v0.38.2 — tool cards properly render on page reload (#154)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:23:54 -07:00
nesquena-hermes
5a52259fd7 fix: tool cards actually render on page reload from session data (#140) (#153)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:23:26 -07:00
nesquena-hermes
d71daad002 release: v0.38.1 — model selector duplicate + stale label fixes (#152)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:16:26 -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
nesquena-hermes
534eefe09a release: v0.38.0 — model routing, personality config.yaml, tool card reload (#150)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:11:41 -07:00
Nathan Esquenazi
d89639dbb3 fix: tool call cards persist across page reload (#140) (#149)
Tool cards disappeared on page refresh because assistant messages with
only tool_use content (no text) were filtered out of the visible
messages list. Since tool cards anchor to DOM rows via data-msg-idx,
removing the anchor row meant cards had nothing to attach to.

Fix: keep assistant messages in the render list if they contain
tool_use blocks, even when they have no text content. The row renders
with the role label but empty body, providing an anchor point for the
tool card insertion pass.

Fixes #140

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:10:33 -07:00
Nathan Esquenazi
2442fca5e5 fix: personalities from config.yaml + ephemeral_system_prompt (#139) (#148)
The previous implementation read SOUL.md files from a filesystem directory.
The Hermes agent uses config.yaml agent.personalities section with string
or dict format (system_prompt, tone, style), resolved via
_resolve_personality_prompt() and passed to AIAgent via
ephemeral_system_prompt.

Changes:
- /api/personalities: reads from config.yaml agent.personalities, not
  filesystem SOUL.md directories. Calls reload_config() to pick up
  config changes without restart.
- /api/personality/set: resolves prompt from config.yaml using the same
  logic as hermes-agent cli.py (string or dict with system_prompt/tone/style)
- streaming.py: passes personality via agent.ephemeral_system_prompt
  (agent's own mechanism) instead of prepending to system_message
- Removed unused 're' import from streaming.py
- Updated tests to match config-based approach

Fixes #139

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:10:30 -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
Nathan Esquenazi
3bba645364 Merge pull request #145 from jeffscottward/fix/claude-haiku-model-id
fix: correct Claude Haiku model ID from 3-5 to 4-5
2026-04-06 13:27:31 -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
nesquena-hermes
cd598c896a docs: v0.37.0 release notes, version bump, test count (465 tests) (#144)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 11:19:19 -07:00
Nathan Esquenazi
58eb6e7fd5 feat: /personality slash command with backend integration (#143)
* feat: /personality slash command with backend integration

Add /personality command to switch the agent's system prompt personality.
Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md.

Backend:
- GET /api/personalities: lists available personalities from the active
  profile's personalities directory (reads first line of SOUL.md for desc)
- POST /api/personality/set: sets active personality on the session, reads
  and validates the SOUL.md file exists, returns the prompt text
- streaming.py: injects personality prompt (SOUL.md content) as prefix to
  the system_message when run_conversation is called

Frontend (commands.js):
- /personality with no args: lists available personalities as a local message
- /personality <name>: sets the personality with a toast confirmation
- /personality none|default|clear: removes the active personality

Session model: new 'personality' field (backward-compatible, defaults to None)

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

* fix: path traversal in personality name + case sensitivity

Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
in both routes.py (POST /api/personality/set) and streaming.py (system
prompt injection). Defense-in-depth: resolve().relative_to() check ensures
the path stays inside the personalities directory even if regex is bypassed.

Also: removed toLowerCase() from frontend command handler so personality
names are case-preserved (filesystem may be case-sensitive).

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

* feat: /personality command — hardened, compact() fix, tests

Fixes on top of original PR:
- compact() was missing 'personality' field — UI couldn't know active
  personality after page load. Added to Session.compact().
- GET /api/personalities: add symlink guard (is_symlink() skip) and
  resolve() check — prevents reading SOUL.md from symlink targets
  outside personalities dir.
- POST /api/personality/set: require() only checks session_id (not name)
  so clearing with name='' works correctly instead of 400.
- POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to
  prevent unbounded context window consumption.
- POST /api/personality/set: return personality:null (not '') when cleared.
- streaming.py: same MAX_FILE_BYTES guard before prepending to system msg.

Added tests/test_sprint28.py: 11 tests for API round-trip, listing,
symlink guard, path traversal rejection, clear, size cap, persistence.
Tests pass in isolation; full-suite run has a test-isolation interaction
with shared server state across sprint tests (tracked as follow-up).

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:16:37 -07: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
Nathan Esquenazi
4622b64ca9 fix: tool call cards persist correctly after page reload (#141)
* fix: tool call cards persist correctly after page reload

Root cause: the insertion logic looked for the NEXT assistant row to
insert BEFORE, but when the triggering assistant message contained only
tool_use blocks (no text), it was filtered from the DOM by msgContent()
and no anchor row existed. Cards were silently dropped.

Fix: find the row AT the assistant_msg_idx first (exact match), fall
back to the nearest preceding visible row, then insert AFTER it (not
before the next). This handles both text+tool and tool-only assistant
messages correctly.

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

* fix: tool card ordering — role-filter fallback anchor, preserve order for same-anchor groups

Two bugs in the initial PR:
- Fallback 'nearest row' had no role filter, could anchor to a user message
  row causing cards to appear after a user bubble. Fixed: check role==='assistant'
  in the fallback loop.
- Back-to-back tool-card groups sharing a filtered anchor were inserted in
  reverse order because each group re-read anchorRow.nextSibling from the live
  DOM. Fixed: track the last inserted node per anchor via anchorInsertAfter Map,
  so each subsequent group appends after the previous one.

Also restores the 'Collect card elements before they get moved to DOM' comment
explaining why Array.from() is used on frag before DOM insertion.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:02:25 -07:00
nesquena-hermes
89891c65c8 docs: v0.36.3 version bump and test count update (449 tests) (#137)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 08:21:04 -07:00
Nathan Esquenazi
173261c428 Merge pull request #136 from nesquena/docs/changelog-v0.36.3
Some checks failed
Release & Docker / release (push) Has been cancelled
docs: update CHANGELOG with v0.36.3 configurable bot name
2026-04-06 08:14:59 -07:00
Nathan Esquenazi
863dc4e938 docs: update CHANGELOG with v0.36.3 configurable bot name
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:14:40 -07:00
Nathan Esquenazi
4407c3097b Merge pull request #135 from nesquena/fix/pr-131-bot-name
fix: harden bot_name feature from PR #131 — crash guard, XSS, tests
2026-04-06 08:13:49 -07:00
Nathan Esquenazi
71dd691ed0 fix: harden bot_name — crash guard, XSS escape, sanitization, tests
- Move `import html` to module top (was inside function body)
- Fix IndexError crash in /login when bot_name is empty string;
  use `or 'Hermes'` fallback instead of .get() default which
  doesn't guard against stored empty string
- Add server-side sanitization in POST /api/settings: strip + default
  empty/whitespace bot_name to 'Hermes' before persisting
- Escape _bn initial char in ui.js innerHTML (esc() consistency)
- Add maxlength=64 to #settingsBotName input field
- Add tests/test_sprint27.py: 9 tests covering API round-trip,
  empty/whitespace defaults, login page rendering, and XSS escaping
2026-04-06 15:06:16 +00:00
TaraTheStar
9f3b2e113e refactor: use template vars for login page instead of string replace 2026-04-06 14:47:00 +00:00
TaraTheStar
e8a8fceb26 feat: make bot name configurable 2026-04-06 05:14:31 +00:00
nesquena-hermes
e1c2e7e3d6 docs: fix stale 433 test counts to 440
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 14:55:04 -07:00
nesquena-hermes
c6017f461b docs: v0.36.2 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 13:59:45 -07: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
nesquena-hermes
1777cf7bfe docs: v0.36.1 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 12:46:58 -07:00
Nathan Esquenazi
48ba2e79e2 fix: Enter key reliably submits login form (#124)
The login form used 'return doLogin(event)' in onsubmit, but doLogin is
async so it returns a Promise (truthy), which some browsers interpret as
'proceed with native form submit'. Changed to 'doLogin(event);return false'
and added an explicit onkeydown Enter handler on the password input as
belt-and-suspenders.

Closes #124

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:45:57 -07:00
Nathan Esquenazi
52e3fb70e0 Merge pull request #123 from nesquena/docs/update-parity-assessment
docs: update SPRINTS parity assessment from v0.21 to v0.36
2026-04-05 10:10:49 -07:00
Nathan Esquenazi
c1bbdf9aeb docs: update SPRINTS parity assessment from v0.21 to v0.36
CLI parity: ~90% -> ~95% (added profiles, CLI bridge, compaction,
self-update, git detection, slash commands, rAF throttle)

Claude parity: ~70% -> ~85% (added themes, voice, mobile responsive,
collapsible groups, context indicator, token display, Docker, subagent
delegation cards)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:10:33 -07:00
nesquena-hermes
3ca7f08b59 docs: sweep markdown for v0.36
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 10:00:45 -07:00
Nathan Esquenazi
af76622c97 Merge pull request #121 from nesquena/feat/self-update-checker
Some checks failed
Release & Docker / release (push) Has been cancelled
feat: self-update checker with one-click update
2026-04-05 09:27:39 -07:00
Nathan Esquenazi
27706367b7 docs: v0.36 release notes, version bump for self-update checker
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:27:27 -07:00
Nathan Esquenazi
beb56b1a8b fix: apply_update concurrency lock, boot.js settings-fail guard, dead workspace code, test_updates URL param
- api/updates.py: add _apply_lock to prevent concurrent stash/pull/pop
- static/boot.js: set check_for_updates:false on settings fetch failure
- static/panels.js: remove dead settingsWorkspace references (element removed from HTML)
- api/routes.py + static/boot.js: add ?test_updates=1 URL param for testing banner
  without being behind on git (localhost-only simulate endpoint)
2026-04-05 16:20:12 +00: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
nesquena-hermes
257092d107 docs: v0.35.1 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 08:31:15 -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
Nathan Esquenazi
2f01afd557 Merge pull request #115 from mangodxd/cleanup/fix-py-issues-2a3ea49e
chore: add missing type hints (10 files)
2026-04-04 23:55:21 -07:00
Nathan Esquenazi
74fcd2e0ab fix: correct 9 inaccurate type hints
- get_password_hash() -> str | None (not bool, returns hash or None)
- parse_cookie() -> str | None (not None, returns cookie value)
- Session.__init__ session_id: str (not int, uuid hex)
- Session.__init__ project_id: str (not int)
- Session.__init__ **kwargs (remove incorrect dict annotation)
- Session.load() remove -> None (returns Session | None)
- import_cli_session session_id: str (not int)
- sync_session_start session_id: str (not int)
- sync_session_usage session_id: str (not int)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:54:58 -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
3d063b08a9 rebuild: clean public history after AGENTS.md rewrite removal 2026-04-05 06:25:24 +00:00
nesquena-hermes
814e016965 Update image descriptions in README.md (#111) 2026-04-04 22:28:32 -07:00
nesquena-hermes
0119365bd8 docs: v0.35 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:27:04 -07:00
Nathan Esquenazi
39066bc614 security: fix env race, signing key, upload traversal, password hash (#106)
* security: fix four audit findings -- env race, signing key, upload traversal, password hash

1. Race condition in os.environ (HIGH): Per-session _agent_lock didn't
   prevent cross-session env writes from interleaving. Added global
   _ENV_LOCK in streaming.py that serializes the entire env save/restore
   block across all sessions.

2. Predictable signing key (MEDIUM): sha256(STATE_DIR) was deterministic.
   Now generates a random 32-byte key on first startup and persists it to
   STATE_DIR/.signing_key (chmod 600). Existing sessions invalidated on
   first restart (acceptable for a security fix).

3. Upload path traversal (MEDIUM): Filename '..' survived the regex
   sanitization (dots are allowed chars). Added explicit rejection of
   dot-only names and safe_resolve_ws() check to verify the resolved
   path stays within the workspace.

4. Weak password hashing (MEDIUM): Replaced bare SHA-256 with PBKDF2-
   SHA256 (600k iterations per OWASP). Uses stdlib hashlib.pbkdf2_hmac,
   no new dependencies. Note: existing passwords must be re-set after
   this change (hash format changed).

Closes #106

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

* fix: use random signing key as PBKDF2 salt (replaces predictable STATE_DIR salt)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:25:08 -07:00
nesquena-hermes
853b23cd14 docs: README screenshot refresh + full markdown sweep (v0.34.3, 433 tests, Sprint 26 completed)
* Revise images and enhance layout description in README

Updated images and added new content to the layout section.

* docs: markdown sweep -- v0.34.3, 433 tests, Sprint 26 completed, custom themes row restored

- THEMES.md: restore custom themes row removed by PR #105
- ROADMAP.md: bump version/tests to v0.34.3/433; mark themes [x]; add v0.34/v0.34.1/v0.34.2/v0.34.3 to sprint history table
- SPRINTS.md: Sprint 26 marked COMPLETED; version bumped to v0.34.3; horizon sprint updated to Sprint 25 (Desktop)
- TESTING.md: coverage updated to Sprint 26 / v0.34.3; test count corrected to 433; port corrected to 8786

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:22:32 -07:00
nesquena-hermes
cf3ccb0666 docs: v0.34.3 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:12:37 -07:00
Nathan Esquenazi
2f58724863 fix: light theme final polish -- sidebar, roles, chips, all interactive elements
* fix: light theme sidebar, roles, chips, active states -- full polish

Comprehensive light theme overrides for every remaining hardcoded
dark-theme element:

Sidebar:
- Session items: warm dark text instead of faint muted gray
- Active session: blue accent (matching --blue) instead of washed-out gold
- Pin stars/headers: deep gold #996b15 instead of bright yellow #f5c542
- Session actions gradient: light bg instead of dark overlay
- Search input: dark borders, proper focus ring

Role labels:
- You: solid #2d6fa3 blue instead of faint rgba(124,185,255,0.65)
- Hermes: solid #8a6520 gold instead of faint rgba(201,168,76,0.6)
- Role icons: proper bg/border contrast for light backgrounds

Chips and interactive elements:
- Project chips: dark borders, dark hover states
- Model chip: blue accent matching theme
- New chat button: blue accent borders
- All hover states: rgba(0,0,0,.XX) instead of rgba(255,255,255,.XX)

Other surfaces:
- Composer box borders and focus ring
- Tool cards, cron items, suggestions
- File tree hover, preview badges
- Profile/workspace dropdown hovers
- Settings, nav tooltips

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

* docs: update THEMES.md with all current CSS variables

Added typography variables (--strong, --em, --code-text, --code-inline-bg,
--pre-text) to the custom theme guide. Added note about light theme
selector overrides needed for hover/border contrast.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:11:42 -07:00
nesquena-hermes
a3f4ad7111 docs: update THEMES.md for theme contract
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:05:50 -07:00
nesquena-hermes
0ed2981205 docs: v0.34.2 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:00:15 -07:00
Nathan Esquenazi
5762aaafba fix: theme-aware text colors -- light mode readable, all themes polished
Added 5 new CSS variables to every theme block:
--strong, --em, --code-text, --code-inline-bg, --pre-text

Light theme: dark brown text, warm gray italics, saddle brown code on
subtle bg. All previously invisible text is now readable.

All themes get palette-appropriate values matching their design language
(Solarized orange, Monokai yellow, Nord green, etc).

Also fixed: remaining white borders to var(--border), light scrollbar,
code-bg contrast, settings overlay, approval card text.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:59:12 -07:00
nesquena-hermes
3294e54e00 docs: v0.34.1 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 21:45:23 -07:00
Nathan Esquenazi
2eddef3275 fix: replace 30+ hardcoded dark-navy colors with theme CSS variables
Root cause: topbar, dropdowns, toast, approval card, tooltips, main area,
inputs, and hover states all used hardcoded rgba(22,33,62), #1a2535, etc.
These only looked correct on the Dark theme — all other themes showed
jarring dark-navy elements on non-navy backgrounds.

New CSS variables added to every theme block:
- --surface: dropdowns, popups, toast, approval card
- --topbar-bg: topbar background
- --main-bg: main chat area background
- --input-bg: subtle input/button backgrounds
- --hover-bg: hover state backgrounds
- --focus-ring / --focus-glow: focus border and box-shadow

Light theme now has proper light-colored surfaces, inputs, and hover
states instead of invisible white-on-white.

THEMES.md updated with all new variables documented.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:43:53 -07:00
Nathan Esquenazi
7bcd6623e9 Merge pull request #98 from nesquena/sprint-26-themes
Some checks failed
Release & Docker / release (push) Has been cancelled
Sprint 26: Pluggable UI themes — dark, light, solarized, monokai, nord
2026-04-04 21:13:10 -07:00
Nathan Esquenazi
82a942a2b1 docs: v0.34 release — themes CHANGELOG, README, add light to picker
- CHANGELOG: v0.34 Sprint 26 entry (6 themes, /theme command, settings UX)
- README: themes section, updated slash commands, THEMES.md in docs list
- THEMES.md: added Slate to theme table, matches actual CSS/dropdown
- commands.js: added 'light' to /theme valid list, updated description
- index.html: added Light option to theme dropdown, version v0.34
- SPRINTS/CHANGELOG footers updated to v0.34 / 433 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:13:01 -07:00
Nathan Esquenazi
805fa296c8 fix: cut light theme from picker, shorten Save button label 2026-04-05 04:06:02 +00:00
Nathan Esquenazi
b8b063f325 fix: settings panel taller -- show Save button without scrolling 2026-04-05 04:01:56 +00:00
Nathan Esquenazi
882fc947e5 fix: settings unsaved-changes guard, add Slate theme, improve Light theme
Unsaved-changes guard:
- _closeSettingsPanel() intercepts all three close paths (X button, overlay
  click, Escape key) and checks _settingsDirty before closing
- If dirty: shows inline 'Unsaved changes' bar with Save & Close / Discard
- Discard reverts the live theme preview to what it was when panel opened
- _markSettingsDirty() wired to all inputs via addEventListener in loadSettingsPanel()
- saveSettings() now resets dirty flag and hides the bar on successful save

Theme improvements:
- Add 'Slate' theme: warm charcoal (#2b2d30 bg), a softer/lighter dark option
  that sits between Dark and the full light themes
- Rework 'Light' theme: replace pure white (#f5f5f7) with warm off-white
  (#f0ede8) -- warmer, lower contrast, less harsh on most displays
- Update /theme command to include 'slate' in valid list
- Add test_settings_set_theme_slate() to test_sprint26.py
2026-04-05 04:00:24 +00: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
nesquena-hermes
d10871c0e4 docs: Sprint 26 themes plan + Sprint 12/13/23/24 cleanup
- Sprint 12 and 13 headers: add missing (COMPLETED) labels
- Sprint 23 header: corrected from 'Profile/Workspace/Model Coherence' to
  'Agentic Transparency + Context Visibility' (what it actually shipped)
- Sprint 24 Track C: removed stale self-referential cleanup items that are now done
- Sprint 26 added: full plan for pluggable UI themes (light/dark/solarized/monokai/nord)
  including CSS variable architecture, flicker prevention, /theme slash command,
  settings picker with live preview, and test spec
- ROADMAP.md: add v0.32/v0.33 to sprint history table, add Sprint 25/26 to feature checklist
- SPRINTS.md footer: add horizon sprint line

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 20:36:42 -07:00
nesquena-hermes
6d4c258d90 docs: v0.33 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 20:09:59 -07:00
nesquena-hermes
c312dd36ca fix: state_sync.py -- correct class name, constructor type, title API, connection leak
Three bugs found during review:
1. Class is SessionDB not HermesState -- would silently no-op on every install
2. SessionDB.__init__ takes Path not str -- would crash with AttributeError
3. _execute_write() takes a callable not SQL+params -- wrong signature.
   Replaced with public set_session_title() API.
4. Each call opened a persistent SQLite connection and never closed it.
   Added try/finally db.close() to prevent WAL leak under sustained load.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 20:08:20 -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
302 changed files with 83453 additions and 4936 deletions

View File

@@ -26,3 +26,6 @@
# Path to your Hermes config.yaml (for toolsets and model config)
# HERMES_CONFIG_PATH=~/.hermes/config.yaml
# Display name for the assistant in the UI (default: Hermes)
# HERMES_WEBUI_BOT_NAME=Hermes

View File

@@ -52,5 +52,6 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: HERMES_VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max

30
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Tests
on:
pull_request:
branches: [master]
push:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyyaml>=6.0 pytest pytest-timeout
- name: Run tests
run: pytest tests/ -v --timeout=60

23
.gitignore vendored
View File

@@ -16,12 +16,33 @@ archive/
.env
.env.*
!.env.example
.claude/*
.claude/
CLAUDE.md
AGENTS.md
.cursorrules
.windsurfrules
.aider*
copilot-instructions.md
# Generated screenshots and transient artifacts
screenshot-*.png
full-UI.png
# Version file written by Docker/CI build — generated, never committed
api/_version.py
# OS files
.DS_Store
Thumbs.db
# Local reference clones — never committed (except tracked design/UI-UX reference pages)
docs/*
!docs/ui-ux/
!docs/ui-ux/**
# Local-only PR review harness: rendering drivers, sample bank, fixtures.
# Used by Claude during deep reviews; never shared in the repo.
.local-review/
graphify-out/
.graphify_cached.json
.graphify_uncached.txt

View File

@@ -1,53 +0,0 @@
# Web UI MVP Instructions
Canonical source: <repo>/
Symlink (for imports): <agent-dir>/webui-mvp -> <repo>
Runtime state: ~/.hermes/webui-mvp/sessions/
Purpose:
- Claude-style web UI for Hermes. Chat, workspace file browser, cron/skills/memory viewers.
Start server:
cd <agent-dir>
nohup venv/bin/python <repo>/server.py > /tmp/webui-mvp.log 2>&1 &
# OR: <repo>/start.sh
Run tests:
cd <agent-dir>
venv/bin/python -m pytest <repo>/tests/ -v
Health check: curl http://127.0.0.1:8787/health
Logs: tail -f /tmp/webui-mvp.log
SSH tunnel from Mac: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>
Living documents (always update after a sprint):
<repo>/ROADMAP.md
<repo>/ARCHITECTURE.md
<repo>/TESTING.md
Sprint process skill: webui-sprint-loop
# Workspace Convention (Web UI Sessions)
When running as an agent invoked from the web UI, each user message is prefixed with:
[Workspace: /absolute/path/to/workspace]
This tag is the single authoritative source of the active workspace. It reflects
whichever workspace the user has selected in the UI at the moment they sent that message.
It updates on every message, so if the user switches workspaces mid-session, the very
next message will carry the new path. Always use the value from the most recent tag.
This tag overrides any prior workspace mentioned in the system prompt, memory, or
conversation history. Never infer or fall back to a hardcoded path like
~/workspace when this tag is present.
Apply it as the default working directory for ALL file operations:
- write_file: resolve relative paths against this workspace
- read_file / search_files: resolve paths relative to this workspace
- terminal workdir: set to this path unless the user explicitly says otherwise
- patch: resolve file paths relative to this workspace
If no [Workspace: ...] tag is present (e.g., CLI sessions), fall back to
~/workspace as the default.

View File

@@ -7,20 +7,42 @@
>
> Keep this document updated as architecture changes are made.
> Current shipped build: `v0.50.36-local.1` (April 16, 2026).
> Baseline: upstream `nesquena/hermes-webui` `v0.50.36`.
> Intentional local delta: first-time password enablement from Settings immediately issues a `hermes_session` cookie so the current browser remains signed in. The previous `Assistant Reply Language` customization has been removed, legacy `assistant_language` settings are filtered out on load/save, the workspace panel closed/open state is preloaded via a `documentElement` dataset marker before `style.css` paints to avoid a first-load desktop flash, transcript disclosure cards now animate caret rotation and body expansion with transitionable `max-height`/`opacity` states instead of `display:none/block`, and thinking cards now share the same rounded bordered card chrome as tool cards while keeping their gold palette.
> Automated coverage: 1353 tests collected (`pytest tests/ --collect-only -q`).
---
## 1. Overview and Purpose
The Hermes Web UI is a lightweight web application that gives you a browser-based
interface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on
the Claude-style interface: a three-panel layout with a sidebar for session management,
a central chat area, and a right panel for workspace file browsing.
the Claude-style interface: a sidebar for session management, a central chat area,
and a demand-driven right panel used for workspace browsing and preview surfaces.
The right panel is closed by default on desktop and opens only when it is actively
being used for browsing or previewing content.
To prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the
saved workspace panel state into `document.documentElement.dataset.workspacePanel`
before the main stylesheet loads. Desktop CSS honors that preload marker immediately,
and `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.
The design philosophy is deliberately minimal. There is no build step, no bundler, no
frontend framework. The Python server is split into a routing shell (server.py) and
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
This makes the code easy to modify from a terminal or by an agent.
For the current local build, the codebase is intentionally as close to upstream as possible:
the app now tracks upstream `v0.50.36`, keeps the password-session continuity patch in the
settings/onboarding flow, and does not carry forward the prior reply-language preference
feature.
Hermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.
Instead, the footer exposes a single "Hermes WebUI" launch button that opens one tabbed
control-center modal for global preferences, conversation import/export, and clear-conversation
actions. The topbar remains focused on conversation context and the workspace/files toggle.
---
## 2. File Inventory
@@ -28,7 +50,8 @@ This makes the code easy to modify from a terminal or by an agent.
<repo>/
server.py Thin routing shell + HTTP Handler + auth middleware. ~81 lines.
Delegates all route handling to api/routes.py.
start.sh Discovery script: finds agent dir, Python, starts server.
bootstrap.py One-shot launcher: optional agent install, deps, health wait, browser open.
start.sh Thin wrapper around bootstrap.py for shell-based startup.
Dockerfile python:3.12-slim container image (~23 lines)
docker-compose.yml Compose config with named volume and optional auth (~22 lines)
.dockerignore Excludes .git, tests/, .env* from Docker builds
@@ -39,7 +62,9 @@ This makes the code easy to modify from a terminal or by an agent.
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers (~71 lines)
models.py Session model + CRUD, per-session profile tracking (~137 lines)
profiles.py Profile state management, hermes_cli wrapper (~246 lines)
onboarding.py First-run onboarding status, real provider config writes, and readiness detection.
routes.py All GET + POST route handlers (~1180 lines)
startup.py Startup helpers: auto_install_agent_deps() (~50 lines)
streaming.py SSE engine, run_agent, cancel, HERMES_HOME save/restore (~236 lines)
upload.py Multipart parser, file upload handler (~78 lines)
workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines)
@@ -48,11 +73,12 @@ This makes the code easy to modify from a terminal or by an agent.
style.css All CSS incl. mobile responsive (~670 lines)
ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~977 lines)
workspace.js File preview, file ops, loadDir, clearPreview (~185 lines)
sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~533 lines)
sessions.js Session CRUD, list rendering, search, SVG icons, dropdown actions (~533 lines)
messages.js send(), SSE event handlers, approval, transcript (~297 lines)
panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines)
commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
boot.js Event wiring, mobile nav, voice input, boot IIFE (~338 lines)
onboarding.js First-run wizard overlay, provider setup flow, and settings/workspace orchestration.
boot.js Event wiring, mobile sidebar/workspace nav, voice input, boot IIFE (~338 lines)
tests/
conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines)
test_sprint{1-20b}.py Feature tests per sprint (21 files, 415 test functions)
@@ -347,7 +373,7 @@ highlighting) and Mermaid.js (diagrams) from CDN, both loaded async/deferred wit
Six JS modules loaded in order at end of <body>:
1. ui.js (~846 lines) DOM helpers, renderMd, tool card rendering, global state
2. workspace.js (~169 lines) File tree, preview, file operations
3. sessions.js (~532 lines) Session CRUD, list rendering, search, SVG icons, overlay actions, project picker
3. sessions.js (~532 lines) Session CRUD, list rendering, search, SVG icons, dropdown actions, project picker
4. messages.js (~293 lines) send(), SSE event handlers, approval, transcript
5. panels.js (~771 lines) Cron, skills, memory, workspace, todo, switchPanel
6. boot.js (~175 lines) Event wiring + boot IIFE
@@ -358,10 +384,19 @@ inherit `currentColor` for consistent theming.
Three-panel layout (in static/index.html):
<aside class="sidebar"> Left panel: session list, nav tabs, model selector
<aside class="sidebar"> Left panel: session list, nav tabs, sidebar-footer Hermes WebUI trigger
<main class="main"> Center: topbar, messages area, approval card, composer
<aside class="rightpanel"> Right panel: workspace file tree and file preview
Composer footer layout (current):
left cluster attach button, mic button, per-conversation model selector
right cluster compact circular context-usage badge, send button
The model selector is still the authoritative control for new-session creation
and session updates; it was moved out of the sidebar so model choice feels scoped
to the active conversation rather than a global app setting.
### 5.2 Global State
const S = {
@@ -406,11 +441,19 @@ Approval:
stopApprovalPolling clearInterval
UI helpers:
setStatus(t) Updates #statusText in composer footer
setStatus(t) Fallback helper: shows a toast for non-chat status/error messages
setComposerStatus(t) Updates the inline composer status label for turn-scoped states
setBusy(v) Sets S.busy, disables/enables Send button, clears status on false
showToast(msg, ms) Bottom-center fade toast (default 2800ms)
showConfirmDialog(o) Shared in-app confirmation modal, resolves true/false
showPromptDialog(o) Shared in-app input modal, resolves string/null
autoResize() Auto-resize #msg textarea up to 200px
Dialog policy:
Native browser confirm()/prompt() are not used in the Web UI.
Destructive actions use showConfirmDialog(...), then a toast on success.
Lightweight naming flows (new file/folder/project) use showPromptDialog(...).
Files:
loadDir(path) GET /api/list, rebuild #fileTree
openFile(path) GET /api/file, show in #previewArea
@@ -463,7 +506,7 @@ Known gaps:
- Nested lists: single regex pass, multi-level indentation not handled
- Mixed bold+link in same line: may produce garbled output
### 5.5 Model Chip Label (Fixed in Sprint 1)
### 5.5 Model Label Resolution (Fixed in Sprint 1, reused by composer selector)
B3 was resolved in Sprint 1. Current code uses a MODEL_LABELS dict:
@@ -474,10 +517,10 @@ B3 was resolved in Sprint 1. Current code uses a MODEL_LABELS dict:
'anthropic/claude-haiku-3-5': 'Haiku 3.5', 'google/gemini-2.5-pro': 'Gemini 2.5 Pro',
'deepseek/deepseek-chat-v3-0324': 'DeepSeek V3', 'meta-llama/llama-4-scout': 'Llama 4 Scout',
};
$('modelChip').textContent = MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
getModelLabel(m) => MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
Fallback: any unlisted model shows its short ID (after the last /) rather than a wrong label.
To add a new model: add an entry to MODEL_LABELS and add an <option> to the <select>.
To add a new model: add an entry to MODEL_LABELS and add an <option> to the composer footer <select>.
### 5.6 Session Delete Rules (from skill)
@@ -1095,7 +1138,7 @@ The model chip label bug is now fixed. The MODEL_LABELS object in syncTopbar():
'deepseek/deepseek-chat-v3-0324': 'DeepSeek V3',
'meta-llama/llama-4-scout': 'Llama 4 Scout',
};
$('modelChip').textContent = MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
getModelLabel(m) => MODEL_LABELS[m] || (m.split('/').pop() || 'Unknown');
Fallback: splits on '/' and uses the last segment, so any unlisted model shows its
short identifier rather than a wrong hardcoded label.
@@ -1586,3 +1629,19 @@ and #rightpanelResize. On mousemove: computes delta and clamps to min/max. On mo
saves width to localStorage. Widths restored at boot via localStorage.getItem().
CSS: .resize-handle with position:absolute, width:5px, cursor:col-resize.
body.resizing added during drag to suppress text selection.
## Workspace path trust levels
`api/workspace.py` has two distinct trust functions — do not collapse them:
**`validate_workspace_to_add(path)`** — used by `/api/workspaces/add` (explicit user registration).
Permissive: blocks only non-existent, non-directory, and system root paths. The user is
consciously registering an external path (e.g. `/mnt/d/Projects` in WSL), so we trust intent.
**`resolve_trusted_workspace(path)`** — used for actual file read/write operations inside
an existing workspace. Strict: path must be under home, in the saved workspace list, or under
`BOOT_DEFAULT_WORKSPACE`. Prevents path traversal and unauthorized file access.
The distinction matters because add uses permissive validation to avoid the circular
dependency: you cannot get a path into the saved list if you need the saved list to add it.

12
BUGS.md
View File

@@ -10,6 +10,18 @@ This file tracks UI bugs and polish items. Fixed items are kept for reference.
---
## Known Limitations
- **Two-container Docker setup: tools run in WebUI container** — In the two-container setup (hermes-agent + hermes-webui as separate containers), WebUI-initiated agent sessions run tools in the WebUI container, not the agent container. This is a known architectural constraint. Workaround: use the combined single-image approach, or initiate sessions via the CLI in the agent container. (#681)
- **Image-in-chat vs. saved-to-workspace mismatch** — When the agent displays an inline image (from a URL) and the user asks it to save that image, the agent issues a fresh download which may return a different file if the source URL is CDN-rotated or parameterized. The WebUI correctly renders whatever URL the agent provides. Fix requires agent-side URL caching. (#641)
- **MCP tools not available in WebUI sessions** — MCP servers must be configured in the active profile's config.yaml under mcp_servers:. If MCP tools are not appearing, check that the profile is correct and the MCP server process is reachable from inside the WebUI container. (#628)
- **os.environ race condition in concurrent sessions** — Concurrent agent sessions share process-level os.environ for TERMINAL_CWD, HERMES_SESSION_KEY, and HERMES_HOME. _ENV_LOCK serializes mutations but does not fully isolate env vars during agent execution. Upstream fix pending in hermes-agent. (#195)
---
## Fixed
### ~~Session title truncation / hover actions~~ -- Fixed (Sprint 16)

File diff suppressed because it is too large Load Diff

171
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,171 @@
# Contributing to Hermes WebUI
Thanks for contributing.
Hermes WebUI is intentionally simple to work on: Python on the server, vanilla JS in the browser, no build step, no bundler, no frontend framework. The best pull requests preserve that simplicity while solving a real problem cleanly.
## Two Paths to a Strong Pull Request
### Path 1: Small, Focused Changes
This is the fastest path to review and merge.
- Fix one clear bug or add one tightly scoped improvement
- Touch the fewest files you can
- Avoid drive-by refactors mixed into functional changes
- Run the relevant tests locally before opening the PR
- Keep the PR description concise and specific
These are the changes that are easiest to review and safest to merge quickly.
### Path 2: Bigger Changes
If you want to change architecture, reshape a workflow, add a substantial UI feature, or alter core behavior, align on direction first.
- Open an issue, start a discussion, or open a draft PR early
- Explain the problem you are solving, not just the implementation you want
- Call out tradeoffs, migration risk, and any alternatives you considered
- Keep the final PR easy to review by separating unrelated work
Large changes are welcome, but surprise rewrites are hard to review well.
## What We Expect in Every PR
### 1. One Logical Change Per PR
Keep each PR focused. A small related group of fixes is fine. A bug fix plus a CSS cleanup plus a refactor plus a docs rewrite is not.
### 2. Local Verification
Run the test suite locally:
```bash
pytest tests/ -v --timeout=60
```
CI also runs this suite on Python `3.11`, `3.12`, and `3.13`.
If your change affects browser behavior, also run the relevant manual checks from [TESTING.md](TESTING.md).
### 3. Clear PR Description
There is currently no PR template in this repo, so include the important sections yourself:
- Thinking Path
- What Changed
- Why It Matters
- Verification
- Risks / Follow-ups
- Model Used
If the change is user-visible, include screenshots or a short video.
For UI or UX changes, before/after images are required. PRs that change the interface or interaction flow without before/after images will likely be ignored, or closed in a regular maintainer sweep without review.
### 4. AI Usage Disclosure
If AI helped produce the change, say so in the PR description.
Include:
- Provider
- Exact model name or ID
- Any notable mode or tool use that mattered
If no AI was used, write: `None — human-authored`.
### 5. Keep the Docs Honest
If your change alters behavior, architecture, testing, setup, or user-facing workflows, update the relevant docs in the same PR.
Common files:
- [README.md](README.md) for setup, usage, and contributor-facing commands
- [ROADMAP.md](ROADMAP.md) for shipped features and sprint history
- [ARCHITECTURE.md](ARCHITECTURE.md) for implementation details and design constraints
- [TESTING.md](TESTING.md) for manual and automated verification guidance
- [CHANGELOG.md](CHANGELOG.md) when maintainers want release-note-ready entries
## Project-Specific Guidelines
### Preserve the Design Constraints
Hermes WebUI is deliberately:
- No build step
- No bundler
- No frontend framework
- Easy to modify from a terminal
Do not introduce new infrastructure or dependencies unless the gain is clear and the tradeoff is justified.
### Match the Existing Shape of the Codebase
- Server logic belongs in `api/` with `server.py` staying thin
- Frontend behavior belongs in the existing `static/*.js` modules
- Prefer extending current patterns over introducing parallel abstractions
- Keep changes legible to future contributors working directly from the repo in a terminal
### Be Careful With User-Facing Changes
This project is heavily UI-driven. If you change interaction flows, session behavior, workspace browsing, onboarding, or mobile layouts:
- test the happy path
- test reload behavior where relevant
- test narrow/mobile layouts where relevant
- include before/after images in the PR
### Security and Safety Matter
This app can expose workspace contents, run agent actions, and optionally sit behind a reverse proxy or Docker deployment. Treat auth, path handling, uploads, streaming, and environment handling as high-risk areas.
If your PR touches security-sensitive behavior, say so explicitly in the PR description and explain how you verified it.
## Writing a Good PR Message
Start with a short Thinking Path that explains the chain from project goal to the specific fix.
Example:
> - Hermes WebUI aims for near 1:1 parity with the Hermes CLI in a browser
> - Long-running chat turns rely on SSE streaming and session recovery
> - Reloading during an in-flight turn can leave the UI in an inconsistent state
> - The bug was that recovered sessions restored messages but not the live stream state
> - This PR fixes the recovery path so in-flight turns reconnect cleanly after reload
> - The benefit is that users can refresh or reconnect without losing visibility into active work
Another example:
> - Hermes WebUI is intentionally a simple Python + vanilla JS application
> - The right panel is used for workspace browsing and previews
> - On mobile, panel state changes need to be obvious and touch-friendly
> - The existing close affordance was inconsistent with the bottom-nav flow
> - This PR fixes the mobile panel close behavior and aligns it with the current navigation model
> - The result is fewer dead-end UI states on phones
After that, cover:
- what you changed
- why you changed it
- how you verified it
- what risks remain
## Review Tips
Want the smoothest review?
- Keep diffs tight
- Name things clearly
- Avoid unnecessary rewrites
- Add short comments only where the code would otherwise be hard to follow
- Respond directly to review feedback and update the PR description if the scope changes
## Development References
- [README.md](README.md)
- [ARCHITECTURE.md](ARCHITECTURE.md)
- [TESTING.md](TESTING.md)
- [ROADMAP.md](ROADMAP.md)
- [SPRINTS.md](SPRINTS.md)
Questions are best raised early, before a large change is finished.

View File

@@ -3,21 +3,94 @@ FROM python:3.12-slim
LABEL maintainer="nesquena"
LABEL description="Hermes Web UI — browser interface for Hermes Agent"
WORKDIR /app
# Install system packages
ENV DEBIAN_FRONTEND=noninteractive
# Copy source
COPY . /app
# Make use of apt-cacher-ng if available
RUN if [ "A${BUILD_APT_PROXY:-}" != "A" ]; then \
echo "Using APT proxy: ${BUILD_APT_PROXY}"; \
printf 'Acquire::http::Proxy "%s";\n' "$BUILD_APT_PROXY" > /etc/apt/apt.conf.d/01proxy; \
fi \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget gnupg \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update -y --fix-missing --no-install-recommends \
&& apt-get install -y --no-install-recommends \
apt-utils \
locales \
ca-certificates \
sudo \
curl \
rsync \
openssh-client \
&& apt-get upgrade -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# UTF-8
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
ENV LANG=en_US.utf8
ENV LC_ALL=C
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONIOENCODING=utf-8
WORKDIR /apptoo
# Every sudo group user does not need a password
RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Create a new group for the hermeswebui and hermeswebuitoo users
RUN groupadd -g 1024 hermeswebui \
&& groupadd -g 1025 hermeswebuitoo
# The hermeswebui (resp. hermeswebuitoo) user will have UID 1024 (resp. 1025),
# be part of the hermeswebui (resp. hermeswebuitoo) and users groups and be sudo capable (passwordless)
RUN useradd -u 1024 -d /home/hermeswebui -g hermeswebui -s /bin/bash -m hermeswebui \
&& usermod -G users hermeswebui \
&& adduser hermeswebui sudo
RUN useradd -u 1025 -d /home/hermeswebuitoo -g hermeswebuitoo -s /bin/bash -m hermeswebuitoo \
&& usermod -G users hermeswebuitoo \
&& adduser hermeswebuitoo sudo
RUN chown -R hermeswebuitoo:hermeswebuitoo /apptoo
USER root
COPY --chmod=555 docker_init.bash /hermeswebui_init.bash
RUN touch /.within_container
# Remove APT proxy configuration and clean up APT downloaded files
RUN rm -rf /var/lib/apt/lists/* /etc/apt/apt.conf.d/01proxy \
&& apt-get clean
USER root
# Pre-install uv system-wide so the container doesn't need internet access at runtime.
# Installing as root places uv in /usr/local/bin, available to all users.
# The init script will skip the download when uv is already on PATH.
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
USER hermeswebuitoo
COPY --chown=hermeswebuitoo:hermeswebuitoo . /apptoo
# Bake the git version tag into the image so the settings badge works even
# when .git is not present (it is excluded by .dockerignore).
# CI passes: --build-arg HERMES_VERSION=$(git describe --tags --always)
# Local builds that omit the arg get "unknown" as the fallback.
ARG HERMES_VERSION=unknown
RUN echo "__version__ = '${HERMES_VERSION}'" > /apptoo/api/_version.py
# Default to binding all interfaces (required for container networking)
ENV HERMES_WEBUI_HOST=0.0.0.0
ENV HERMES_WEBUI_PORT=8787
# State directory (mount as volume for persistence)
ENV HERMES_WEBUI_STATE_DIR=/data
EXPOSE 8787
CMD ["python", "server.py"]
CMD ["/hermeswebui_init.bash"]

552
HERMES.md
View File

@@ -1,165 +1,176 @@
# Why Hermes
Hermes is a persistent, autonomous AI agent that lives on your server. It remembers everything,
schedules work while you sleep, and gets more capable the longer it runs. This document explains
the mental model, why that matters, and how Hermes compares to every major AI tool available today.
Hermes is a persistent, autonomous AI agent that runs on your server. It has layered memory that
accumulates across sessions, a cron scheduler that fires jobs while you're offline, and a
self-improving skills system that saves reusable procedures automatically. You reach it from a
terminal, a browser, or a messaging app — and it's the same agent with the same history every time.
This document explains the mental model, how Hermes compares to other tools honestly, and where
it is and is not the right choice.
---
## The Core Idea: Assistants Forget. Agents Don't.
## The real problem: most tools are excellent in the moment and weak over time
Every time you open Claude Code, Codex, or a chat window, the tool starts from zero. It does not
know who you are, what you worked on yesterday, how your repo is structured, or what bugs you
already fixed. You re-explain yourself every single session. The tool is powerful in the moment
and useless the next day.
Memory is no longer a differentiator on its own. ChatGPT, Claude, Cursor, and GitHub Copilot all
have some form of memory now. Anthropic, OpenAI, and Microsoft are all shipping scheduling and
agent features. The category boundaries that existed twelve months ago are blurring fast.
Hermes fills that gap. It runs on your server, retains context across every session, and acts
on your behalf whether or not you are at a keyboard.
Hermes is not the only tool with memory or automation. It is the tool that makes those
capabilities durable, self-hosted, cross-surface, and cumulative on your own server. The
distinction that matters is not "has memory" vs. "has no memory" — it's whether context persists
across sessions automatically, whether execution happens on hardware you control, whether you can
reach the same agent identity from any device, and whether the system gets meaningfully better at
your specific workflow over time without manual configuration.
```
Assistant model: You -> [Tool] -> Answer -> Done
(tool forgets everything when the window closes)
Session-scoped: You -> [Tool] -> Answer -> Done
(some tools now carry memory, but the execution is stateless)
Agent model: You <-> [Hermes] <-> (memory, skills, schedule, tools)
(persistent, learns your stack, acts on your behalf, runs while you're offline)
Persistent agent: You <-> [Hermes] <-> (memory, skills, schedule, tools, surfaces)
(runs on your server, accumulates context, acts on your behalf offline)
```
---
## The Three Pillars
## A note on convergence
### 1. Memory That Compounds
The market is converging. Chat assistants are adding task scheduling and file connectors. IDE
tools are launching cloud agent modes. CLI tools are adding skills systems and mobile surfaces.
The lines between "assistant," "editor," and "agent" are dissolving.
Hermes has layered memory that survives every session, every reboot, every model swap:
This makes comparisons harder but also makes the question sharper: what actually matters when
every tool is claiming some version of every feature? For Hermes, the answer is synthesis. Any
single feature — memory, scheduling, messaging — is available somewhere else. The value is
having all of them in one self-hosted system, running continuously, with a persistent identity
that accumulates real knowledge of your stack over time.
- **User profile** -- who you are, your preferences, your communication style, things you've
corrected Hermes on
- **Agent memory** -- facts about your environment, your toolchain, your project conventions
- **Skills** -- reusable procedures Hermes discovers and saves; it never has to relearn how to
deploy your app, run your tests, or review a PR
- **Session history** -- every past conversation is searchable; Hermes can recall what you
worked on last Tuesday
---
## The three pillars
### 1. Memory that compounds
Hermes has layered memory that survives every session, every reboot, and every model swap:
- User profile — who you are, your preferences, your communication style, things you've corrected Hermes on
- Agent memory — facts about your environment, your toolchain, your project conventions
- Skills — reusable procedures Hermes discovers and saves automatically; it never has to relearn how to deploy your app, run your tests, or review a PR
- Session history — every past conversation is searchable; Hermes can recall what you worked on last Tuesday
When you correct Hermes, it remembers. When it solves a tricky problem, it saves the approach.
When it learns your stack, that knowledge carries into every future session.
When it learns your stack, that knowledge carries into every future session. You never configure
this manually — it happens in the background as a side effect of normal use.
### 2. Autonomous Scheduling
### 2. Autonomous scheduling
Hermes can run jobs without you present -- every hour, every morning, on any cron schedule.
It fires up a fresh session, runs the task, and delivers the result to wherever you want it:
Telegram, Discord, Slack, Signal, WhatsApp, SMS, email, and more.
Hermes can run jobs without you present every hour, every morning, on any cron schedule. It
fires up a fresh session with full access to your memory and skills, runs the task, and delivers
the result wherever you want it: Telegram, Discord, Slack, Signal, WhatsApp, SMS, email, and more.
Things Hermes can do while you sleep:
- Review new pull requests on your GitHub repo and post a full verdict comment
- Send you a morning briefing of news, markets, or anything else you care about
- Send a morning briefing of news, markets, or anything else you track
- Run your test suite and alert you if something breaks
- Watch a competitor's blog for new posts and summarize them
- Monitor a datasource and notify you when a threshold is crossed
### 3. Reach It From Anywhere
The difference from cloud-scheduled alternatives is that the job runs on your server, with your
memory and skills, and your data never leaves your hardware.
### 3. Reach it from anywhere
Hermes runs on your server and is reachable from every surface: terminal over SSH, the web UI
(this project), and messaging apps including Telegram, Discord, Slack, WhatsApp, Signal, and
Matrix. Start a task from your phone, check it from the browser on your laptop, continue it in
a terminal on a remote server. The same agent, memory, and history follow you everywhere.
a terminal on a remote server. The same agent, memory, and history follow you across all of them.
---
## A Framework for AI Tools
## How AI tools are layered today
There are four distinct categories of AI tool. Understanding the category tells you what a tool
can and cannot do.
The old four-category model — chat, editor, CLI, agent — is too clean. These layers are actively
collapsing into each other. Here is a more honest picture:
### Category 1: Chat Assistants
*Claude.ai, ChatGPT, Gemini*
Chat assistants (Claude.ai, ChatGPT) now have persistent memory, task scheduling, 50+ service
connectors, and in some cases full agent modes with computer use. They are no longer "just chat."
You open a window, ask something, get an answer. No persistent memory beyond the conversation,
no ability to run code or touch files, no way to act on your behalf. Excellent for Q&A,
drafting, and brainstorming. You re-explain your context every session.
IDE tools (Cursor, Windsurf, Copilot) have shipped or are shipping cross-session memory,
cloud-based background agents, and in Cursor's case a full Automations platform with Slack
integration. Cursor v3.0 (April 2026) is explicitly agent-first.
### Category 2: IDE Integrations
*GitHub Copilot, Cursor, Windsurf, Zed AI*
CLI tools (Claude Code, Codex, OpenCode) have added hooks, skills, desktop app automations,
and multi-surface reach. Claude Code now spans terminal, IDE, desktop, and browser. Codex has
become a product family: CLI, IDE extension, desktop app, and Codex Cloud.
Deep inside your editor. Autocomplete, inline diffs, refactors -- all excellent. Windsurf was
earliest with workspace-scoped memory (Cascade Memories); Copilot has been shipping repo-level
memory since late 2025 and is catching up. Cursor has no native memory as of early 2026. None
have scheduling or messaging access. Tied to one machine and one editor.
Persistent self-hosted agents (Hermes, OpenClaw) sit at the intersection: they combine the
tool-use power of CLI agents, the memory of chat assistants, the scheduling of automation
platforms, and the cross-surface reach of messaging integrations — running continuously on
hardware you own.
### Category 3: Agentic CLI Tools
*Claude Code, Codex CLI, OpenCode, Aider*
The current frontier for most developers. Can use real tools -- run shell commands, read and
write files, search the web, call APIs. Great for deep, multi-step tasks in a single terminal
session. All are adding memory and scheduling features to varying degrees (see comparisons below),
but the core model is still session-scoped: you invoke it, it works, it stops.
### Category 4: Persistent Autonomous Agents
*Hermes, OpenClaw (as of early 2026)*
All the tool use of Category 3, plus memory that accumulates across sessions, plus always-on
scheduling, plus multi-modal access from any device or messaging app. Gets more useful over time
rather than resetting to zero. Hermes and OpenClaw are the two primary open-source, self-hosted
tools in this category. OpenClaw is a gateway-centric automation platform; Hermes is a
self-improving agent that writes and reuses its own procedures from experience.
The question is not which category a tool belongs to. The question is which combination of
capabilities you actually need, where that execution lives, and whether the system gets better
at your specific context over time.
---
## How Hermes Compares
## How Hermes compares
### vs. OpenClaw
OpenClaw is the most direct comparison to Hermes and the question most people ask first.
Both are open-source, self-hosted, always-on agents with persistent memory, cron scheduling,
and messaging app integration. If you're evaluating Hermes, you should evaluate OpenClaw too.
OpenClaw is the most direct comparison and the question most people ask first. Both are
open-source, self-hosted, always-on agents with persistent memory, cron scheduling, and messaging
app integration. If you're evaluating Hermes, evaluate OpenClaw too.
OpenClaw (MIT, ~347k GitHub stars) is built around a **Gateway** control plane written in
Node.js/TypeScript. It excels at broad personal automation: native Chrome/Chromium control for
browser automation, the widest messaging platform support in the space (WhatsApp, Telegram,
Signal, iMessage, LINE, WeChat, Slack, Discord, Teams, Matrix, and more), voice wake words,
and a ClawHub skill marketplace where users share pre-built automations. The community is large
and the ecosystem is growing fast.
OpenClaw (MIT) is built around a Gateway control plane written in Node.js/TypeScript. It has the
widest messaging coverage in the space — 24+ channels including WhatsApp, Telegram, Signal,
iMessage, LINE, WeChat, Slack, Discord, Teams, Matrix, Google Chat, Feishu, Mattermost, IRC,
Nextcloud Talk, and more. It has native Chrome/Chromium control via CDP, voice wake words on
macOS and iOS, and a ClawHub marketplace with 10,700+ skills. The community is large (350k+
GitHub stars, 16,900+ commits) and growing.
Hermes takes a different approach. It is built in Python and centers on a **self-improving
agent loop** rather than a gateway control plane. The core difference is in how skills work:
OpenClaw skills are primarily human-authored plugins installed from a marketplace; Hermes
**writes and saves its own skills automatically** as part of every session. When Hermes solves
a problem a new way, it saves the procedure and reuses it going forward without any user effort.
Hermes is built in Python and centers on a self-improving agent loop rather than a gateway
control plane. The core architectural difference is in skills: OpenClaw skills are primarily
human-authored plugins installed from a marketplace. Hermes writes and saves its own skills
automatically as part of every session. When Hermes solves a problem a new way, it saves the
procedure and reuses it without any user effort. That's not a subtle distinction — it's the
reason Hermes gets meaningfully better at your workflow without you maintaining a plugin library.
Beyond the skills architecture, there are two other practical differences worth knowing:
Two practical differences worth knowing directly:
**Stability.** OpenClaw's community forums and GitHub issues document a recurring pattern of
update-breaking regressions -- for example, Telegram integration was broken across multiple
releases in early 2026. The unofficial WhatsApp Web protocol OpenClaw uses is known to
disconnect and requires periodic re-pairing (this is documented in OpenClaw's own FAQ).
Hermes has had no equivalent release breakages.
Stability. OpenClaw's GitHub issues and community forums document recurring update-breaking
regressions. Telegram integration was broken across multiple releases from early 2026 through
at least April 2026. The unofficial WhatsApp Web protocol OpenClaw relies on disconnects and
requires periodic re-pairing this is in OpenClaw's own FAQ.
**Security.** ClawHub's open publishing model has been exploited repeatedly. A community audit
identified over a thousand malicious skills in the marketplace including prompt injections and
tool-poisoning payloads; the community-maintained awesome-openclaw-skills list tracks confirmed
removals and flags known bad actors. Hermes has no third-party marketplace and a correspondingly
smaller attack surface.
Security. ClawHub's open publishing model has been exploited at scale. Three separate audits in
early 2026 found serious problems: Koi Security (January 2026) linked 335 skills to a campaign
called "ClawHavoc" that delivered Atomic Stealer malware on macOS; Bitdefender found roughly
900 malicious packages representing about 20% of the ecosystem at the time; Snyk's "ToxicSkills"
report (February 2026) found malicious skills across roughly 4,000 scanned packages. China's
CNCERT issued a national warning about ClawHub. Hermes has no third-party marketplace and a
correspondingly smaller attack surface.
**OpenClaw's genuine strengths** are worth stating plainly: it has broader messaging coverage
(iMessage, LINE, WeChat, Teams -- platforms Hermes does not support), native browser and
computer control via Chrome CDP, voice wake words on macOS and iOS, a larger community, and
more third-party integrations than Hermes. If those capabilities matter most to you, OpenClaw
is worth a serious look.
OpenClaw's genuine strengths are worth stating plainly: broader messaging coverage (iMessage,
LINE, WeChat, Teams, Google Chat — platforms Hermes does not support), native browser and
computer control via Chrome CDP, voice wake words, a larger community, and more third-party
integrations than Hermes. If those capabilities matter most, OpenClaw is worth a serious look.
Where Hermes is the better fit: you want an agent that self-improves from experience without
manual plugin authoring, you work in Python and want access to the ML/data science ecosystem,
you want a stable deployment that does not break between updates, or you want a full web chat
UI rather than a monitoring dashboard.
Where Hermes fits better: you want an agent that self-improves from experience without managing
a plugin library, you work in Python and want the ML/data science ecosystem, you want a stable
deployment that doesn't break between updates, or you want a full web chat UI rather than a
control dashboard.
| | OpenClaw | Hermes |
|---|---|---|
| Persistent memory | Yes | Yes |
| Scheduled jobs (cron) | Yes | Yes |
| Messaging app access | Yes (15+ platforms, incl. iMessage/WeChat) | Yes (10+ platforms) |
| Web UI | Gateway dashboard (monitoring only) | Full three-panel chat UI |
| Messaging app access | Yes (24+ platforms, incl. iMessage/WeChat/LINE) | Yes (many platforms) |
| Web UI | Chat UI + control dashboard | Full three-panel chat UI |
| Self-hosted | Yes | Yes |
| Open source | Yes (MIT) | Yes |
| Self-improving skills | Partial (AI can generate skills; not the default loop) | Yes (automatic, first-class) |
| Self-improving skills | Partial (AI can generate; not the default loop) | Yes (automatic, first-class) |
| Browser / computer control | Yes (native Chrome CDP) | Via shell / tools |
| Voice wake words | Yes (macOS/iOS) | No |
| Python / ML ecosystem | No (Node.js) | Yes |
@@ -167,209 +178,312 @@ UI rather than a monitoring dashboard.
| Multi-profile support | Via binding-rule routing | Yes (first-class named profiles) |
| Provider-agnostic | Yes | Yes |
| Update reliability | Moderate (documented regressions) | High |
| Memory inspectability | Limited | Yes (markdown files, editable) |
| Self-hosted autonomous execution | Yes | Yes |
### vs. Claude Code (Anthropic)
Claude Code is Anthropic's official agentic CLI and one of the best tools in Category 3.
In a single focused session it is capable -- deep code understanding, shell access, file
editing, multi-step reasoning.
Claude Code is Anthropic's official agentic tool and one of the strongest options for focused
coding sessions. It has deep code understanding, shell access, file editing, and multi-step
reasoning. It has been expanding rapidly — it now spans terminal, IDE plugin, desktop app, and
browser surfaces — and the gap is closing in several areas.
Claude Code has been adding features rapidly and the gap is narrowing:
What Claude Code has that's worth knowing:
- **Hooks system** -- 13 event types (SessionStart, PreToolUse, PostToolUse, Stop, etc.) with
4 handler types (shell command, HTTP endpoint, LLM prompt, sub-agent); deterministic
- Hooks system — 26 event types (SessionStart, PreToolUse, PostToolUse, Stop, and more) with
4 handler types (shell command, HTTP endpoint, LLM prompt, sub-agent); gives deterministic
non-LLM control over the agent lifecycle
- **Plugins / Skills** -- installable via `/plugin install`, hot-reloaded from `~/.claude/skills`,
with a marketplace; skills and slash commands unified as of v2.1.0
- **Scheduling** -- `/loop` (session-scoped), cloud-managed cron via `claude.ai/code/scheduled`
(Anthropic infrastructure, minimum interval applies), and desktop app automations
- **Messaging channels** -- Telegram, Discord, iMessage, and webhooks via the Channels feature
(research preview, v2.1.80+); deep Slack integration that triggers cloud sessions and creates PRs
- **Claude Cowork** -- a separate product for knowledge workers; connects to 38+
services via MCP including Slack, Gmail, Microsoft Teams, Notion, Jira, Salesforce, and more
- **Memory** -- CLAUDE.md and MEMORY.md for project-level context; auto-memory rolling out
- Plugins / Skills installable via `/plugin install`, hot-reloaded from `~/.claude/skills`,
with a marketplace; includes the official ralph-wiggum plugin (`/ralph-loop`) for
autonomous iteration toward a completion goal (distinct from `/loop`)
- `/loop` — a native bundled skill, available in every session without any plugin, that runs
a prompt on a repeating schedule within an active CLI session (polling/monitoring use case);
session-scoped, dies when the terminal closes
- Scheduling — cloud-managed cron (Anthropic infrastructure, minimum 1-hour interval) and
desktop app scheduled tasks (run locally while the app is open, minimum 1-minute interval,
full local file access); no self-hosted cron
- Messaging channels — Telegram, Discord, and iMessage via the Channels feature (research
preview, requires Bun runtime); Slack is the most-requested addition and has not yet shipped
- Memory — CLAUDE.md and MEMORY.md for project-level context; auto-memory since v2.1.59+
- Claude Cowork — a separate knowledge-worker product connecting 38+ services via MCP
including Gmail, Microsoft Teams, Notion, Jira, Salesforce, and more
These are real features. The key differences that remain:
Claude Code's source was briefly and accidentally made public in March 2026 before being taken
down. The CLI ships as minified/bundled TypeScript compiled with Bun — it is not open source.
- Claude Code's scheduling runs on **Anthropic's cloud** (or requires the desktop app open),
not a self-hosted server; cloud jobs have a minimum interval and your data leaves your hardware
- Memory is **project-file-based** (CLAUDE.md / MEMORY.md), not a knowledge graph that
accumulates automatically across all your work; auto-memory is still rolling out
- **Not provider-agnostic** -- routes through Bedrock or Vertex but always hits a Claude model;
you cannot switch to GPT, Gemini, or a local model
- **Not open source** -- proprietary; the CLI ships obfuscated JavaScript
- Messaging channels are a **research preview** requiring Bun runtime; not yet production-grade
Key differences that remain:
- Scheduling requires cloud (Anthropic infrastructure, data off your hardware, 1-hour minimum)
or the desktop app (runs locally, but the app must stay open — not a headless server process);
neither runs as a server daemon the way Hermes cron does
- Memory is project-file-based (CLAUDE.md / MEMORY.md plus rolling auto-memory); it doesn't
automatically accumulate a cross-project knowledge graph the way Hermes does
- Not provider-agnostic — routes through Anthropic, Bedrock, Vertex, or Foundry, but always
a Claude model; you can't switch to GPT, Gemini, or a local model
- Messaging channels are still a research preview, not production
Hermes can use Claude Code as a sub-agent. For large implementation tasks, Hermes can spawn
Claude Code to handle the heavy lifting and fold the result back into its own memory and history.
| | Claude Code | Hermes |
|---|---|---|
| Persistent memory (automatic) | Partial (CLAUDE.md / MEMORY.md, rolling out) | Yes |
| Skills / hooks system | Yes (Hooks + Plugin/Skills marketplace) | Yes (auto-generated from experience) |
| Persistent memory (automatic) | Partial (CLAUDE.md / MEMORY.md + auto-memory v2.1.59+) | Yes |
| Skills / hooks system | Yes (26-event Hooks + Plugin/Skills marketplace) | Yes (auto-generated from experience) |
| Scheduled jobs (self-hosted) | No (cloud or desktop-app only) | Yes |
| Messaging access | Partial (Telegram/Discord/iMessage via research preview; Slack native) | Yes (10+ platforms, production) |
| Messaging access | Partial (Telegram/Discord/iMessage research preview; Slack not yet) | Yes (many platforms, production) |
| Cowork connectors (Slack, Gmail, etc.) | Yes (via Claude Cowork, separate product) | Via agent tool use |
| Web UI | Yes (claude.ai/code, Anthropic-hosted) | Yes (self-hosted) |
| Provider-agnostic | No (Claude models only, via Bedrock/Vertex) | Yes (any provider) |
| Provider-agnostic | No (Claude models only) | Yes (any provider) |
| Self-hosted scheduling | No | Yes |
| Open source | No | Yes |
| Background/cloud agent mode | Yes (cloud-scheduled) | Yes (self-hosted cron) |
| Runs as sub-agent of Hermes | Yes | N/A |
| Memory inspectability | Partial (CLAUDE.md readable; auto-memory less so) | Yes (markdown files) |
### vs. Codex CLI (OpenAI)
Codex CLI is OpenAI's open-source agentic terminal tool (Apache 2.0, ~73k GitHub stars). It
supports 10+ providers including Anthropic, Google, Mistral, Groq, and local models via Ollama.
It added persistent session memory in v0.100.0 with `codex resume`. The desktop app has an
Automations feature for scheduled local tasks.
Codex CLI (Apache 2.0, ~60k GitHub stars) started as a straightforward terminal tool and has
expanded into a product family. It was rewritten from TypeScript to Rust. It now includes an IDE
extension, a desktop app with an Automations feature, and Codex Cloud for remote execution. A
Skills system is shared across surfaces. It supports 12+ built-in providers: OpenAI, Anthropic,
Google/Gemini, Mistral, Groq, Ollama, OpenRouter, LM Studio, Together AI, DeepSeek, xAI,
Azure OpenAI, and custom endpoints.
The CLI itself has no native scheduling (open feature request as of early 2026). Memory is
session-history-based rather than a living knowledge graph. No messaging app access. A strong
tool for single-session coding; Hermes adds the always-on layer on top.
The CLI itself has no native scheduling (open feature request). Session continuity is available
via `codex resume`. Memory is session-history-based plus AGENTS.md project context — not a
living knowledge graph that accumulates across all your projects. No first-party messaging
integration. The Automations feature in the desktop app covers scheduled local tasks but doesn't
reach the cross-session, cross-surface continuity Hermes has.
| | Codex CLI | Hermes |
|---|---|---|
| Persistent memory | Partial (session history + AGENTS.md) | Yes (automatic, layered) |
| Scheduled jobs | Partial (desktop app only; CLI has none) | Yes |
| Scheduled jobs | Partial (desktop app Automations; CLI has none) | Yes |
| Messaging app access | No | Yes |
| Web UI | No | Yes (self-hosted) |
| Provider-agnostic | Yes (10+ providers) | Yes (10+ providers) |
| Web UI | No (CLI + desktop app) | Yes (self-hosted) |
| Provider-agnostic | Yes (12+ providers) | Yes |
| Self-hosted | Yes | Yes |
| Open source | Yes (Apache 2.0) | Yes |
| Background/cloud agent mode | Yes (Codex Cloud) | Yes (self-hosted cron) |
| Self-improving skills | No | Yes |
### vs. OpenCode
OpenCode is an open-source TUI agentic coding assistant, provider-agnostic across 75+ providers.
It has a WebUI embedded in its binary and an official desktop app. It uses SQLite for session
history and AGENTS.md for project context.
OpenCode is an open-source TUI agentic coding assistant supporting 75+ providers. It has a WebUI
embedded in its binary, an official desktop app, SQLite session history, and AGENTS.md project
context. It supports CLAUDE.md as a fallback for users migrating from Claude Code. There are 30+
community plugins, and community messaging integrations exist for Telegram, Slack, Discord, and
Microsoft Teams — though none are first-party and all require manual setup.
No native scheduled jobs (a community background plugin exists), no first-party messaging
integration (community Telegram bots exist but require manual setup), and no automatic
cross-session semantic memory. Good for interactive terminal coding sessions.
OpenCode Go ($10/month) and OpenCode Zen (curated model service) are subscription tiers. The
GitHub Copilot official integration launched January 2026. There is no native scheduling; a
community background plugin exists. No automatic cross-session semantic memory.
| | OpenCode | Hermes |
|---|---|---|
| Persistent memory | Partial (session history + AGENTS.md) | Yes (automatic, layered) |
| Scheduled jobs | No (community plugin only) | Yes |
| Messaging app access | No (community Telegram bot only) | Yes (first-party, 10+ platforms) |
| Messaging app access | Community integrations only (Telegram/Slack/Discord/Teams) | Yes (first-party, many platforms) |
| Web UI | Yes (embedded + desktop app) | Yes (self-hosted) |
| Mobile access | No | Yes |
| Skills system | No | Yes |
| Skills / plugins | Yes (30+ community plugins) | Yes (auto-generated, first-party) |
| Provider-agnostic | Yes (75+ providers) | Yes |
| Open source | Yes | Yes |
| Self-hosted autonomous execution | No | Yes |
### vs. Cursor / Windsurf / Copilot
### vs. Cursor
Category 2 tools -- exceptional at in-editor autocomplete, inline diffs, and code review.
Not competing for the same job as Hermes, and they work well alongside it.
Cursor has changed substantially. The "no memory, no scheduling, no messaging" description was
accurate in 2024 and is wrong now.
Windsurf was earliest with workspace-scoped memory (Cascade Memories); Copilot has been
shipping repo-level memory since late 2025. Cursor has no native cross-session memory as of
early 2026. None have scheduling or messaging access.
Memories (per-project cross-session knowledge base) shipped in beta with v1.0 in June 2025.
Automations launched March 5, 2026 — time-based, event-based (GitHub/Linear/PagerDuty), and
communication-based (Slack) triggers that fire background agents on cloud VMs. The web app,
mobile agent, and Slack bot give it multi-surface reach. Cursor v3.0 (April 2, 2026) is
explicitly agent-first with Design Mode and 30+ marketplace plugins. Cursor acquired Supermaven
for autocomplete. As of early 2026 it's valued at $29.3B with $2B ARR. It is not a narrow editor
tool anymore.
Hermes still has a different profile: it's self-hosted and server-resident, the same persistent
identity follows you across every surface without cloud intermediation, and it works with any
model family rather than being cloud-VM-based. For workflows that require data sovereignty,
self-hosted scheduling, or deep Python/ML tooling on your own hardware, Cursor's cloud-agent
architecture is a fundamental mismatch. For teams that want editor-native agents with strong
IDE integration, Cursor's recent evolution is significant.
| | Cursor | Windsurf | Copilot | Hermes |
|---|---|---|---|---|
| In-editor autocomplete | Excellent | Excellent | Excellent | No |
| In-editor autocomplete | Excellent (Supermaven) | Excellent (Cascade) | Excellent | No |
| Inline diff / refactor | Yes | Yes | Yes | Via shell |
| Cross-session memory | No | Yes (workspace) | Partial (repo, early access) | Yes |
| Scheduled background jobs | No | No | No | Yes |
| Messaging app / mobile | No | No | No | Yes |
| Cross-session memory | Yes (Memories, per-project) | Yes (Cascade Memories, workspace) | Yes (Agentic Memory, repo-scoped, 28-day expiry) | Yes (automatic, persistent) |
| Scheduled background jobs | Yes (Automations, cloud VM) | No | Via Coding Agent (issue-driven) | Yes (self-hosted cron) |
| Messaging app / multi-surface | Yes (Slack bot, web app, mobile) | No | Via Copilot CLI / fleet | Yes (many platforms) |
| Background/cloud agent mode | Yes (Automations on cloud VMs) | No | Yes (Coding Agent, GA Mar 2026) | Yes (self-hosted) |
| Terminal tool use | Limited | Limited | Limited | Full |
| Self-hosted | No | No | No | Yes |
| Provider-agnostic | Partial | Partial | No | Yes |
| Self-hosted autonomous execution | No | No | No | Yes |
| Provider-agnostic | Partial | Partial | No (GitHub models) | Yes |
| Open source | No | No | No | Yes |
| Memory inspectability | Partial | Yes (stored locally) | Limited | Yes (markdown files) |
### vs. Claude.ai / ChatGPT
### vs. Claude.ai and ChatGPT
Category 1. For drafting, Q&A, and brainstorming in the moment, both are excellent.
These are no longer simple chat tools. The description of "no memory, no scheduling, no
messaging" is inaccurate for both.
Claude.ai memory has been improving -- it now generates memory from chat history, not just
user-curated entries. Claude.ai can also execute code and read/write files in a sandboxed
environment via Artifacts. These are real capabilities, just not the same as direct filesystem
or shell access on your own server.
Claude Cowork (in Claude Desktop) launched scheduled tasks on February 25, 2026 — hourly,
daily, weekly, weekdays, and on-demand. It runs in an isolated VM with file and shell access.
Claude has 50+ service connectors as of February 2026 including Slack (launched January 26,
2026), Gmail, Google Calendar, Google Drive, Microsoft 365, Notion, Asana, Linear, and Jira.
Memory auto-generates from chat history, not just user-curated entries. Code execution and
file access in Artifacts is sandboxed, not the same as shell access on your own server.
| | Claude.ai / ChatGPT | Hermes |
|---|---|---|
| Memory across conversations | Yes (improving; auto-generated from history) | Yes (deep, automatic) |
| Runs shell commands | No | Yes |
| Code execution | Sandboxed (Artifacts) | Yes (full shell) |
| Reads / writes files | Sandboxed (Artifacts) | Yes (full filesystem) |
| Schedules background jobs | No | Yes |
| Web UI | Yes | Yes |
| Messaging apps | No | Yes |
| Self-hosted | No | Yes |
| Provider-agnostic | No | Yes |
| Open source | No | Yes |
ChatGPT has Agent Mode (launched July 17, 2025), Scheduled Tasks (January 2025, recurring
automated prompts), a computer-using agent, Projects, 50+ connectors including Gmail, GitHub,
and Google Drive, dual-mode memory (auto + manual), and ChatGPT Pulse for Pro users (daily
research briefings). It is not a passive Q&A interface.
Where Claude.ai and ChatGPT differ from Hermes: neither is self-hosted, neither is
provider-agnostic, and neither gives you execution on your own hardware. Connectors and
scheduling exist, but they run on Anthropic's or OpenAI's infrastructure. Your memory, session
history, and agent execution live on their servers, not yours. For many use cases that's fine
— they are capable and well-supported. For privacy-conscious users, regulated environments, or
workflows that require persistent server-side execution on controlled hardware, it's a
disqualifying constraint.
| | Claude.ai | ChatGPT | Hermes |
|---|---|---|---|
| Memory across conversations | Yes (auto-generated from history) | Yes (dual-mode: auto + manual) | Yes (deep, automatic) |
| Scheduled tasks | Yes (Cowork: hourly/daily/weekly) | Yes (since Jan 2025) | Yes (any cron, self-hosted) |
| Service connectors / messaging | Yes (50+ via Cowork) | Yes (50+ connectors) | Yes (many platforms, direct) |
| Runs shell commands | Sandboxed (Cowork VM) | Sandboxed | Yes (full shell) |
| Code execution | Sandboxed | Sandboxed | Yes (full shell) |
| Reads / writes files | Sandboxed | Sandboxed | Yes (full filesystem) |
| Web UI | Yes (Anthropic-hosted) | Yes (OpenAI-hosted) | Yes (self-hosted) |
| Self-hosted | No | No | Yes |
| Provider-agnostic | No | No | Yes |
| Open source | No | No | Yes |
| Self-hosted autonomous execution | No | No | Yes |
| Memory inspectability | Limited | Limited | Yes (markdown files) |
---
## The Compounding Advantage
## The compounding advantage
What matters most about Hermes is that it improves over time. That is the point.
What distinguishes Hermes from most of the tools above is that it gets meaningfully better at
your specific workflow over time without manual configuration.
Every time Hermes encounters a new environment, it saves facts to memory. Every time it solves
a problem a new way, it saves the approach as a skill. Every time you correct it, it updates its
profile of you. Every session, every scheduled job, every tool call, the agent gets more
calibrated to you and your workflow.
profile of you. Every session, every scheduled job, every tool call adds to a body of knowledge
that is specific to you, stored on your hardware, and available to every future interaction.
A Claude Code session on day one and day one hundred are identical. A Hermes agent on day one
and day one hundred is smarter about you -- it knows your stack, your conventions, your
preferences, and the solutions that have worked before.
A Claude Code session on day one and day one hundred are identical — it starts fresh. A Hermes
agent on day one and day one hundred knows your stack, your conventions, your preferences, and
the solutions that have worked before. That's the actual compounding.
---
## Who Hermes Is For
## Who Hermes is for
**Solo developers and power users** who don't want to re-explain their stack every session and
want an AI that actually knows their environment.
Solo developers and power users who don't want to re-explain their stack every session and want
an AI that actually knows their environment.
**Teams on a shared server** where multiple people want Claude-quality AI access without each
paying for a separate subscription or running local tooling.
Teams on a shared server where multiple people want capable AI access without each paying for
a separate subscription or running separate local tooling.
**Automation-heavy workflows** where you want an AI running tasks on a schedule, delivering
results to your phone, without babysitting it.
Automation-heavy workflows where you want an AI running tasks on a schedule, delivering results
to your phone, without babysitting it.
**Privacy-conscious users** who want their conversations, memory, and files on their own
hardware.
Privacy-conscious users who want their conversations, memory, and files on their own hardware.
**Multi-model users** who want to switch between OpenAI, Anthropic, Google, DeepSeek, and
others based on cost, capability, or rate limits, without rebuilding their workflow each time.
Multi-model users who want to switch between OpenAI, Anthropic, Google, DeepSeek, and others
based on cost, capability, or rate limits, without rebuilding their workflow each time.
---
## Scope and Limits
## What Hermes is not
**Hermes lives in the terminal, browser, and messaging apps.** For in-editor autocomplete and
inline diffs, use Cursor or Windsurf alongside it -- they do that job better.
Hermes is not the best in-editor autocomplete tool. Cursor and Windsurf do that job better.
Use one alongside Hermes.
**You run Hermes on your own server.** That means initial setup, but your data stays on your
It is not zero-setup. You are running a server. That means initial configuration, and it means
you're responsible for uptime, upgrades, and backups. The tradeoff is data sovereignty and
control; that only makes sense if you actually want it.
It does not make weaker models magical. Memory and skills help, but the underlying model still
determines reasoning quality. Hermes with a weak model is a well-organized weak model.
It still needs guardrails, approvals, and observability for high-stakes automations. Autonomous
execution on a schedule with shell access is powerful and requires judgment about what to
approve. Terminal commands can require confirmation before running; use that for anything
consequential.
If you need the absolute lowest-friction path to a one-off answer or a quick edit, a chat
interface or an in-editor tool is the right call. Hermes is for continuity and autonomy, not
minimum-friction one-shots.
---
## Scope and limits
Hermes lives in the terminal, browser, and messaging apps. For in-editor autocomplete and inline
diffs, use Cursor or Windsurf — they do that job better and work well alongside Hermes.
You run Hermes on your own server. That means initial setup, but your data stays on your
hardware and you control the schedule, the models, and the costs.
**Hermes is an orchestration and memory layer.** It makes whatever model you point it at more
useful over time. The models do the reasoning; Hermes makes sure that reasoning accumulates into
Hermes is an orchestration and memory layer. It makes whatever model you point at it more useful
over time. The models do the reasoning; Hermes makes sure that reasoning accumulates into
something durable.
---
## Quick Reference
## Security and control
| | OpenClaw | Claude Code | Codex CLI | OpenCode | Cursor | Claude.ai | Hermes |
|---|---|---|---|---|---|---|---|
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | No | Yes (improving) | **Yes** |
| Scheduled / background jobs | Yes | Partial‡ | Partial§ | No | No | No | **Yes (self-hosted)** |
| Messaging app access | Yes (15+ platforms) | Partial (Telegram/Discord preview; Slack native) | No | No | No | No | **Yes (10+ platforms)** |
| Web UI | Dashboard only | Yes (Anthropic cloud) | No | Yes | No | Yes | **Yes (self-hosted)** |
| Skills system | Yes (marketplace) | Yes (Hooks + Plugins) | No | No | No | No | **Yes** |
| Self-improving skills | Partial | No | No | No | No | No | **Yes** |
| Browser / computer control | Yes (Chrome CDP) | No | No | No | No | No | Via shell |
| Python / ML ecosystem | No (Node.js) | No | No | No | No | No | **Yes** |
| In-editor autocomplete | No | No | No | No | Yes | No | No |
| Orchestrates other agents | No | No | No | No | No | No | **Yes** |
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Partial | No | **Yes** |
| Self-hosted | Yes | No | Yes | Yes | No | No | **Yes** |
| Open source | Yes (MIT) | No | Yes | Yes | No | No | **Yes** |
| Always-on / autonomous | Yes | No | No | No | No | No | **Yes** |
Memory is stored locally on your server as readable, editable files: user profile, agent memory,
and skills are all markdown. Session history is in SQLite on your machine. You can inspect,
edit, or delete any of it directly.
† Claude Code has CLAUDE.md / MEMORY.md project context and rolling auto-memory, but not full automatic cross-session recall
‡ Claude Code scheduling: cloud-managed (Anthropic infrastructure) or desktop-app only; no self-hosted cron
§ Codex scheduling: desktop app Automations only; CLI has no native scheduling
If you want external memory providers, eight are supported: Mem0, Honcho, Hindsight, RetainDB,
ByteRover, Supermemory, Holographic, and others. These are optional and configurable.
Execution runs in configurable backends: local shell, Docker, SSH, Daytona, Singularity, or
Modal. You choose what execution environment Hermes operates in and what it can reach.
Terminal commands can require confirmation before running. For any automation that touches
production systems or makes external calls, enable approval controls.
Secrets stay on your hardware. Hermes does not phone home; it calls whatever model APIs you
configure directly.
Multiple profiles give isolation between users or projects. A shared server can have separate
profiles with separate memory, separate skills, and separate history.
---
## Quick reference
| | OpenClaw | Claude Code | Codex | OpenCode | Cursor | Copilot | Claude.ai | ChatGPT | Hermes |
|---|---|---|---|---|---|---|---|---|---|
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | Yes (per-project) | Yes (repo-scoped‡) | Yes | Yes | Yes |
| Scheduled / background jobs | Yes | Partial§ | Partial¶ | No | Yes (Automations) | Via Coding Agent | Yes (Cowork) | Yes | Yes (self-hosted) |
| Messaging / multi-surface | Yes (24+ platforms) | Partial (preview) | No | Community only | Yes (Slack/web/mobile) | Via CLI/fleet | Yes (50+ connectors) | Yes (50+ connectors) | Yes (many platforms) |
| Web UI | Chat UI + control dashboard | Anthropic-hosted | No | Yes | Yes + mobile | github.com | Yes (Claude Desktop) | Yes | Yes (self-hosted) |
| Skills system | Yes (ClawHub marketplace) | Yes (Hooks + Plugins) | Partial (Skills) | Community plugins | Yes (marketplace) | No | No | No | Yes (auto-generated) |
| Self-improving skills | Partial | No | No | No | No | No | No | No | Yes |
| Browser / computer control | Yes (Chrome CDP) | No | No | No | No | No | No | Yes (CUA) | Via shell |
| In-editor autocomplete | No | No | Via extension | No | Excellent | Excellent | No | No | No |
| Orchestrates other agents | No | No | No | No | No | No | No | No | Yes |
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Partial | No | No | No | Yes |
| Self-hosted | Yes | No | Yes (CLI) | Yes | No | No | No | No | Yes |
| Self-hosted autonomous execution | Yes | No | No | No | No | No | No | No | Yes |
| Background/cloud agent mode | Yes | Yes (cloud) | Yes (Codex Cloud) | No | Yes (cloud VMs) | Yes (Coding Agent) | Yes (Cowork VM) | Yes (Agent Mode) | Yes (self-hosted) |
| Memory inspectability | Limited | Partial | Partial | Partial | Partial | Limited | Limited | Limited | Yes (markdown files) |
| Open source | Yes (MIT) | No | Yes (Apache 2.0) | Yes | No | No | No | No | Yes |
| Always-on autonomous execution | Yes | No | No | No | No | No | No | No | Yes |
† Claude Code: CLAUDE.md / MEMORY.md project context plus auto-memory since v2.1.59+; no automatic cross-project accumulation
‡ Copilot Agentic Memory: public preview Jan 15, 2026; enabled by default Mar 4, 2026; repo-scoped, auto-expires after 28 days
§ Claude Code scheduling: cloud-managed (Anthropic infrastructure) or desktop-app only; no self-hosted cron
¶ Codex scheduling: desktop app Automations only; CLI has no native scheduling

391
README.md
View File

@@ -1,16 +1,32 @@
# Hermes Web UI
[Hermes Agent](https://hermes-agent.nousresearch.com/) is a sophisticated autonomous agent that lives on your server, accessed via a terminal or messaging apps, remembers what it learns, and gets more capable the longer it runs.
[Hermes Agent](https://hermes-agent.nousresearch.com/) is a sophisticated autonomous agent that lives on your server, accessed via a terminal or messaging apps, that remembers what it learns and gets more capable the longer it runs.
Hermes WebUI is a lightweight, dark-themed web app interface in your browser for [Hermes Agent](https://hermes-agent.nousresearch.com/).
Full parity with the CLI experience - everything you can do from a terminal,
you can do from this UI. No build step, no framework, no bundler. Just Python
and vanilla JS.
Layout: three-panel Claude-style. Left sidebar for sessions and tools,
center for chat, right for workspace file browsing.
Layout: three-panel. Left sidebar for sessions and navigation, center for chat,
right for workspace file browsing. Model, profile, and workspace controls live in
the **composer footer** — always visible while composing. A circular context ring
shows token usage at a glance. All settings and session tools are in the
**Hermes Control Center** (launcher at the sidebar bottom).
<img width="1392" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/79cd3c0d-3167-42ed-9434-447a742c25c3" />
<img width="2448" height="1748" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/6bf8af4c-209d-441e-8b92-6515d7a0c369" />
<table>
<tr>
<td width="50%" align="center">
<img width="2940" height="1848" alt="Light mode with full profile support" src="https://github.com/user-attachments/assets/4ef3a59c-7a66-4705-b4e7-cb9148fe4c47" />
<br /><sub>Light mode with full profile support</sub>
</td>
<td width="50%" align="center">
<img alt="Customize your settings, configure a password" src="https://github.com/user-attachments/assets/941f3156-21e3-41fd-bcc8-f975d5000cb8" />
<br /><sub>Customize your settings, configure a password</sub>
</td>
</tr>
</table>
<table>
<tr>
@@ -79,20 +95,31 @@ ecosystem. See [HERMES.md](HERMES.md) for the full side-by-side.
## Quick start
First, you need to install and configure [Hermes Agent](https://hermes-agent.nousresearch.com/). Once installed:
Run the repo bootstrap:
```bash
git clone https://github.com/nesquena/hermes-webui.git hermes-webui
cd hermes-webui
python3 bootstrap.py
```
Or keep using the shell launcher:
```bash
./start.sh
```
That is it. The script will:
The bootstrap will:
1. Locate your Hermes agent checkout automatically.
2. Find (or create) a Python environment with the required dependencies.
3. Start the server.
4. Print the URL (and SSH tunnel command if you are on a remote machine).
1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`).
2. Find or create a Python environment with the WebUI dependencies.
3. Start the web server and wait for `/health`.
4. Open the browser unless you pass `--no-browser`.
5. Drop you into a first-run onboarding wizard inside the WebUI.
> Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2.
If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser.
---
@@ -100,14 +127,23 @@ That is it. The script will:
**Pre-built images** (amd64 + arm64) are published to GHCR on every release:
Make sure the `HERMES_WEBUI_STATE_DIR` (by default `~/.hermes/webui-mvp`, as detailed in the `.env.example` file) folder exist with the UID/GID of the owner of the `.hermes` folder.
The container will also mount your configured "workspace" (also from the example .env.example) as `/workspace`. adapt the location as needed.
```bash
docker pull ghcr.io/nesquena/hermes-webui:latest
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes ghcr.io/nesquena/hermes-webui:latest
docker run -d \
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
-v ~/workspace:/workspace \
-p 8787:8787 ghcr.io/nesquena/hermes-webui:latest
```
Or run with Docker Compose (recommended):
```bash
# Check the docker-compose.yml and make sure to adapt as needed, at minimum WANTED_UID/WANTED_GID
docker compose up -d
```
@@ -115,7 +151,11 @@ Or build locally:
```bash
docker build -t hermes-webui .
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes hermes-webui
docker run -d \
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
-v ~/workspace:/workspace \
-p 8787:8787 hermes-webui
```
Open http://localhost:8787 in your browser.
@@ -123,15 +163,119 @@ Open http://localhost:8787 in your browser.
To enable password protection:
```bash
docker run -d -p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret -v ~/.hermes:/root/.hermes ghcr.io/nesquena/hermes-webui:latest
docker run -d \
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
-v ~/workspace:/workspace \
-p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret ghcr.io/nesquena/hermes-webui:latest
```
Session data persists in a named volume (`hermes-data`) across restarts.
> **Note:** By default, Docker Compose binds to `127.0.0.1` (localhost only).
> To expose on a network, change the port to `"8787:8787"` in `docker-compose.yml`
> and set `HERMES_WEBUI_PASSWORD` to enable authentication.
### Two-container setup (Agent + WebUI)
If you run the Hermes Agent in its own Docker container and want the WebUI
in a separate container:
```bash
docker compose -f docker-compose.two-container.yml up -d
```
This starts both containers with shared volumes:
- **`hermes-home`** — shared `~/.hermes` for config, sessions, skills, memory
- **`hermes-agent-src`** — the agent's source code, mounted into the WebUI
container so it can install the agent's Python dependencies at startup
> **Volume type:** The compose files use named Docker volumes by default.
> If you prefer bind mounts to an existing directory (e.g. for sharing state
> with an agent container you already run), both containers must mount the
> same host path — the agent writes to `/root/.hermes`, the WebUI reads from
> `/home/hermeswebui/.hermes`. See `docker-compose.two-container.yml` for
> a bind-mount example.
The WebUI's init script automatically installs hermes-agent and all its
dependencies (openai, anthropic, etc.) into its own Python environment on
first boot. Subsequent restarts reuse the installed packages.
> **How it works:** The WebUI imports hermes-agent's Python modules directly
> (not via HTTP). The shared volume makes the agent source available, and
> the init script runs `uv pip install` to set up the dependencies. Both
> containers share the same `~/.hermes` directory for config and state.
See `docker-compose.two-container.yml` for the full configuration.
### Running alongside hermes-dashboard (three-container setup)
To run the Hermes Agent, Hermes Dashboard, and the WebUI together on a
shared volume, use the three-container Compose file:
```bash
docker compose -f docker-compose.three-container.yml up -d
```
This brings up:
- **`hermes-agent`** — gateway API on port 8642
- **`hermes-dashboard`** — monitoring UI on port 9119
- **`hermes-webui`** — browser chat interface on port 8787
All three services share the same `hermes-home` named volume so config,
sessions, skills, and memory are consistent across all surfaces.
#### Why UIDs must match
The `hermes-home` volume is a bind-mount in practice — all three containers
write to the same filesystem tree under `~/.hermes`. If the containers run
as different UIDs, whichever container creates a file first becomes its
owner, and the others hit `PermissionError` on subsequent writes.
The fix is to make all containers run as **your host user's UID and GID**.
#### Variable name asymmetry
> ⚠️ **The two image families use different environment variable names** for
> the UID/GID setting:
>
> | Image | Variable |
> |---|---|
> | `nousresearch/hermes-agent` (agent + dashboard) | `HERMES_UID` / `HERMES_GID` |
> | `ghcr.io/nesquena/hermes-webui` | `WANTED_UID` / `WANTED_GID` |
>
> You must set **both pairs** when using a `.env` file.
#### Recommended setup
For a standard Linux user (UID ≥ 1000):
```bash
# Create a .env file with your host UID/GID
echo "UID=$(id -u)" >> .env
echo "GID=$(id -g)" >> .env
# hermes-agent / hermes-dashboard
echo "HERMES_UID=$(id -u)" >> .env
echo "HERMES_GID=$(id -g)" >> .env
```
For NAS/Unraid deployments where a fixed service account is preferred, use
`10000:10000` (or your NAS service UID) instead of `$(id -u)`.
If you get `PermissionError` on an **existing** `~/.hermes` directory, run
the one-time ownership fix:
```bash
chown -R $(id -u):$(id -g) ~/.hermes
```
#### Volume mount mode
The dashboard container needs **read-write** access to the shared volume
(it writes session logs and dashboard state). Do **not** add `:ro` to the
`hermes-home` volume in `hermes-dashboard`'s `volumes:` entry.
See `docker-compose.three-container.yml` for the full reference configuration.
---
## What start.sh discovers automatically
@@ -154,6 +298,7 @@ If discovery finds everything, nothing else is required.
export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
export HERMES_WEBUI_PYTHON=/path/to/python
export HERMES_WEBUI_PORT=9000
export HERMES_WEBUI_AUTO_INSTALL=1 # enable auto-install of agent deps (disabled by default)
./start.sh
```
@@ -202,6 +347,40 @@ are running over SSH.
---
## Accessing on your phone with Tailscale
[Tailscale](https://tailscale.com) is a zero-config mesh VPN built on
WireGuard. Install it on your server and your phone, and they join the same
private network -- no port forwarding, no SSH tunnels, no public exposure.
The Hermes Web UI is fully responsive with a mobile-optimized layout
(hamburger sidebar, sidebar top tabs in the drawer, touch-friendly controls),
so it works well as a daily-driver agent interface from your phone.
**Setup:**
1. Install [Tailscale](https://tailscale.com/download) on your server and
your iPhone/Android.
2. Start the WebUI listening on all interfaces with password auth enabled:
```bash
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PASSWORD=your-secret ./start.sh
```
3. Open `http://<server-tailscale-ip>:8787` in your phone's browser
(find your server's Tailscale IP in the Tailscale app or with
`tailscale ip -4` on the server).
That's it. Traffic is encrypted end-to-end by WireGuard, and password auth
protects the UI at the application level. You can add it to your home screen
for an app-like experience.
> **Tip:** If using Docker, set `HERMES_WEBUI_HOST=0.0.0.0` in your
> `docker-compose.yml` environment (already the default) and set
> `HERMES_WEBUI_PASSWORD`.
---
## Manual launch (without start.sh)
If you prefer to launch the server directly:
@@ -237,8 +416,8 @@ Or using the agent venv explicitly:
```
Tests run against an isolated server on port 8788 with a separate state directory.
Production data and real cron jobs are never touched. Current count: **424 tests**
across 22 test files.
Production data and real cron jobs are never touched. Current count: **1898 tests**
across 53 test files.
---
@@ -250,7 +429,7 @@ across 22 test files.
- Send a message while one is processing -- it queues automatically
- Edit any past user message inline and regenerate from that point
- Retry the last assistant response with one click
- Cancel a running task from the activity bar
- Cancel a running task directly from the composer footer (Stop button next to Send)
- Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns
- Subagent delegation cards -- child agent activity shown with distinct icon and indented border
- Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts)
@@ -267,6 +446,7 @@ across 22 test files.
### Sessions
- Create, rename, duplicate, delete, search by title and message content
- Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete
- Pin/star sessions to the top of the sidebar (gold indicator)
- Archive sessions (hide without deleting, toggle to show)
- Session projects -- named groups with colors for organizing sessions
@@ -298,10 +478,11 @@ across 22 test files.
- Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari)
### Profiles
- Profile picker in the topbar -- purple chip with dropdown showing all profiles
- Profile chip in the **composer footer** -- dropdown showing all profiles with gateway status and model info
- Gateway status dots (green = running), model info, skill count per profile
- Profiles management panel -- create, switch, and delete profiles from the sidebar
- Clone config from active profile on create
- Optional custom endpoint fields on create -- Base URL and API key written into the profile's `config.yaml` at creation time, so Ollama, LMStudio, and other local endpoints can be configured without editing files manually
- Seamless switching -- no server restart; reloads config, skills, memory, cron, models
- Per-session profile tracking (records which profile was active at creation)
@@ -314,17 +495,25 @@ across 22 test files.
- 20MB POST body size limit
- CDN resources pinned with SRI integrity hashes
### Themes
- 7 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord, OLED
- Switch via Settings panel dropdown (instant live preview) or `/theme` command
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
- Custom themes: define a `:root[data-theme="name"]` CSS block and it works — see [THEMES.md](THEMES.md)
### Settings and configuration
- Settings panel (gear icon) -- default model, default workspace, send key preference
- **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
- Send key: Enter (default) or Ctrl/Cmd+Enter
- Show/hide CLI sessions toggle (enabled by default)
- Token usage display toggle (off by default, also via `/usage` command)
- Control Center always opens on the Conversation tab; resets on close
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
- Background agent error alerts -- banner when a non-active session encounters an error
### Slash commands
- Type `/` in the composer for autocomplete dropdown
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
- Arrow keys navigate, Tab/Enter select, Escape closes
- Unrecognized commands pass through to the agent
@@ -339,10 +528,10 @@ across 22 test files.
### Mobile responsive
- Hamburger sidebar -- slide-in overlay on mobile (<640px)
- Bottom navigation bar -- 5-tab iOS-style fixed bar
- Sidebar top tabs stay available on mobile; no fixed bottom nav stealing chat height
- Files slide-over panel from right edge
- Touch targets minimum 44px on all interactive elements
- Composer positioned above bottom nav
- Full-height chat/composer on phones without bottom-nav spacing
- Desktop layout completely unchanged
---
@@ -350,31 +539,33 @@ across 22 test files.
## Architecture
```
server.py HTTP routing shell + auth middleware (~83 lines)
server.py HTTP routing shell + auth middleware (~154 lines)
api/
auth.py Optional password authentication, signed cookies (~149 lines)
config.py Discovery, globals, model detection, reloadable config (~726 lines)
helpers.py HTTP helpers, security headers (~71 lines)
models.py Session model + CRUD + CLI bridge (~338 lines)
profiles.py Profile state management, hermes_cli wrapper (~366 lines)
routes.py All GET + POST route handlers (~1314 lines)
streaming.py SSE engine, run_agent, cancel support (~332 lines)
upload.py Multipart parser, file upload handler (~78 lines)
auth.py Optional password authentication, signed cookies (~201 lines)
config.py Discovery, globals, model detection, reloadable config (~1110 lines)
helpers.py HTTP helpers, security headers (~175 lines)
models.py Session model + CRUD + CLI bridge (~377 lines)
onboarding.py First-run onboarding wizard, OAuth provider support (~507 lines)
profiles.py Profile state management, hermes_cli wrapper (~411 lines)
routes.py All GET + POST route handlers (~2250 lines)
state_sync.py /insights sync — message_count to state.db (~113 lines)
streaming.py SSE engine, run_agent, cancel support (~660 lines)
updates.py Self-update check and release notes (~257 lines)
upload.py Multipart parser, file upload handler (~82 lines)
workspace.py File ops, workspace helpers, git detection (~288 lines)
static/
index.html HTML template (~388 lines)
style.css All CSS incl. mobile responsive (~726 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1063 lines)
workspace.js File preview, file ops, git badge (~247 lines)
sessions.js Session CRUD, collapsible groups, search (~589 lines)
messages.js send(), SSE handlers, rAF throttle (~352 lines)
panels.js Cron, skills, memory, profiles, settings (~1146 lines)
commands.js Slash command autocomplete (~170 lines)
boot.js Mobile nav, voice input, boot IIFE (~338 lines)
index.html HTML template (~600 lines)
style.css All CSS incl. mobile responsive, themes (~1050 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1740 lines)
workspace.js File preview, file ops, git badge (~286 lines)
sessions.js Session CRUD, collapsible groups, search, reload recovery (~800 lines)
messages.js send(), SSE handlers, live streaming, session recovery (~655 lines)
panels.js Cron, skills, memory, profiles, settings (~1438 lines)
commands.js Slash command autocomplete (~267 lines)
boot.js Mobile nav, voice input, boot IIFE (~524 lines)
tests/
conftest.py Isolated test server (port 8788)
test_sprint{1-23}.py 22 test files, 426 test functions
test_regressions.py Permanent regression gate (23 tests)
61 test files 961 test functions
Dockerfile python:3.12-slim container image
docker-compose.yml Compose with named volume and optional auth
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag
@@ -393,6 +584,120 @@ State lives outside the repo at `~/.hermes/webui-mvp/` by default
- `TESTING.md` -- manual browser test plan and automated coverage reference
- `CHANGELOG.md` -- release notes per sprint
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
- `THEMES.md` -- theme system documentation, custom theme guide
## Contributors
Hermes WebUI is built with help from the open-source community. Every PR — whether merged directly or incorporated via rebase — shapes the project, and we're grateful to everyone who has taken the time to contribute.
### Major contributions
**[@aronprins](https://github.com/aronprins)** — v0.50.0 UI overhaul (PR #242)
The biggest single contribution to the project: a complete UI redesign that moved model/profile/workspace controls into the composer footer, replaced the gear-icon settings panel with the Hermes Control Center (tabbed modal), removed the activity bar in favor of inline composer status, redesigned the session list with a `⋯` action dropdown, and added the workspace panel state machine. 26 commits, thoroughly designed and iterated through multiple review rounds.
**[@iRonin](https://github.com/iRonin)** — Security hardening sprint (PRs #196#204)
Six consecutive security and reliability PRs: session memory leak fix (expired token pruning), Content-Security-Policy + Permissions-Policy headers, 30-second slow-client connection timeout, optional HTTPS/TLS support via environment variables, upstream branch tracking fix for self-update, and CLI session support in the file browser API. This is the kind of focused, high-quality security work that makes a self-hosted tool trustworthy.
**[@DavidSchuchert](https://github.com/DavidSchuchert)** — German translation (PR #190)
Complete German locale (`de`) covering all UI strings, settings labels, commands, and system messages — and in doing so, stress-tested the i18n system and exposed several elements that weren't yet translatable, which got fixed as part of the same PR.
**[@Jordan-SkyLF](https://github.com/Jordan-SkyLF)** — Live streaming, session recovery, workspace fallback (PRs #366, #367)
Three interlocking improvements: workspace fallback resolution so the server recovers gracefully when the configured workspace is deleted or unavailable; live reasoning cards that upgrade the generic thinking spinner to a real-time reasoning display as the model thinks; and durable session state recovery via `localStorage` so in-flight tool cards, partial assistant output, and the live SSE stream all survive a full page reload or session switch.
### Feature contributions
**[@gabogabucho](https://github.com/gabogabucho)** — Spanish locale + onboarding wizard (PRs #275, #285)
Full Spanish (`es`) locale covering all 175 UI strings, plus the one-shot bootstrap onboarding wizard that guides new users through provider setup on first launch — the feature most responsible for new users actually getting started.
**[@bergeouss](https://github.com/bergeouss)** — Real-time gateway session sync (PR #274)
Bridged the gateway session database (Telegram, Discord, Slack, etc.) into the WebUI sidebar with live SSE polling. Gateway sessions now appear alongside WebUI sessions in real time, without any changes to hermes-agent.
**[@ccqqlo](https://github.com/ccqqlo)** — Terminal approval UX + custom model discovery + mobile close button (PRs #224, #225, #238, #333)
A run of focused quality-of-life improvements: terminal tool approval prompts that stay visible long enough to actually be read, restored custom model API key discovery, and the redundant mobile close button fix that had been confusing users on narrow screens.
**[@kevin-ho](https://github.com/kevin-ho)** — OLED theme (PR #168)
Added the 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk. Small diff, big impact for anyone on an OLED display.
**[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button + Android Chrome fixes (PRs #253, #263, #265)
Added the Profiles entry to the mobile navigation flow, making profile switching reachable on phones, plus a set of Android Chrome-specific fixes for the profile dropdown.
**[@franksong2702](https://github.com/franksong2702)** — Session title guard + breadcrumb nav (PRs #301, #302)
Two clean bug fixes / features: the session title guard that stops `title_from()` from overwriting user-renamed sessions after every turn, and clickable breadcrumb navigation in the workspace file preview panel.
**[@betamod](https://github.com/betamod)** — Security hardening (PR #171)
A comprehensive security audit PR covering CSRF protection, SSRF guards, XSS escaping improvements, and the env race condition between concurrent agent sessions — foundational security work that shipped in v0.39.0.
**[@TaraTheStar](https://github.com/TaraTheStar)** — Bot name + thinking blocks + login refactor (PRs #132, #176, #181)
Made the assistant display name configurable throughout the UI, added thinking/reasoning block display in chat, and refactored the login page to use template variables instead of inline string replacement.
**[@thadreber-web](https://github.com/thadreber-web)** — CLI session bridge (PR #56)
The original CLI session bridge: reads CLI sessions from the agent's SQLite state store and surfaces them in the WebUI sidebar. This was the first bridge between the CLI and WebUI session worlds.
**[@deboste](https://github.com/deboste)** — Reverse proxy auth + mobile responsive layout + model routing (PRs #3, #4, #5)
Three of the very first community PRs: fixed EventSource/fetch to use the URL origin for reverse proxy setups, corrected model provider routing from config, and added mobile responsive layout with dvh viewport fix. Early foundation work.
### Bug fix and security contributions
**[@Hinotoi-agent](https://github.com/Hinotoi-agent)** — Profile .env secret isolation (PR #351)
Fixed API key leakage between profiles on switch — switching from a profile with `OPENAI_API_KEY` to one without it left the key in the process environment for the duration of the session, effectively leaking credentials. A subtle and important security fix.
**[@lawrencel1ng](https://github.com/lawrencel1ng)** — Bandit security fixes B310/B324/B110 + QuietHTTPServer (PR #354)
Systematic bandit security scan fixes: URL scheme validation before `urlopen`, MD5 `usedforsecurity=False`, and 40+ bare `except: pass` blocks replaced with proper logging — plus `QuietHTTPServer` to stop client-disconnect log spam from SSE streams.
**[@lx3133584](https://github.com/lx3133584)** — CSRF fix for reverse proxy on non-standard ports (PR #360)
Fixed CSRF rejection for deployments behind Nginx Proxy Manager or similar on non-standard ports — a real-world blocker for anyone hosting on a port other than 80/443.
**[@DelightRun](https://github.com/DelightRun)** — session_search fix for WebUI sessions (PR #356)
The `session_search` tool silently returned "Session database not available" in every WebUI session. Tracked down the missing `SessionDB` injection in the streaming path and fixed it.
**[@shaoxianbilly](https://github.com/shaoxianbilly)** — Unicode filename downloads (PR #378)
Fixed `UnicodeEncodeError` crashes when downloading workspace files with Chinese, Japanese, or other non-ASCII names. Implemented proper `Content-Disposition` header with RFC 5987 `filename*=UTF-8''...` encoding.
**[@huangzt](https://github.com/huangzt)** — Cancel interrupts agent (PR #244)
Made the Cancel button actually interrupt the running agent and clean up UI state, rather than just hiding the button while the agent kept running.
**[@tgaalman](https://github.com/tgaalman)** — Thinking card fix (PR #169)
Fixed top-level reasoning fields being missed in the thinking card display — an edge case in how Claude's extended thinking blocks surface in the API response.
**[@smurmann](https://github.com/smurmann)** — Custom provider routing fix (PR #189)
Fixed model routing for slash-prefixed custom provider models, which were being misrouted in the model selector. A precise fix for a real edge case in multi-provider setups.
**[@jeffscottward](https://github.com/jeffscottward)** — Claude Haiku model ID fix (PR #145)
Caught and corrected the Claude Haiku model ID (`3-5``4-5`) immediately after the Anthropic release — the kind of quick community catch that keeps the model dropdown accurate.
**[@kcclaw001](https://github.com/kcclaw001)** — Credential redaction in API responses (PR #243)
Added credential redaction to all API response paths so API keys, tokens, and other secrets in session data or error messages are masked before reaching the browser.
**[@mbac](https://github.com/mbac)** — Phantom "Custom" provider group fix (PR #191)
Removed the phantom "Custom" optgroup that appeared in the model dropdown even when no custom provider was configured — a small but consistently confusing UI noise issue.
**[@andrewy-wizard](https://github.com/andrewy-wizard)** — Chinese localization (PR #177)
Added Simplified Chinese (`zh`) locale to the WebUI. One of the first non-English locales and the most-used non-English locale in the codebase.
**[@mmartial](https://github.com/mmartial)** — Docker UID/GID matching (PR #237)
Added Docker support for running as an arbitrary UID/GID matching the host user, eliminating permission issues with bind-mounted volumes — essential for Docker deployments where the host user isn't UID 1000.
**[@vCillusion](https://github.com/vCillusion)** — pip package resolution fix (PR #76)
Fixed agent dependency resolution to prefer packages from the venv's site-packages over the agent directory itself, preventing shadowing bugs when developing locally.
**[@carlytwozero](https://github.com/carlytwozero)** — API key pass-through for non-Anthropic providers (PR #78)
Fixed `api_key` not being passed to `AIAgent` for non-Anthropic `/anthropic` providers — a quiet regression that silently broke any non-default provider.
**[@mangodxd](https://github.com/mangodxd)** — Type hints cleanup (PR #115)
Added missing type hints across 10 files and corrected 9 inaccurate existing ones — the kind of maintenance work that makes the codebase easier to reason about.
**[@Argonaut790](https://github.com/Argonaut790)** — HTML entity decode + Traditional Chinese locale (PR #239)
Fixed double-escaping of HTML entities in `renderMd()` — LLM output containing `&lt;code&gt;` was being escaped a second time, rendering as literal text instead of the intended markdown. The same PR also completed the Simplified Chinese translation (40+ missing keys) and added a full Traditional Chinese (`zh-Hant`) locale.
**[@indigokarasu](https://github.com/indigokarasu)** — Visual redesign proposal: icon rail + design token system + 7 themes (PR #213)
A CSS-only redesign of the full UI — proper design tokens (`--bg-primary`, `--text-info`, spacing scale), an icon rail sidebar replacing the emoji tab strip, consistent form cards, breadcrumb nav, and 7 built-in themes as custom properties. The PR didn't merge as-is but directly shaped the design language and theme architecture that shipped in v0.50.0.
**[@zenc-cp](https://github.com/zenc-cp)** — Anti-hallucination guard for ReAct loop (PR #133)
Added a streaming token buffer and post-run message scrub to `streaming.py` to detect and strip fake tool execution JSON that weaker models write inline instead of calling tools properly. A three-layer approach: ephemeral anti-hallucination prompt, live token filtering, and session history cleanup. The pattern influenced later streaming.py improvements.
---
Want to contribute? See [ARCHITECTURE.md](ARCHITECTURE.md) for the codebase layout and [TESTING.md](TESTING.md) for how to run the test suite. The best contributions are focused, well-tested, and solve a real problem — exactly what every person on this list did.
## Repo

View File

@@ -3,8 +3,8 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.31.2 (April 5, 2026)
> Tests: 424 total (424 passing, 0 failures)
> Last updated: v0.50.225 (April 26, 2026) — 2591 tests collected
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
> Source: <repo>/
---
@@ -32,14 +32,60 @@
| Sprint 13 | Alerts + polish | Cron completion alerts (polling + badge), background error banner, session duplicate, browser tab title | 221 |
| Sprint 14 | Visual polish + workspace ops | Mermaid diagrams, message timestamps, file rename, folder create, session tags, session archive | 233 |
| Sprint 15 | Session projects + code copy | Session projects/folders, code block copy button, tool card expand/collapse toggle | 237 |
| Sprint 16 | Session sidebar visual polish | SVG action icons, overlay hover actions, pin indicator, project border, safe HTML rendering | 289 |
| Sprint 16 | Session sidebar visual polish | SVG action icons, session action dropdown, pin indicator, project border, safe HTML rendering | 289 |
| Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 318 |
| Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking/reasoning cards, expandable directory tree (#22) | 318 |
| Sprint 19 | Auth + security hardening | Password auth (off by default), login page, security headers, 20MB body limit (#23) | 328 |
| Sprint 20 | Voice input + send button | Voice input (Web Speech API), send button icon-circle with pop-in animation | 415 |
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, bottom nav, files slide-over, Docker support (#21, #7) | 415 |
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, mobile nav, files slide-over, Docker support (#21, #7) | 415 |
| Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) | 415 |
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
| v0.44.0 patch | Fix batch: approval card, login CSP, update diagnostics, Lucide icons | PRs #221 #225 #226 #227 #228 | 579 |
| v0.45.0 | Custom endpoint in new profile form | Base URL + API key fields; server-side URL validation; config.yaml merge; 9 new tests (PR #233, fixes #170) | 604 |
| v0.46.0 | Security, Docker UID/GID, model discovery, i18n, cancel fix | Credential redaction in API responses (PR #243); Docker UID/GID matching (PR #237); custom model API key discovery (PR #238); HTML entity decode + zh/zh-Hant i18n (PR #239); cancel interrupts agent (PR #244); +20 tests | 624 |
| v0.47.0 | Dialogs, session menu, skills command, mobile fixes, mobile QA | Shared app dialogs (#251); session ⋯ menu (#252); mobile QA suite (#254); custom provider slash routing fix (#255); Android Chrome mobile fixes (#256); /skills command (#257); +21 tests | 645 |
| v0.47.1 | Spanish locale | Full Spanish (es) locale, 175 keys, key-parity tests (#275 @gabogabucho); +3 tests | 648 |
| v0.48.0 | Gateway session sync | Real-time Telegram/Discord/Slack sessions in sidebar via SSE + DB polling (#274 @bergeouss); +10 tests | 658 |
| v0.48.1 | Table inline formatting | `inlineMd()` in table cells — **bold**, *italic*, `code`, links render correctly (PR #278); 0 new tests | 658 |
| v0.48.2 | Provider mismatch warning | Toast warning + auth_mismatch error type for provider/model mismatches (#283, fixes #266); +21 tests | 679 |
| v0.49.1 | Docker docs + mobile Profiles button | Two-container Docker compose (#291/#288); Profiles added to the mobile navigation flow with correct panel wiring and SVG sizing (#297/#265 @gabogabucho); +3 tests | 700 |
| v0.49.0 | First-run onboarding wizard + self-update hardening | One-shot bootstrap + guided setup wizard; provider config persisted to config.yaml + .env; OpenRouter/Anthropic/OpenAI/Custom; wizard hidden after completion (#285); self-update stderr/split-ref/conflict fixes (#287); skip flaky redaction test (#289); +18 tests | 697 |
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
| v0.34.1 | Theme variable polish | 30+ hardcoded dark-navy colors replaced with theme-aware CSS variables | 433 |
| v0.34.2 | Theme text colors | 5 new per-theme typography variables (--strong, --em, --code-text, --code-inline-bg, --pre-text) | 433 |
| v0.34.3 | Light theme final polish | 46 light-scoped selector overrides for sidebar, roles, chips, interactive elements | 433 |
| v0.35 | Security hardening | Env race fix, random signing key, upload path traversal, PBKDF2 password hash | 433 |
| v0.36v0.37 | Model routing, personality config, tool card reload, duplicate model fixes | Model routing by provider prefix, personality via config.yaml, tool cards reload on page refresh | 466 |
| v0.38.0v0.38.6 | Model selector, custom endpoints, OLED theme, reasoning display, insights sync | Custom endpoint URL fix, OLED theme, top-level reasoning field fix, message_count sync to state.db | 466 |
| v0.39.0 | Security hardening (Sprint 29) | CSRF, PBKDF2, rate limiting, session ID validation, SSRF, ENV_LOCK, XSS, HMAC, skills traversal, secure cookie, error sanitization, startup warning | 499 |
| v0.40v0.44.2 | Approval card + Lucide icons + sprint auth | Approval prompt surfaced in UI, emoji icons → Lucide SVG, login CSP inline fix, update diagnostics | 579 |
| v0.45v0.46 | Custom endpoints + security + i18n + cancel | Custom endpoint Base URL + API key on profile create, credential redaction (PR #243), Docker UID/GID (PR #237), HTML entity decode + zh/zh-Hant i18n, cancel interrupts agent | 624 |
| v0.47v0.47.1 | Dialogs + session menu + skills + mobile QA + Spanish | Shared app dialogs, session ⋯ menu, /skills command, mobile QA suite, Android Chrome fixes, Spanish locale (@gabogabucho) | 648 |
| v0.48v0.48.2 | Gateway session sync + table formatting + provider warnings | Real-time Telegram/Discord/Slack sessions in sidebar (@bergeouss), inlineMd() in table cells, provider/model mismatch toast | 679 |
| v0.49v0.49.1 | Onboarding wizard + Docker two-container | One-shot bootstrap + guided setup wizard, OpenRouter/Anthropic/OpenAI/Custom provider config, two-container Docker compose, mobile Profiles button | 700 |
| v0.50.0 | v0.50.0 UI overhaul (Sprint 34) | Composer-centric controls, Hermes Control Center modal, workspace panel state machine, collapsible date groups, rAF streaming throttle, context ring indicator (@aronprins) | 742 |
| v0.50.5v0.50.10 | Think-tag edge cases + onboarding hardening + mobile fixes | MiniMax M2.5 leading-whitespace think-tag fix, skip-onboarding env var, OAuth provider path, Docker bridge networks fix, model dropdown dedup, title auto-generation fix, mobile close button | 802 |
| v0.50.11v0.50.12 | Chat table styles + URL autolink + profile env isolation | .msg-body table borders, plain URL auto-linking, profile .env secret isolation on switch (prevents API key leakage across profiles, @Hinotoi-agent) | 815 |
| v0.50.13v0.50.15 | session_search + security sweep + KaTeX math | SessionDB injection for session_search in WebUI (@DelightRun), bandit B310/B324/B110 + QuietHTTPServer (@lawrencel1ng), KaTeX math rendering with fence-before-math fix | 871 |
| v0.50.16v0.50.17 | CSRF reverse proxy + Docker uv pre-install | Scheme-aware CSRF port normalization for non-standard ports (@lx3133584), Docker uv pre-installed at build time as root (fixes air-gapped startup, @mmartial-pattern) | 900 |
| v0.50.18v0.50.19 | Workspace fallback + Unicode filenames | Cascading workspace path recovery (@Jordan-SkyLF), Unicode Content-Disposition headers with RFC 5987 filename* (@shaoxianbilly), silent auth error surfacing, stale model cleanup | 924 |
| v0.50.20v0.50.21 | Silent errors + live model fetching + durable streaming recovery | apperror on empty agent response, /api/models/live endpoint with SSRF guard, live reasoning cards, tool_complete SSE events, SESSION_QUEUES, localStorage reload recovery (@Jordan-SkyLF) | 961 |
| v0.50.22v0.50.36-local.1 | Upstream sync + minimal local patch retention | Synced to upstream `v0.50.36`; retained first-password session continuity in Settings/onboarding; removed local Assistant Reply Language enhancement; added legacy settings cleanup regression coverage | 1059 |
| v0.50.37v0.50.40 | Sprint 40 — rendering fixes + KaTeX CSP + MEDIA images | Think-tag edge cases, renderMd link double-linking fix, MEDIA: inline image rendering, KaTeX CSP font-src fix | 1117 |
| v0.50.41v0.50.43 | Sprint 41/42 — context ring, session polish, renderMd hardening | Context indicator live usage, session display fixes, renderMd bold+code stash, outer link pass ordering, _ob_stash, autolink double-link fixes (@multiple contributors) | 1150 |
| v0.50.44 | Renderer formatting bug fixes (#486, #487) | CSS: inline code sizing in table cells; JS: markdown image syntax ![alt](url) → <img> in renderMd + inlineMd; _img_stash for autolink protection | 1195 |
| v0.50.45v0.50.100 | Upstream sync + contributor sprint | Sidebar declutter, SKIP_ONBOARDING, runtime route details, subpath mount, bug batch (light theme/panel/model cache/Docker), Docker UID/GID auto-detect, chat transcript redesign, favicon SVG+PNG+ICO, Docker UID-mismatch crash fix, auto-title markdown strip | 1777 |
| v0.50.101v0.50.139 | Contributor sprint wave | Custom providers, Russian locale, collapsed timestamps, IME composition fixes, model-switch toast, approval queue multi-slot, live model fetching SSRF guard, orphaned tool-message sanitization, profile polish sprint (model routing, workspace cross-profile, legacy session backfill), font-size CSS fix | 1777 |
| v0.50.140v0.50.147 | Bug batch + appearance | Font size setting visibly scales UI text (#843), slash command echoed as user message (#840), scroll selected item into view (#838), tasks refresh button (#835), font size toggle (#833), stale model fix (#829), session search clear on boot (#822), gateway SSE polling fallback (#635) | 1858 |
| v0.50.148v0.50.150 | Session index + read-path + profile | Prune stale _index.json ghost rows after session-id rotation (#847 @franksong2702), GET /api/session side-effect-free model resolution (#848 @franksong2702), profile switching cookie persist + syncTopbar fix (#849 @migueltavares) | 1858 |
| v0.50.151 | credential_pool + Ollama Cloud | Providers added via auth store credential_pool now visible in model dropdown; Ollama Cloud support; ambient gh-cli token suppression; _apply_provider_prefix helper (#820 @starship-s) | 1898 |
| v0.50.152 | Image rendering + auto-title | image_generate MEDIA: token renders all https:// URLs as img regardless of extension (closes #853); auto-title strips Qwen3-style plain-text thinking preambles (closes #857) | 1898 |
| v0.50.153 | Portal model routing | Live-fetched models from portal providers (Nous, OpenCode) now get @provider: prefix so they route correctly instead of falling through to OpenRouter (closes #854) | 1898 |
| v0.50.154 | Thinking card mirror fix | _streamDisplay() early return removed — thinking card and main response now show distinct content when provider double-emits (closes #852) | 1898 |
| v0.50.155 | Honcho session stability | gateway_session_key=session_id passed to AIAgent so Honcho per-session strategy maintains one Honcho session per WebUI chat instead of one per turn (closes #855) | 1903 |
| v0.50.156 | Auto-install security gate | auto_install_agent_deps() is now opt-in; set HERMES_WEBUI_AUTO_INSTALL=1 to enable; _trusted_agent_dir() checks ownership/permission bits before running pip (⚠️ breaking: default changed) | 1903 |
---
@@ -47,14 +93,14 @@
| Layer | Location | Status |
|-------|----------|--------|
| Python server | <repo>/server.py (~81 lines) + api/ modules (~3210 lines) | Thin shell + auth middleware + business logic in api/ |
| HTML template | <repo>/static/index.html (~364 lines) | Served from disk |
| CSS | <repo>/static/style.css (~670 lines) | Served from disk, incl. mobile responsive |
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3610 lines total |
| Python server | <repo>/server.py (~165 lines) + api/ modules (~5000 lines) | Thin shell + QuietHTTPServer + auth middleware + business logic in api/ |
| HTML template | <repo>/static/index.html (~600 lines) | Served from disk |
| CSS | <repo>/static/style.css (~1050 lines) | Served from disk, incl. mobile responsive, KaTeX, table styles |
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands,icons,i18n,login}.js | 10 modules, ~7100 lines total |
| Docker | Dockerfile, docker-compose.yml, .dockerignore | python:3.12-slim, multi-arch (amd64+arm64) |
| CI/CD | .github/workflows/release.yml | Auto-release + GHCR publish on tag push |
| Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files |
| Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run |
| Test server | Port 8788 (conftest.py), port 8789 (browser sanity) | Isolated, wiped per run |
| Production server | Port 8787 | SSH tunnel from Mac |
---
@@ -64,11 +110,12 @@
### Chat and Agent
- [x] Send messages, get SSE-streaming responses
- [x] Switch models per session (10 models, grouped by provider)
- [x] Composer-scoped model picker in footer (moved from sidebar to align with per-conversation model selection)
- [x] Multi-provider API support: use any Hermes agent API provider (OpenAI, Anthropic, Google, etc.) directly, not just OpenRouter (Sprint 11)
- [x] Custom endpoint model discovery: auto-detect models from Ollama, LM Studio, and other local LLM servers via base_url (PR #18)
- [x] Upload files to workspace (drag-drop, click, clipboard paste)
- [x] File tray with remove button
- [x] Tool progress shown in activity bar above composer
- [x] Tool progress shown inline in the conversation via live tool cards
- [x] Approval card for dangerous commands (Allow once/session/always, Deny)
- [x] Approval polling + SSE-pushed approval events
- [x] INFLIGHT guard: switch sessions mid-request without losing response
@@ -80,23 +127,25 @@
- [x] Token/cost estimate per message (Sprint 23)
### Tool Visibility
- [x] Tool progress in activity bar (moved out of composer footer)
- [x] Tool progress in live tool cards (kept out of the composer/footer chrome)
- [x] Approval card with all 4 choices
- [x] Tool call cards inline (collapsed, show name/args/result)
### Workspace / Files
- [x] Workspace panel defaults closed and opens only for active browsing or preview
- [x] Browse workspace directory tree with type icons
- [x] Preview text/code files (read-only)
- [x] Preview markdown files (rendered, tables supported)
- [x] Preview image files (PNG, JPG, GIF, SVG, WEBP inline)
- [x] Edit files inline (Edit button, Enter to save, Escape to cancel)
- [x] Create new file (+ button in panel header)
- [x] Delete file (hover trash, confirm dialog)
- [x] Delete file (hover trash, confirmation modal)
- [x] File name truncation with tooltip for long names
- [x] Right panel resizable (drag inner edge)
- [x] Syntax highlighted code preview (Prism.js)
- [x] Rename file (Sprint 14)
- [x] Create folder (Sprint 14)
- [x] Shared app modal for confirm/input flows (Sprint 33)
### Sessions
- [x] Create session (+ button or Cmd/Ctrl+K)
@@ -186,7 +235,7 @@
- [x] Voice input via Web Speech API (Sprint 20)
### Mobile
- [x] Mobile responsive layout — hamburger sidebar, bottom nav, files slide-over (Sprint 21)
- [x] Mobile responsive layout — hamburger sidebar, sidebar tabs on phones, files slide-over (Sprint 21 + later mobile nav simplification)
### Profiles
- [x] Multi-profile support — create, switch, delete profiles (Sprint 22, Issue #28)
@@ -197,16 +246,17 @@
- [x] Streaming performance -- rAF-throttled token rendering (Sprint 24, PR #81)
- [x] Workspace git detection -- branch name and dirty status badge (Sprint 24, PR #82)
- [x] Collapsible date groups -- click group headers to collapse (Sprint 24, PR #80)
- [x] Context usage indicator -- token count and cost in composer footer (Sprint 24, PR #83)
- [x] Context usage indicator -- compact circular badge in composer footer (Sprint 24, PR #83; refreshed April 10, 2026)
- [ ] LLM-generated session titles -- auto-title via small model instead of first-message substring (PR #75)
- [ ] Workspace git detection -- show branch name, dirty status in workspace header (PR #75)
- [ ] Clarify dialog -- agent can ask clarifying questions that block until user responds (PR #75)
- [ ] Gateway approval polling -- support blocking approvals from messaging gateway (PR #75)
- [ ] Unified session storage -- SessionDB shared between webui and CLI (PR #75)
- [ ] TTS playback of responses (deferred)
- [x] Background task cancel (activity bar Cancel button)
- [x] Background task cancel (composer footer stop button)
- [ ] Code execution cell (deferred)
- [ ] Desktop application (deferred)
- [ ] Desktop application (Sprint 25, PLANNED)
- [x] Pluggable UI themes -- Dark, Light, Slate, Solarized, Monokai, Nord (Sprint 26, v0.34)
- [ ] Extended slash command / skill integration (deferred)
- [ ] Virtual scroll for large lists (deferred)

View File

@@ -1,32 +1,45 @@
# Hermes Web UI -- Forward Sprint Plan
> Current state: v0.32 | 424 tests | Daily driver ready
> This document plans the path from here to two targets:
> Current state: v0.50.156 | 1903 tests | Full daily driver — CLI parity achieved
>
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
> terminal, you can do from the browser)
> NOTE: This file is preserved as a historical planning record. Current sprint state
> and version history live in CHANGELOG.md and ROADMAP.md.
>
> Target B: 1:1 parity with Claude's reproducible features (the full Claude
> browser UI experience, minus things only Anthropic can build)
> Target A (CLI parity): ✅ Complete — all core tools, workspace, cron, skills,
> memory, sessions, profiles, model routing, streaming, voice, mobile.
>
> Sprints are ordered by impact. Each builds on the one before.
> Past sprint history lives in CHANGELOG.md.
> Target B (Claude parity): ~90% — thinking display, math rendering (KaTeX),
> tool cards, workspace preview, onboarding, settings panel all done.
> Remaining: full subagent transparency UI, file diff viewer.
>
> Last meaningful update: v0.50.21 (April 13, 2026). See CHANGELOG.md for full history.
---
## Where we are now (v0.21)
## Where we are now (v0.50.21 — updated April 2026)
**CLI parity: ~90% complete.** Core agent loop, all tools visible, workspace
file ops with tree view, cron/skills/memory CRUD, session management, streaming,
cancel, multi-provider models, custom endpoint discovery, slash commands,
thinking/reasoning display, password auth -- all solid. Gaps are subagent
visibility, toolset control, and code execution.
> The sections below describe the state as of v0.36 for historical reference.
> See ROADMAP.md for the current sprint history table (v0.36 → v0.50.21).
**Claude parity: ~70% complete.** Chat, streaming, file browser, session
management, tool cards, syntax highlighting, model switching, projects,
settings, Mermaid diagrams, mobile layout, breadcrumb workspace nav, slash
commands, thinking display, auth -- all present. Gaps are artifacts, voice,
TTS, sharing, mobile-optimized layout.
**CLI parity: ✅ Complete** as of v0.50.x. Core agent loop, all tools visible, workspace
file ops with tree view and git detection, cron/skills/memory CRUD, session
management, streaming with rAF throttle, cancel, multi-provider models, custom
endpoint discovery, slash commands (help/clear/model/workspace/new/usage/theme/compact),
thinking/reasoning display, password auth, multi-profile support with seamless
switching, CLI session bridge (read and import from state.db), context
auto-compaction handling, self-update checker. Remaining gaps: subagent
session tree, toolset control per session, code execution cells.
**Claude parity: ~85% complete.** Chat, streaming, file browser, session
management with projects and tags, tool cards with subagent delegation,
syntax highlighting, model switching, Mermaid diagrams, mobile responsive
layout (hamburger sidebar, bottom nav, files slide-over), breadcrumb
workspace nav with tree view, slash commands, thinking/reasoning display,
auth with signed cookies, 6 pluggable UI themes (dark/light/slate/solarized/
monokai/nord), voice input (Web Speech API), collapsible date groups,
context usage indicator, token/cost display, git branch badge, Docker
support. Remaining gaps: artifacts (HTML/SVG preview), TTS playback,
sharing/public URLs, code execution inline.
---
@@ -75,7 +88,7 @@ heavy agentic work.
---
## Sprint 12 -- Settings Panel + Reliability + Session QoL
## Sprint 12 -- Settings Panel + Reliability + Session QoL (COMPLETED)
**Theme:** Persist your preferences, survive network blips, and organize sessions.
@@ -118,7 +131,7 @@ to keep important conversations accessible.
---
## Sprint 13 -- Alerts, Session QoL, Polish
## Sprint 13 -- Alerts, Session QoL, Polish (COMPLETED)
**Theme:** Know what Hermes is doing, and small quality-of-life wins.
@@ -248,7 +261,7 @@ inconsistently across platforms. These were the most common visual complaints.
button now only appears in the hover overlay like all other actions.
### Track B: Features
- **SVG action icons.** Replaced all emoji HTML entities (★, 📂, 📦, ⊕, 🗑)
- **SVG action icons.** Replaced old symbol and emoji HTML entities
with monochrome SVG line icons that inherit `currentColor`. Consistent
rendering across macOS, Linux, and Windows. Icons: pin (star), folder,
archive (box), duplicate (overlapping squares), trash (bin with lines).
@@ -511,15 +524,14 @@ single default profile, blocking multi-persona workflows.
---
## Sprint 23 -- Profile/Workspace/Model Coherence (COMPLETED)
## Sprint 23 -- Agentic Transparency + Context Visibility (COMPLETED)
**Theme:** Make profiles, workspaces, models, and sessions coherent across
profile switches.
**Theme:** Surface what the agent is doing and how much context it's using.
**Why now:** Sprint 22 added profile switching but five coherence bugs remained:
the model picker ignored the profile's default, workspaces were a global file,
DEFAULT_WORKSPACE was a startup singleton, the session list showed all profiles,
and switchToProfile() didn't refresh workspaces or sessions.
**Why now:** Users had no visibility into tool call arguments, session token
usage, or context window fill. Sprint 22 left five coherence bugs in the
profile/workspace/model flow that also needed closing before the UI felt
reliable.
### Track A: Bugs
- **Model picker ignores profile on switch.** `populateModelDropdown()` skipped
@@ -611,12 +623,9 @@ the app to others.
CSS `contain: strict` + IntersectionObserver approach, no library needed.
### Track C: Code Quality
- **SPRINTS.md + ROADMAP.md + CHANGELOG.md updated** to reflect Sprint 23
completion (agentic transparency) and correct test counts.
- **Remove stale Sprint 23 description** from SPRINTS.md (the "Profile/Workspace
coherence" text is from an older plan; Sprint 23 actually shipped agentic
transparency features).
- **CHANGELOG entry for v0.29** covering Sprint 23 deliverables.
- Audit and remove any remaining dead code introduced by Sprint 23 (e.g. `S.lastUsage` assignment in messages.js that nothing reads).
- Verify tool call args render correctly in settled history cards on session reload.
- Update test count in all docs to match actual pytest output after sprint merges.
**Estimated tests:** ~10 new. Target total: ~435.
**Hermes CLI parity impact:** Low
@@ -758,7 +767,7 @@ Both architectures in one .app. No separate downloads needed.
- JS bridge fires when approval card appears/disappears
**Menu bar mode (optional, v2):**
- A small status bar item (⚗️ icon in menu bar) that opens a compact popover
- A small status bar item (beaker icon in menu bar) that opens a compact popover
- Popover shows current session status, last message, quick-compose field
- Useful for running Hermes in the background without a full window
@@ -897,6 +906,270 @@ genuinely differentiating for an open-source project
---
*Last updated: April 5, 2026*
*Current version: v0.32 | 424 tests*
## Sprint 26 -- Pluggable UI Themes (COMPLETED)
**Theme:** Let users choose how the app looks -- light, dark, and custom color
schemes. One-click switching, persistent preference, zero flicker on load.
**Difficulty: Low-Medium.** The existing CSS is already 100% CSS-variable-driven
off a single `:root` block. Every color, background, and accent in the entire UI
is already a variable. Adding themes is mostly a matter of defining alternative
`:root` overrides and wiring a picker -- not a rewrite. The main engineering
work is flicker prevention on load and the settings UI.
**Estimated effort:** 1 sprint, ~2 days of implementation. 8-12 new tests.
---
### Why now
The UI ships only one dark theme. Contributors have asked for light mode. Power
users want to match their terminal colorscheme. This is low-risk, high-value
polish that makes the app feel more finished and more personal. It's also a
good precedent-setter: once the theme system exists, community members can
contribute new themes as a pure CSS addition with no Python changes needed.
---
### Design decisions
**Themes are CSS-variable overrides, not separate stylesheets.** Each theme is
a named `:root[data-theme="name"]` block. The base stylesheet stays untouched.
Switching themes sets `document.documentElement.dataset.theme = name` in JS.
No FOUC (flash of unstyled content), no stylesheet swap latency.
**Theme preference persists server-side in `settings.json`.** Same mechanism
as `send_key` and `show_token_usage`. The server includes `theme` in the
`GET /api/settings` response. Boot.js reads it and applies before first paint.
**Flicker prevention.** A tiny inline `<script>` in `<head>` (before the
stylesheet link) reads `localStorage.getItem('hermes-theme')` and sets
`document.documentElement.dataset.theme` synchronously. This prevents a
dark-flash on light-mode users during the round-trip to `/api/settings`.
The localStorage value is kept in sync whenever the user changes themes.
**No third-party dependencies.** Pure CSS + vanilla JS. No theme library.
---
### Track A: Core theme system
**1. CSS variable blocks in `static/style.css`**
The existing `:root` block becomes the `dark` (default) theme. Add named
theme blocks immediately after:
```css
/* ── Default (dark) theme ── already in :root ── */
:root[data-theme="light"] {
--bg: #f5f5f7;
--sidebar: #e8e8ed;
--border: rgba(0,0,0,0.10);
--border2: rgba(0,0,0,0.16);
--text: #1c1c1e;
--muted: #6e6e80;
--accent: #c0392b;
--blue: #0a6dc2;
--gold: #a07a20;
--code-bg: #f0f0f5;
}
:root[data-theme="solarized"] {
--bg: #002b36;
--sidebar: #073642;
--border: rgba(255,255,255,0.08);
--border2: rgba(255,255,255,0.13);
--text: #839496;
--muted: #657b83;
--accent: #dc322f;
--blue: #268bd2;
--gold: #b58900;
--code-bg: #073642;
}
:root[data-theme="monokai"] {
--bg: #272822;
--sidebar: #1e1f1c;
--border: rgba(255,255,255,0.07);
--border2: rgba(255,255,255,0.12);
--text: #f8f8f2;
--muted: #75715e;
--accent: #f92672;
--blue: #66d9e8;
--gold: #e6db74;
--code-bg: #1e1f1c;
}
:root[data-theme="nord"] {
--bg: #2e3440;
--sidebar: #272c36;
--border: rgba(255,255,255,0.07);
--border2: rgba(255,255,255,0.12);
--text: #eceff4;
--muted: #9099aa;
--accent: #bf616a;
--blue: #81a1c1;
--gold: #ebcb8b;
--code-bg: #272c36;
}
```
Additional theming notes:
- `syntax-highlight` colors (Prism.js) are theme-independent (they come from the
CDN stylesheet) -- acceptable for v1.
- The logo gradient (`linear-gradient(145deg,#e8a030,var(--accent))`) uses
`--accent` already so it adapts automatically.
- Scrollbar colors and `::selection` backgrounds need explicit overrides in the
light theme to avoid dark scrollbars on a light background.
**2. Flicker-prevention inline script in `static/index.html`**
Immediately after `<head>` opens, before the stylesheet `<link>`:
```html
<script>
(function(){
var t=localStorage.getItem('hermes-theme');
if(t && t!=='dark') document.documentElement.dataset.theme=t;
})();
</script>
```
This runs synchronously before the stylesheet parses. Zero flicker.
**3. Theme loading in `static/boot.js`**
In the existing `api('/api/settings')` call, read and apply the theme:
```js
const s = await api('/api/settings');
window._sendKey = s.send_key || 'enter';
window._showTokenUsage = !!s.show_token_usage;
window._showCliSessions = !!s.show_cli_sessions;
// Theme: apply server preference, update localStorage for flicker prevention
const theme = s.theme || 'dark';
document.documentElement.dataset.theme = theme;
localStorage.setItem('hermes-theme', theme);
```
**4. Theme setting in `api/config.py`**
```python
_SETTINGS_DEFAULTS = {
...
'theme': 'dark', # active UI theme name
...
}
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
```
No enum constraint on `theme` -- allows user-defined theme names to work
without server changes.
---
### Track B: Theme picker UI
**Settings panel addition (`static/index.html` + `static/panels.js`)**
A `<select>` in the Settings panel, below the send-key picker:
```html
<div class="settings-field">
<label for="settingsTheme">Theme</label>
<select id="settingsTheme" ...>
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
</select>
</div>
```
In `loadSettingsPanel()`:
```js
const themeSel = $('settingsTheme');
if(themeSel) themeSel.value = settings.theme || 'dark';
```
In `saveSettings()`:
```js
body.theme = $('settingsTheme').value;
```
**Live preview on select change (no save required):**
```js
$('settingsTheme').addEventListener('change', e => {
document.documentElement.dataset.theme = e.target.value;
localStorage.setItem('hermes-theme', e.target.value);
});
```
This gives instant visual feedback as the user clicks through options.
The full settings save then persists it server-side.
**`/theme` slash command (`static/commands.js`)**
```js
async function cmdTheme(arg) {
const themes = ['dark','light','solarized','monokai','nord'];
if(!arg || !themes.includes(arg)) {
showToast('Usage: /theme dark|light|solarized|monokai|nord');
return;
}
document.documentElement.dataset.theme = arg;
localStorage.setItem('hermes-theme', arg);
try { await api('/api/settings', {method:'POST', body: JSON.stringify({theme: arg})}); } catch(e) {}
showToast('Theme: ' + arg);
}
```
---
### Track C: Tests
New test cases in `tests/test_sprint26.py`:
1. `GET /api/settings` returns `theme: 'dark'` by default
2. `POST /api/settings` with `{theme: 'light'}` persists and round-trips
3. `POST /api/settings` with `{theme: 'nord'}` accepts any string (no enum gate)
4. Theme value survives server restart (reads from `settings.json`)
5. `/theme` command fires without error for each named theme
6. `loadSettingsPanel()` populates the select with the current theme value
7. Settings save includes theme in the POST body
8. `data-theme` attribute is set on `<html>` before first paint (inline script)
**Estimated new tests:** 8. Target total after sprint: ~443.
---
### What's out of scope
- **Custom color editors** (hex pickers for each variable): saves that for v2.
The five shipped themes cover the main use cases. A custom theme can always
be added by dropping a CSS block with no code changes.
- **Per-session themes**: single global preference is the right call for v1.
- **System `prefers-color-scheme` sync**: nice-to-have, low priority. The
flicker-prevention script could be extended to read the media query if no
explicit preference is set.
- **Prism.js theme switching**: the code-block syntax highlighting comes from
a CDN stylesheet. Swapping it requires a `<link>` swap and SRI re-check.
Defer to a future sprint; the default Prism Tomorrow theme works on all
current dark themes and is acceptable on light.
---
**Estimated tests:** 8 new. Target total: ~443.
**Hermes CLI parity impact:** None
**Claude parity impact:** Medium (Claude.ai has light/dark/system sync)
**User-facing value:** High -- first thing many users ask for
---
*Last updated: April 12, 2026*
*Current version: v0.49.1 | 700 tests*
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
*Docs sweep policy: update markdown proactively during PR reviews and after significant releases*

View File

@@ -1,15 +1,17 @@
# Hermes Web UI: Browser Testing Plan
> This document is for manual browser testing by you or by a Claude browser agent.
> It covers user-facing features of the UI through Sprint 22 (v0.24).
> It covers user-facing features of the UI through v0.50.21 and later releases.
> Each section is written as a step-by-step test procedure with expected outcomes.
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
>
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated tests: 424 total (424 passing, 0 failures)
> Automated coverage: 2591 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
---
@@ -32,9 +34,11 @@ SETUP: Clear localStorage (DevTools > Application > Local Storage > delete herme
STEPS:
1. Navigate to http://localhost:8787
EXPECT:
- Dark background, Hermes logo in sidebar header
- Dark background
- Sidebar begins directly with the icon tab row; there is no dedicated branding header
- Center area shows "What can I help with?" heading with suggestion buttons
- Session list in sidebar is empty or shows existing sessions
- Sidebar footer shows a single "Hermes WebUI" control-center button
- No session is highlighted active
- Send button is present but there is no input focus by default
FAIL: Page shows error, blank white screen, or auto-creates a new session without user action.
@@ -73,11 +77,11 @@ STEPS:
EXPECT:
- User message appears immediately in chat
- Thinking dots (three animated dots) appear below
- Status bar shows "Hermes is thinking..."
- Send button becomes disabled (grayed out)
- A red stop button appears in the composer footer while the turn is running
- Within 10-30 seconds, Hermes responds with a three-word greeting
- Thinking dots disappear
- Send button re-enables
- Send button re-enables and the stop button disappears
- Session title in sidebar updates to reflect the first message
FAIL: Message never appears, thinking dots never go away, Send button stays disabled forever.
@@ -144,7 +148,7 @@ FAIL: New session created, error thrown, or UI breaks.
### T3.1: Model Dropdown Shows All Options
SETUP: Any active session.
STEPS:
1. Look at the sidebar bottom: "Model" label and a dropdown
1. Look at the composer footer: to the right of the attach/mic controls there is a model dropdown
2. Click the dropdown to expand it
EXPECT:
- Provider groups visible: OpenAI, Anthropic, Other
@@ -153,18 +157,30 @@ EXPECT:
- Other group: Gemini 2.5 Pro, DeepSeek V3, Llama 4 Scout
FAIL: Only 2 options visible, no groups, or missing models.
### T3.2: Model Chip Reflects Selection
### T3.2: Model Dropdown Reflects Active Conversation
SETUP: Active session.
STEPS:
1. Change model dropdown to "Claude Sonnet 4.6"
EXPECT:
- The blue chip in the topbar right updates to "Sonnet 4.6" immediately
- NOT "GPT-5.4 Mini" (this was Bug B3, now fixed)
- The composer footer dropdown stays on "Claude Sonnet 4.6"
- Sending the next message uses that session model rather than an older one from another conversation
STEPS (continued):
2. Change model to "Gemini 2.5 Pro"
EXPECT:
- Chip updates to "Gemini 2.5 Pro" (not "GPT-5.4 Mini")
FAIL: Chip shows wrong model name for any non-Sonnet selection.
- The dropdown updates to "Gemini 2.5 Pro"
- Switching away and back to the conversation restores the same model in the footer selector
FAIL: Dropdown shows the wrong active model after a session switch, or sending uses a stale model.
### T3.3: Context Badge Shares Footer Space Cleanly
SETUP: Active session with at least one completed response.
STEPS:
1. Look at the right side of the composer footer
EXPECT:
- A compact circular context badge appears next to the send button when usage data is available
- The number in the center shows the used percentage
- Hovering or focusing the badge shows a tooltip with percent used, token count, auto-compress threshold, and estimated cost when available
- The model dropdown remains usable without overlapping the send button or pushing controls out of view
FAIL: Linear meter still shown, tooltip missing/incomplete, controls overlap, or footer wraps in a broken way.
---
@@ -228,8 +244,18 @@ FAIL: File not removed, error.
## Section 5: Workspace File Browser
### T5.1: File Tree Loads on Session Start
### T5.0: Panel Is Closed By Default
SETUP: Active session with workspace set.
EXPECT:
- Right workspace panel is hidden on initial load
- Center chat column uses the freed width
- "Files" toggle is visible in the topbar
FAIL: Right panel starts open without any browsing or preview action.
### T5.1: File Tree Loads When Files Panel Is Opened
SETUP: Active session with workspace set.
STEPS:
1. Click the "Files" toggle in the topbar
EXPECT:
- Right panel shows "WORKSPACE" header
- File tree lists files and directories in the workspace
@@ -263,10 +289,11 @@ STEPS:
1. Click the X button in the panel header
EXPECT:
- Preview closes
- File tree is visible again
- If the panel auto-opened for that preview, the entire right panel closes again
- If the panel was manually opened for browsing first, the file tree is visible again
- Preview area is hidden
- Reopening the same file shows fresh content (no stale cached text)
FAIL: X button does nothing, tree does not reappear.
FAIL: X button does nothing, panel stays stuck open, or the file tree does not reappear after manual browse mode.
### T5.5: Preview an Image File (Sprint 2)
SETUP: Upload a PNG, JPG, or any image file to the workspace, OR the workspace already contains one.
@@ -377,7 +404,8 @@ FAIL: Command blocked after Allow once, card stays, error.
### T8.1: Download Conversation as Markdown
SETUP: A session with at least 2 messages (1 user + 1 assistant).
STEPS:
1. Click the "Transcript" download button in the sidebar bottom
1. Click the "Hermes" button in the sidebar footer
2. In the Control Center modal, click "Transcript"
EXPECT:
- Browser downloads a .md file named hermes-{session_id}.md
- Opening the file shows the conversation in markdown format:
@@ -468,6 +496,7 @@ FAIL: No log output, log shows Apache-style text instead of JSON, log file not c
SETUP: Message is sending (thinking dots visible).
EXPECT:
- Send button is visually grayed out
- Stop button is visible in the composer footer
- Pressing Enter does NOT send another message
- Clicking Send button does nothing
FAIL: Multiple messages sent while one is in flight.
@@ -831,7 +860,7 @@ FAIL: No icon ever appears, icon always visible (not hover-only).
### T21.2: Delete a File with Confirmation
STEPS:
1. Hover over a file and click its trash icon
2. A browser confirm dialog appears: "Delete [filename]?"
2. An in-app confirmation modal appears: "Delete [filename]?"
3. Click OK
EXPECT:
- Toast: "Deleted [filename]"
@@ -842,7 +871,7 @@ FAIL: File not deleted, no confirmation dialog, error.
### T21.3: Cancel Delete Does Nothing
STEPS:
1. Hover over a file and click its trash icon
2. Click Cancel on the confirm dialog
2. Click Cancel on the confirmation modal
EXPECT:
- File remains in the tree
- No toast, no error
@@ -851,7 +880,7 @@ FAIL: File deleted despite cancel.
### T21.4: Create a New File
STEPS:
1. Click the + button in the workspace panel header
2. A prompt dialog appears: "New file name (e.g. notes.md):"
2. An in-app input modal appears: "New file name (e.g. notes.md):"
3. Type "test-sprint4.md" and click OK
EXPECT:
- Toast: "Created test-sprint4.md"
@@ -925,7 +954,7 @@ FAIL: Invalid path added, no error.
### T22.4: Remove a Workspace
STEPS:
1. Click the X button next to any non-default workspace
2. Confirm the dialog
2. Confirm the modal
EXPECT:
- Workspace disappears from the list
- Toast: "Workspace removed"
@@ -981,7 +1010,7 @@ STEPS:
1. Hover over an assistant message
2. Click the clipboard icon
EXPECT:
- Icon briefly shows a checkmark (✓) then reverts to clipboard
- Icon briefly shows a check icon, then reverts to the copy icon
- Paste (Cmd+V) elsewhere shows the full text of that message
FAIL: No visual feedback, clipboard empty or wrong content.
@@ -994,23 +1023,23 @@ STEPS:
1. Click any .py, .js, or .txt file in the workspace file tree
EXPECT:
- File content shows in read-only monospace view
- An "✎ Edit" button is visible in the preview path bar
- An Edit button with a pencil icon is visible in the preview path bar
- Content is NOT editable (clicking in it does nothing)
FAIL: Content immediately editable, no Edit button.
### T24.2: Edit Button Enters Edit Mode
STEPS:
1. Click "✎ Edit" on a code file preview
1. Click the Edit button on a code file preview
EXPECT:
- Read-only view replaced by an editable textarea
- Content of the file is pre-populated in the textarea
- Button changes to "💾 Save"
- Button changes to "Save" with a disk icon
FAIL: Nothing changes, button doesn't change.
### T24.3: Save Writes Changes to Disk
STEPS:
1. In edit mode, change some text
2. Click "💾 Save"
2. Click the Save button
EXPECT:
- Read-only view returns, showing the updated content
- Toast: "Saved"
@@ -1022,8 +1051,8 @@ STEPS:
1. Enter edit mode on a file
2. Make any change (type a character)
EXPECT:
- Button shows "💾 Save*" (asterisk indicates unsaved changes)
FAIL: No asterisk, button stays as "💾 Save".
- Button shows "Save*" with the disk icon still visible (asterisk indicates unsaved changes)
FAIL: No asterisk, button stays as "Save".
### T24.5: Markdown File Edit-Save Roundtrip
STEPS:
@@ -1070,7 +1099,7 @@ against each criterion below. A Claude browser agent can verify these with brows
### T25.1: Sidebar Nav Tabs are Icon-Only
EXPECT:
- Five icon-only tabs in the sidebar nav row: 💬 ⏱️ 📚 🧠 📁
- Five icon-only tabs in the sidebar nav row: message, clock, book, brain, folder
- No text labels visible by default (text removed to prevent overflow)
- Hovering a tab shows a tooltip with the label (Chat/Tasks/Skills/Memory/Spaces)
- Active tab has a blue underline, icon brighter blue
@@ -1194,7 +1223,7 @@ STEPS:
3. Click Create job
EXPECT:
- Form closes
- Toast: "Job created"
- Toast: "Job created"
- New job appears in the cron list with status "active"
FAIL: Error shown, job not created, form stays open.
@@ -1225,7 +1254,8 @@ FAIL: Job created, form doesn't close.
### T28.1: JSON Export Button Downloads File
SETUP: Active session with at least a few messages.
STEPS:
1. Click the "JSON" button in the sidebar footer (next to Transcript)
1. Click the "Hermes" button in the sidebar footer
2. In the Control Center modal, click "JSON"
EXPECT:
- Browser downloads a file named hermes-{session_id}.json
- Opening the file shows valid JSON with: session_id, title, messages array,
@@ -1289,7 +1319,7 @@ STEPS (continued from T29.1):
1. Change the name field to "Renamed Job"
2. Click Save
EXPECT:
- Form closes, toast "Job updated"
- Form closes, toast "Job updated"
- Job header shows new name
FAIL: Save fails, name unchanged.
@@ -1297,7 +1327,7 @@ FAIL: Save fails, name unchanged.
SETUP: A cron job you can safely delete (or a test job created for this).
STEPS:
1. Expand the job, click "Delete"
2. Confirm the dialog
2. Confirm the modal
EXPECT:
- Toast: "Job deleted"
- Job disappears from the list
@@ -1326,7 +1356,7 @@ tags: [test]
# Test"
2. Click Save skill
EXPECT:
- Toast "Skill created", form closes
- Toast "Skill created", form closes
- Skill appears in the skills list
FAIL: Error, skill not in list.
@@ -1354,7 +1384,7 @@ STEPS:
1. In edit mode, add a line to the textarea
2. Click Save
EXPECT:
- Toast "Memory saved", form closes
- Toast "Memory saved", form closes
- Memory panel reloads showing the updated content
FAIL: Save fails, content unchanged.
@@ -1467,14 +1497,16 @@ FAIL: Both messages removed, wrong message sent, crash.
### T34.1: Clear Button Appears When Session Has Messages
SETUP: Session with at least one message.
EXPECT:
- A "🗑 Clear" chip appears in the topbar right side (next to the workspace chip)
- Button NOT visible when session has no messages / empty state
- The "Hermes" button is visible in the sidebar footer
- Opening the Control Center shows a "Clear" action in the Conversation section
- The Clear action is disabled when there is no active session or no messages
FAIL: Button always visible, never visible.
### T34.2: Clear Wipes Messages and Resets Title
STEPS:
1. Click the Clear button in the topbar
2. Confirm the dialog
1. Click the "Hermes" button in the sidebar footer
2. Click "Clear" in the Conversation section
3. Confirm the modal
EXPECT:
- All messages disappear from the chat area
- Empty state ("What can I help with?") reappears
@@ -1485,7 +1517,7 @@ FAIL: Session deleted, messages remain, title not reset.
### T34.3: Cancel Clear Does Nothing
STEPS:
1. Click Clear, then click Cancel in the confirm dialog
1. Click Clear, then click Cancel in the confirmation modal
EXPECT:
- All messages still present
- No toast, no change
@@ -1609,7 +1641,7 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
- Switch model. Send a message. Verify response uses selected model.
### Sprint 12: Settings + Pin + Import
- Click gear icon. Settings overlay opens.
- Click the "Hermes WebUI" button in the sidebar footer. Control Center overlay opens with vertical section tabs on the left.
- Change default model, save. Restart server. Verify setting persisted.
- Pin a session (star icon in hover overlay). Verify it floats to top of list.
- Export session as JSON. Import it back. Verify messages restored.
@@ -1637,11 +1669,12 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
### Sprint 16: Sidebar Visual Polish
- Session titles use full sidebar width (no truncated space for hidden icons).
- Hover a session → action buttons appear from right with gradient fade.
- Hover a session → a dotted actions trigger appears on the right.
- Click the dotted trigger → a dropdown opens with pin, project, archive, duplicate, and delete actions.
- All icons are monochrome SVGs (not emoji). Consistent across platforms.
- Pinned sessions show small gold star inline. Unpinned = no star, full title width.
- Active session has gold highlight (not blue). Overlay gradient matches.
- Double-click to rename → overlay hides during rename.
- Active session has gold highlight (not blue).
- Double-click to rename → session actions hide during rename.
### Sprint 17: Workspace + Slash Commands + Send Key
- Navigate into a subdirectory. Breadcrumb bar appears with clickable segments.
@@ -1655,6 +1688,13 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
- Click a directory toggle arrow (▸) → expands in-place showing children.
- Click again (▾) → collapses. Double-click navigates into it (breadcrumb view).
- If model returns thinking blocks (Claude extended thinking), verify collapsible gold card appears above response.
- Verify the thinking card has a tinted background, visible border, and rounded corners like a tool card, but in the gold thinking palette.
- Open and close a thinking card. Verify the caret rotation and the content reveal both animate smoothly instead of snapping open.
### UI Polish: Tool Card Disclosure Animation
- Trigger a response with at least one completed tool call card.
- Open and close the tool call card. Verify the caret rotates smoothly and the args/result section animates open and closed instead of appearing instantly.
- If a turn has 2+ tool cards, use "Expand all / Collapse all" and verify the same smooth animation applies to every card in the group.
### Sprint 19: Auth + Security
- No password set: everything works as normal. No login page.
@@ -1684,12 +1724,13 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
- Open on mobile viewport (<640px): hamburger icon visible in topbar.
- Tap hamburger → sidebar slides in from left with backdrop overlay.
- Tap outside sidebar → closes. Tap a session → closes and loads session.
- Bottom navigation bar: 5 tabs (Chat, Tasks, Skills, Memory, Spaces).
- Tap "Tasks" in bottom nav → sidebar opens showing Tasks panel.
- Tap "Chat" in bottom nav → sidebar closes (chat is in main area).
- Sidebar top nav remains visible inside the mobile drawer; includes Chat/Tasks/Skills/Memory/Spaces/Profile tabs.
- Tap "Tasks" in the drawer nav → Tasks panel opens in the sidebar drawer.
- Tap "Chat" in the drawer nav → sidebar closes and chat is unobstructed in the main area.
- Files button in topbar → right panel slides in from right.
- No fixed mobile bottom nav; chat transcript and composer use the reclaimed vertical space.
- All touch targets are at least 44px (session items, buttons, icons).
- Desktop viewport (>640px): no hamburger, no bottom nav, no mobile elements.
- Desktop viewport (>640px): no hamburger or mobile overlay; desktop layout unchanged.
- Docker: `docker compose up -d` starts server on port 8787.
- Docker: session data persists across container restarts (named volume).
@@ -1702,14 +1743,47 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
- "Use" button switches profile. Delete button removes non-default profiles.
- "+ New profile" form: name validation (lowercase + hyphens), clone config checkbox.
- Create profile → appears in list and dropdown.
- Delete profile → confirm dialog. Auto-switches to default if deleting active.
- Delete profile → confirmation modal. Auto-switches to default if deleting active.
- Attempt switch while agent busy → blocked with toast message.
- With hermes-agent not installed → only default profile shown, graceful fallback.
---
*Last updated: Sprint 22 / v0.24, April 3, 2026*
*Total automated tests: 415 (392 passing, 23 pre-existing failures)*
*Regression gate: tests/test_regressions.py (23 tests)*
## Slash command parity (manual checklist)
For each batch-1 command, run via webui slash menu AND via `hermes` CLI in the
same `HERMES_HOME` (when applicable) and verify identical effect.
- [ ] `/help` — dropdown lists 25+ commands; selecting `/help` posts an assistant message listing them.
- [ ] `/new` (and alias `/reset`) — starts fresh session.
- [ ] `/clear` — clears current transcript display (webui-only meaning, distinct from CLI's "clear screen").
- [ ] `/title <name>` — renames active session, topbar + sidebar update; `/title` alone shows current title.
- [ ] `/status` — assistant message shows session_id, model, workspace, message count.
- [ ] `/usage` — assistant message shows token counts; the "show token usage" setting is unchanged (toggle still in Settings panel).
- [ ] `/stop` — interrupts a running stream; with no active stream toasts "No active task to stop."
- [ ] `/retry` — removes last user+assistant exchange, refills composer with last user text, resends. Final transcript has only ONE copy of the resent message.
- [ ] `/undo` — removes last user+assistant exchange; toast confirms; repeated until empty toasts "Nothing to undo."
- [ ] `/model <name>` — switches model dropdown.
- [ ] `/personality` — lists personalities; `/personality <name>` switches.
- [ ] `/skills [query]` — lists matching skills.
- [ ] `/theme <name>` — switches webui theme.
- [ ] `/workspace <name>` — switches workspace.
Unknown / deferred:
- [ ] `/yolo`, `/reasoning`, `/voice`, `/branch`, `/insights`, `/debug`, `/reload`, etc. — toast "Web UI 暂未实现该命令: /<name>". MUST NOT be sent as plain text to the LLM.
- [ ] `/compact` — toast "/compress is not available in the web UI yet — use the CLI for now." (was sending free text to LLM before this batch.)
- [ ] Made-up command (e.g. `/fhfajl`) — fall through to send as text (existing behavior preserved for typos vs. real commands).
Bridged CLI sessions:
- [ ] Open a CLI-bridged session in webui sidebar (if `show_cli_sessions` setting enabled).
- [ ] `/retry`, `/undo` toast "该命令仅支持 Web UI 原生会话…" and do nothing.
---
*Last updated: v0.50.91, April 19, 2026*
*Total automated tests collected: 2107*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

145
THEMES.md Normal file
View File

@@ -0,0 +1,145 @@
# Hermes Web UI — Themes
Hermes Web UI supports pluggable color themes. Seven themes ship built-in, and
you can create your own with pure CSS — no Python changes needed.
---
## Switching Themes
**Settings panel:** Click the gear icon, select a theme from the dropdown. The
preview is instant — the UI updates as you click through options.
**Slash command:** Type `/theme dark` or `/theme light` in the composer.
**Themes persist** across page reloads and server restarts (stored in
`settings.json` server-side, with `localStorage` for flicker-free loading).
---
## Built-in Themes
| Theme | Description |
|-------|-------------|
| **Dark** (default) | Deep navy/indigo with muted blue accents. Easy on the eyes for long sessions. |
| **Light** | Warm off-white with dark text. High contrast for bright environments. |
| **Slate** | Warm charcoal, lighter than Dark. Easier on the eyes for extended use. |
| **Solarized Dark** | Ethan Schoonover's classic dark palette. Teal background, warm accents. |
| **Monokai** | Warm dark theme inspired by the Monokai editor scheme. Green/pink accents. |
| **Nord** | Arctic blue-gray palette from the Nord color system. Calm and minimal. |
| **OLED** | True black (#000) backgrounds for OLED displays. Minimizes glow and burn-in risk. |
| **Custom themes** | Any string accepted by `settings.json`, `POST /api/settings`, and `/theme` if added to the picker/command list. Pure CSS variables only. |
---
## Creating a Custom Theme
A theme is a CSS block that overrides the color variables. Add it to
`static/style.css` (or a separate file that you link after the main stylesheet).
### Step 1: Define your theme block
Every color in the UI comes from these CSS variables:
```css
:root[data-theme="your-theme-name"] {
/* Core palette */
--bg: #1a1a2e; /* Main background */
--sidebar: #16213e; /* Sidebar background */
--border: rgba(255,255,255,0.08); /* Subtle borders */
--border2: rgba(255,255,255,0.14); /* Stronger borders */
--text: #e8e8f0; /* Primary text color */
--muted: #8888aa; /* Secondary/muted text */
--accent: #e94560; /* Accent color (errors, warnings, delete) */
--blue: #7cb9ff; /* Primary action color (links, active states) */
--gold: #c9a84c; /* Secondary accent (pinned items, gold highlights) */
--code-bg: #0d1117; /* Code block background */
/* Surface and chrome (required for full theme polish) */
--surface: #1a2535; /* Dropdowns, popups, toast, approval card */
--topbar-bg: rgba(22,33,62,.98); /* Topbar background */
--main-bg: rgba(26,26,46,0.5); /* Main chat area background */
--input-bg: rgba(255,255,255,.04); /* Input/button subtle backgrounds */
--hover-bg: rgba(255,255,255,.06); /* Hover state backgrounds */
--focus-ring: rgba(124,185,255,.35); /* Focus border color */
--focus-glow: rgba(124,185,255,.08); /* Focus box-shadow glow */
/* Typography (required for readable text across themes) */
--strong: #fff; /* Bold text in messages */
--em: #c9c9e8; /* Italic text in messages */
--code-text: #f0c27f; /* Inline code text color */
--code-inline-bg: rgba(0,0,0,.35); /* Inline code background */
--pre-text: #e2e8f0; /* Code block text color */
}
```
The **core palette** controls the overall mood. The **surface/chrome** and
**typography** variables are part of the standard theme contract — define all
of them for a complete theme.
For **light themes**, you also need `:root[data-theme="name"]` overrides
for elements that use `rgba(255,255,255,.XX)` hover/border effects (these
are invisible on light backgrounds). See the built-in light theme for the
full pattern — it overrides ~45 selectors for proper dark-on-light contrast
on hover states, borders, chips, role labels, session items, and
interactive elements.
### Step 2: Add it to the theme picker (optional)
To make your theme appear in the Settings dropdown, add an `<option>` to the
theme `<select>` in `static/index.html`:
```html
<option value="your-theme-name">Your Theme Name</option>
```
And update the `/theme` command's valid theme list in `static/commands.js`.
### Step 3: Test it
Switch to your theme via `/theme your-theme-name` or the Settings panel.
Check these areas:
- Sidebar session list (hover states, active state, project borders)
- Message bubbles (user vs assistant styling)
- Code blocks (background contrast, copy button visibility)
- Tool cards (running indicator, expand/collapse)
- Settings panel and login page
- Mobile layout (hamburger sidebar, bottom nav)
### Tips
- **Light themes** need scrollbar and selection overrides, plus the full
text/code set (`--strong`, `--em`, `--code-text`, `--code-inline-bg`,
`--pre-text`) or they will look broken.
- The **logo gradient** uses `--accent` automatically, so it adapts to your
theme without extra work.
- **Prism.js syntax highlighting** uses its own CDN stylesheet (Tomorrow theme).
It works well on dark themes; on light themes the contrast is acceptable but
not perfect. Custom Prism theme support is planned for a future update.
- **No server changes needed.** The `theme` setting in `settings.json` accepts
any string — your custom theme name will persist without code changes.
---
## How Themes Work Internally
1. Each theme is a `:root[data-theme="name"]` CSS block that overrides variables.
2. Switching themes sets `document.documentElement.dataset.theme = name` in JS.
3. A tiny inline `<script>` in `<head>` reads `localStorage` before the
stylesheet loads — this prevents a flash of the wrong theme on page load.
4. The theme preference is saved server-side via `POST /api/settings` and
loaded on boot via `GET /api/settings`.
5. The `/theme` command and Settings dropdown both update the DOM, localStorage,
and server settings simultaneously.
---
## Contributing a Theme
To contribute a new built-in theme:
1. Add your `:root[data-theme="name"]` block to `static/style.css`
2. Add the `<option>` to the Settings panel in `static/index.html`
3. Add the theme name to the valid list in `cmdTheme()` in `static/commands.js`
4. Test on desktop and mobile
5. Open a PR — themes are pure CSS additions with no backend changes needed

196
api/agent_sessions.py Normal file
View File

@@ -0,0 +1,196 @@
"""Shared helpers for reading Hermes Agent sessions from state.db."""
import logging
import sqlite3
from pathlib import Path
logger = logging.getLogger(__name__)
def _optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str:
return f"s.{name}" if name in columns else f"{fallback} AS {name}"
def _is_compression_continuation(parent: dict | None, child: dict) -> bool:
"""Mirror Hermes Agent's compression-child guard.
A child is a continuation only when the parent ended because of compression
and the child started after that compression boundary. Plain parent/child
relationships are left alone for future subagent-tree work.
"""
if not parent:
return False
if parent.get('end_reason') != 'compression':
return False
ended_at = parent.get('ended_at')
if ended_at is None:
return False
try:
return float(child.get('started_at') or 0) >= float(ended_at)
except (TypeError, ValueError):
return False
def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
"""Collapse compression chains into one logical sidebar row.
The visible conversation should still look like the original chain head
(title and timestamps), while importing should use the latest importable
segment so the user continues from the current compressed state.
"""
rows_by_id = {row['id']: row for row in rows}
children_by_parent: dict[str, list[dict]] = {}
continuation_child_ids = set()
for row in rows:
parent_id = row.get('parent_session_id')
if not parent_id:
continue
children_by_parent.setdefault(parent_id, []).append(row)
if _is_compression_continuation(rows_by_id.get(parent_id), row):
continuation_child_ids.add(row['id'])
for children in children_by_parent.values():
children.sort(key=lambda row: row.get('started_at') or 0, reverse=True)
def compression_tip(row: dict) -> tuple[dict | None, int]:
current = row
seen = {row['id']}
latest_importable = row if (row.get('actual_message_count') or 0) > 0 else None
segment_count = 1
for _ in range(len(rows_by_id) + 1):
candidates = [
child for child in children_by_parent.get(current['id'], [])
if child['id'] not in seen and _is_compression_continuation(current, child)
]
if not candidates:
return latest_importable, segment_count
current = candidates[0]
seen.add(current['id'])
segment_count += 1
if (current.get('actual_message_count') or 0) > 0:
latest_importable = current
return latest_importable, segment_count
projected = []
for row in rows:
if row['id'] in continuation_child_ids:
continue
segment_count = 1
tip = row
if row.get('end_reason') == 'compression':
tip, segment_count = compression_tip(row)
if not tip or (tip.get('actual_message_count') or 0) <= 0:
continue
if tip is row:
projected.append(dict(row))
continue
merged = dict(row)
# Keep the chain head's visible identity (title, started_at), but
# point the row at the latest importable segment for navigation AND
# surface the tip's recency so an actively-used chain bubbles to the
# top of the sidebar by its true last activity. Without overriding
# last_activity, a long-lived chain whose tip is being edited NOW
# would sort by the root's old timestamp and fall below recently
# touched standalone sessions — exactly the inverse of what a user
# expects from "Show agent sessions" sorted by activity.
for key in (
'id', 'model', 'message_count', 'actual_message_count',
'ended_at', 'end_reason', 'last_activity',
):
if key in tip:
merged[key] = tip[key]
if not merged.get('title'):
merged['title'] = tip.get('title')
if not merged.get('source'):
merged['source'] = tip.get('source')
merged['_lineage_root_id'] = row['id']
merged['_lineage_tip_id'] = tip['id']
merged['_compression_segment_count'] = segment_count
projected.append(merged)
projected.sort(
key=lambda row: row.get('last_activity') or row.get('started_at') or 0,
reverse=True,
)
return projected
def read_importable_agent_session_rows(
db_path: Path,
limit: int = 200,
log=None,
exclude_sources: tuple[str, ...] | None = ("cron",),
) -> list[dict]:
"""Return non-WebUI agent sessions projected as importable conversations.
Hermes Agent can create rows in ``state.db.sessions`` before a session has
any messages, and long conversations can be split into compression-linked
rows. WebUI cannot import empty rows and should not show compression
segments as separate conversations, so both the regular ``/api/sessions``
path and the gateway SSE watcher use this shared projection.
By default, omit background/internal sources such as ``cron`` from the WebUI
sidebar. This mirrors Hermes Agent CLI's session-list behaviour: interactive
views should stay focused on user-facing conversations, while callers that
need a source-specific diagnostic view can opt out by passing
``exclude_sources=None``.
"""
db_path = Path(db_path)
if not db_path.exists():
return []
log = log or logger
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Older Hermes Agent versions may not have source tracking. Without a
# source column we cannot safely distinguish WebUI rows from agent rows.
cur.execute("PRAGMA table_info(sessions)")
session_cols = {row[1] for row in cur.fetchall()}
if 'source' not in session_cols:
log.warning(
"agent session listing skipped: state.db at %s has no 'source' column "
"(older hermes-agent?). Agent sessions unavailable. "
"Upgrade hermes-agent to fix this.",
db_path,
)
return []
parent_expr = _optional_col('parent_session_id', session_cols)
ended_expr = _optional_col('ended_at', session_cols)
end_reason_expr = _optional_col('end_reason', session_cols)
where_clauses = ["s.source IS NOT NULL", "s.source != 'webui'"]
params: list[str] = []
if exclude_sources:
excluded = tuple(str(source) for source in exclude_sources if source)
if excluded:
placeholders = ", ".join("?" for _ in excluded)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(excluded)
cur.execute(
f"""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
{parent_expr},
{ended_expr},
{end_reason_expr},
COUNT(m.id) AS actual_message_count,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE {' AND '.join(where_clauses)}
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
""",
params,
)
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
if limit is None:
return projected
return projected[:max(0, int(limit))]

View File

@@ -6,12 +6,17 @@ or configuring a password in the Settings panel.
import hashlib
import hmac
import http.cookies
import json
import logging
import os
import secrets
import tempfile
import time
from api.config import STATE_DIR, load_settings
logger = logging.getLogger(__name__)
# ── Public paths (no auth required) ─────────────────────────────────────────
PUBLIC_PATHS = frozenset({
'/login', '/health', '/favicon.ico',
@@ -21,22 +26,109 @@ PUBLIC_PATHS = frozenset({
COOKIE_NAME = 'hermes_session'
SESSION_TTL = 86400 # 24 hours
# Active sessions: token -> expiry timestamp
_sessions = {}
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
def _load_sessions() -> dict[str, float]:
"""Load persisted sessions from STATE_DIR, pruning expired entries.
Returns an empty dict on any read or parse error so startup is never
blocked by a corrupt or missing sessions file.
"""
try:
if _SESSIONS_FILE.exists():
data = json.loads(_SESSIONS_FILE.read_text(encoding='utf-8'))
if not isinstance(data, dict):
raise ValueError('malformed sessions file — expected dict')
now = time.time()
return {t: exp for t, exp in data.items()
if isinstance(t, str) and isinstance(exp, (int, float)) and exp > now}
except Exception as e:
logger.debug("Failed to load sessions file, starting fresh: %s", e)
return {}
def _save_sessions(sessions: dict[str, float]) -> None:
"""Atomically persist sessions to STATE_DIR/.sessions.json (0600).
Uses a temp file + os.replace() so a crash mid-write never leaves a
truncated file. Mirrors the same pattern as .signing_key persistence.
"""
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=STATE_DIR, suffix='.sessions.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(sessions, f)
os.chmod(tmp, 0o600)
os.replace(tmp, _SESSIONS_FILE)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception as e:
logger.debug("Failed to persist sessions: %s", e)
# Active sessions: token -> expiry timestamp (persisted across restarts via STATE_DIR)
_sessions = _load_sessions()
# ── Login rate limiter ──────────────────────────────────────────────────────
_login_attempts = {} # ip -> [timestamp, ...]
_LOGIN_MAX_ATTEMPTS = 5
_LOGIN_WINDOW = 60 # seconds
def _check_login_rate(ip: str) -> bool:
"""Return True if the IP is allowed to attempt login."""
now = time.time()
attempts = _login_attempts.get(ip, [])
# Prune old attempts
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
_login_attempts[ip] = attempts
return len(attempts) < _LOGIN_MAX_ATTEMPTS
def _record_login_attempt(ip: str) -> None:
now = time.time()
attempts = _login_attempts.get(ip, [])
attempts.append(now)
_login_attempts[ip] = attempts
def _signing_key():
"""Derive a stable signing key from STATE_DIR."""
return hashlib.sha256(str(STATE_DIR).encode()).digest()
"""Return a random signing key, generating and persisting one on first call."""
key_file = STATE_DIR / '.signing_key'
try:
if key_file.exists():
raw = key_file.read_bytes()
if len(raw) >= 32:
return raw[:32]
except Exception:
logger.debug("Failed to read or access signing key file, using in-memory key")
# Generate a new random key
key = secrets.token_bytes(32)
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
key_file.write_bytes(key)
key_file.chmod(0o600)
except Exception:
logger.debug("Failed to persist signing key, using in-memory key only")
return key
def _hash_password(password):
"""SHA-256 hash with a salt derived from STATE_DIR."""
salt = str(STATE_DIR).encode()
return hashlib.sha256(salt + password.encode()).hexdigest()
"""PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
Salt is the persisted random signing key, which is secret and unique per
installation. This keeps the stored hash format a plain hex string
(no format change to settings.json) while replacing the predictable
STATE_DIR-derived salt from the original implementation."""
salt = _signing_key()
dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
return dk.hex()
def get_password_hash():
def get_password_hash() -> str | None:
"""Return the active password hash, or None if auth is disabled.
Priority: env var > settings.json."""
env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
@@ -46,12 +138,12 @@ def get_password_hash():
return settings.get('password_hash') or None
def is_auth_enabled():
def is_auth_enabled() -> bool:
"""True if a password is configured (env var or settings)."""
return get_password_hash() is not None
def verify_password(plain):
def verify_password(plain) -> bool:
"""Verify a plaintext password against the stored hash."""
expected = get_password_hash()
if not expected:
@@ -59,20 +151,32 @@ def verify_password(plain):
return hmac.compare_digest(_hash_password(plain), expected)
def create_session():
def create_session() -> str:
"""Create a new auth session. Returns signed cookie value."""
token = secrets.token_hex(32)
_sessions[token] = time.time() + SESSION_TTL
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
_save_sessions(_sessions)
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
return f"{token}.{sig}"
def verify_session(cookie_value):
def _prune_expired_sessions():
"""Remove all expired session entries to prevent unbounded memory growth."""
now = time.time()
expired = [t for t, exp in _sessions.items() if now > exp]
if expired:
for token in expired:
_sessions.pop(token, None)
_save_sessions(_sessions)
def verify_session(cookie_value) -> bool:
"""Verify a signed session cookie. Returns True if valid and not expired."""
if not cookie_value or '.' not in cookie_value:
return False
_prune_expired_sessions() # lazy cleanup on every verification attempt
token, sig = cookie_value.rsplit('.', 1)
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
if not hmac.compare_digest(sig, expected_sig):
return False
expiry = _sessions.get(token)
@@ -82,14 +186,16 @@ def verify_session(cookie_value):
return True
def invalidate_session(cookie_value):
def invalidate_session(cookie_value) -> None:
"""Remove a session token."""
if cookie_value and '.' in cookie_value:
token = cookie_value.rsplit('.', 1)[0]
_sessions.pop(token, None)
if token in _sessions:
_sessions.pop(token, None)
_save_sessions(_sessions)
def parse_cookie(handler):
def parse_cookie(handler) -> str | None:
"""Extract the auth cookie from the request headers."""
cookie_header = handler.headers.get('Cookie', '')
if not cookie_header:
@@ -103,7 +209,7 @@ def parse_cookie(handler):
return morsel.value if morsel else None
def check_auth(handler, parsed):
def check_auth(handler, parsed) -> bool:
"""Check if request is authorized. Returns True if OK.
If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""
if not is_auth_enabled():
@@ -128,7 +234,7 @@ def check_auth(handler, parsed):
return False
def set_auth_cookie(handler, cookie_value):
def set_auth_cookie(handler, cookie_value) -> None:
"""Set the auth cookie on the response."""
cookie = http.cookies.SimpleCookie()
cookie[COOKIE_NAME] = cookie_value
@@ -136,10 +242,13 @@ def set_auth_cookie(handler, cookie_value):
cookie[COOKIE_NAME]['samesite'] = 'Lax'
cookie[COOKIE_NAME]['path'] = '/'
cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL)
# Set Secure flag when connection is HTTPS
if getattr(handler.request, 'getpeercert', None) is not None or handler.headers.get('X-Forwarded-Proto', '') == 'https':
cookie[COOKIE_NAME]['secure'] = True
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
def clear_auth_cookie(handler):
def clear_auth_cookie(handler) -> None:
"""Clear the auth cookie on the response."""
cookie = http.cookies.SimpleCookie()
cookie[COOKIE_NAME] = ''

87
api/background.py Normal file
View File

@@ -0,0 +1,87 @@
"""Background and ephemeral task tracking for /background and /btw commands."""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
logger = logging.getLogger(__name__)
_lock = threading.Lock()
# parent_session_id -> list of task dicts
_BACKGROUND_TASKS: dict[str, list[dict[str, Any]]] = {}
# btw ephemeral session tracking: parent_sid -> {ephemeral_sid, stream_id, question}
_BTW_TRACKING: dict[str, dict[str, Any]] = {}
def track_background(parent_sid: str, bg_sid: str, stream_id: str,
task_id: str, prompt: str) -> None:
with _lock:
_BACKGROUND_TASKS.setdefault(parent_sid, []).append({
"task_id": task_id,
"bg_session_id": bg_sid,
"stream_id": stream_id,
"prompt": prompt,
"status": "running",
"started_at": time.time(),
"answer": None,
"completed_at": None,
})
def track_btw(parent_sid: str, ephemeral_sid: str, stream_id: str,
question: str) -> None:
with _lock:
_BTW_TRACKING[parent_sid] = {
"ephemeral_session_id": ephemeral_sid,
"stream_id": stream_id,
"question": question,
}
def complete_background(parent_sid: str, task_id: str, answer: str) -> None:
with _lock:
for t in _BACKGROUND_TASKS.get(parent_sid, []):
if t["task_id"] == task_id and t["status"] == "running":
t["status"] = "done"
t["answer"] = answer
t["completed_at"] = time.time()
break
def get_results(parent_sid: str) -> list[dict[str, Any]]:
"""Return completed background task results and remove only the done ones
from tracking. Tasks still in ``status="running"`` MUST stay in the list
so that ``complete_background()`` can still find them when the worker
thread finishes — otherwise the first poll during a long-running task
silently drops it and the result is lost forever.
"""
with _lock:
tasks = _BACKGROUND_TASKS.get(parent_sid, [])
done = [t for t in tasks if t["status"] == "done"]
still_running = [t for t in tasks if t["status"] != "done"]
if still_running:
_BACKGROUND_TASKS[parent_sid] = still_running
else:
_BACKGROUND_TASKS.pop(parent_sid, None)
return [{
"task_id": t["task_id"],
"prompt": t["prompt"],
"answer": t["answer"],
"completed_at": t["completed_at"],
} for t in done]
def get_background_tasks(parent_sid: str) -> list[dict[str, Any]]:
"""Return all background tasks (running and done) for a parent session."""
with _lock:
return list(_BACKGROUND_TASKS.get(parent_sid, []))
def cleanup_btw(parent_sid: str) -> dict[str, Any] | None:
"""Remove and return btw tracking for a parent session."""
with _lock:
return _BTW_TRACKING.pop(parent_sid, None)

142
api/clarify.py Normal file
View File

@@ -0,0 +1,142 @@
"""Clarify prompt state for the WebUI.
This mirrors the approval flow structure, but the response is a free-form
clarification string instead of an approval decision.
"""
from __future__ import annotations
import threading
import time
from typing import Optional
DEFAULT_TIMEOUT_SECONDS = 120
_lock = threading.Lock()
_pending: dict[str, dict] = {}
_gateway_queues: dict[str, list] = {}
_gateway_notify_cbs: dict[str, object] = {}
class _ClarifyEntry:
"""One pending clarify request inside a session."""
__slots__ = ("event", "data", "result")
def __init__(self, data: dict):
self.event = threading.Event()
self.data = data
self.result: Optional[str] = None
def register_gateway_notify(session_key: str, cb) -> None:
"""Register a per-session callback for sending clarify requests to the UI."""
with _lock:
_gateway_notify_cbs[session_key] = cb
def _clear_queue_locked(session_key: str) -> list[_ClarifyEntry]:
entries = _gateway_queues.pop(session_key, [])
_pending.pop(session_key, None)
return entries
def unregister_gateway_notify(session_key: str) -> None:
"""Unregister the per-session callback and unblock any waiting clarify prompt."""
with _lock:
_gateway_notify_cbs.pop(session_key, None)
entries = _clear_queue_locked(session_key)
for entry in entries:
entry.event.set()
def clear_pending(session_key: str) -> int:
"""Clear any pending clarify prompts for the session without removing the callback."""
with _lock:
entries = _clear_queue_locked(session_key)
for entry in entries:
entry.event.set()
return len(entries)
def _with_timeout_metadata(data: dict) -> dict:
item = dict(data or {})
requested_at = float(item.get("requested_at") or time.time())
timeout_seconds = int(item.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
expires_at = float(item.get("expires_at") or requested_at + timeout_seconds)
item["requested_at"] = requested_at
item["timeout_seconds"] = timeout_seconds
item["expires_at"] = expires_at
return item
def submit_pending(session_key: str, data: dict) -> _ClarifyEntry:
"""Queue a pending clarify request and notify the UI callback if registered."""
data = _with_timeout_metadata(data)
with _lock:
queue = _gateway_queues.setdefault(session_key, [])
# De-duplicate while unresolved: if the most recent pending clarify is
# semantically identical, reuse it instead of stacking duplicates.
if queue:
last = queue[-1]
if (
str(last.data.get("question", "")) == str(data.get("question", ""))
and list(last.data.get("choices_offered") or [])
== list(data.get("choices_offered") or [])
):
entry = last
cb = _gateway_notify_cbs.get(session_key)
# Keep _pending aligned to the oldest unresolved entry.
_pending[session_key] = queue[0].data
if cb:
try:
cb(dict(entry.data))
except Exception:
pass
return entry
entry = _ClarifyEntry(data)
queue.append(entry)
_pending[session_key] = queue[0].data
cb = _gateway_notify_cbs.get(session_key)
if cb:
try:
cb(data)
except Exception:
pass
return entry
def get_pending(session_key: str) -> dict | None:
"""Return the oldest pending clarify request for this session, if any."""
with _lock:
queue = _gateway_queues.get(session_key) or []
if queue:
return dict(queue[0].data)
pending = _pending.get(session_key)
return dict(pending) if pending else None
def has_pending(session_key: str) -> bool:
with _lock:
return bool(_gateway_queues.get(session_key))
def resolve_clarify(session_key: str, response: str, resolve_all: bool = False) -> int:
"""Resolve the oldest pending clarify request for a session."""
with _lock:
queue = _gateway_queues.get(session_key)
if not queue:
_pending.pop(session_key, None)
return 0
entries = list(queue) if resolve_all else [queue.pop(0)]
if queue:
_pending[session_key] = queue[0].data
else:
_clear_queue_locked(session_key)
count = 0
for entry in entries:
entry.result = response
entry.event.set()
count += 1
return count

56
api/commands.py Normal file
View File

@@ -0,0 +1,56 @@
"""Expose hermes-agent's COMMAND_REGISTRY to the webui frontend.
This module is the single integration point with hermes_cli.commands.
If hermes-agent is unavailable the endpoint degrades to an empty list
so the frontend can still load with WEBUI_ONLY commands.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
# Commands that are gateway_only in the agent registry -- webui never
# wants to expose them (sethome, restart, update etc.) even if a future
# agent version drops the gateway_only flag. /commands is the agent's
# own command-listing command; webui has its own /help that calls
# cmdHelp() locally, so /commands would be redundant and confusing.
_NEVER_EXPOSE: frozenset[str] = frozenset({
'sethome', 'restart', 'update', 'commands',
})
def list_commands(_registry=None) -> list[dict[str, Any]]:
"""Return COMMAND_REGISTRY entries as JSON-friendly dicts.
Returns empty list if hermes_cli is not installed (graceful
degradation -- the frontend has its own fallback minimum set).
Args:
_registry: Optional injected registry for testing. When None
(production), imports COMMAND_REGISTRY from hermes_cli.
"""
if _registry is None:
try:
from hermes_cli.commands import COMMAND_REGISTRY as _registry
except ImportError:
logger.warning("hermes_cli.commands not importable -- /api/commands returns []")
return []
out: list[dict[str, Any]] = []
for cmd in _registry:
if cmd.gateway_only:
continue
if cmd.name in _NEVER_EXPOSE:
continue
out.append({
'name': cmd.name,
'description': cmd.description,
'category': cmd.category,
'aliases': list(cmd.aliases),
'args_hint': cmd.args_hint,
'subcommands': list(cmd.subcommands),
'cli_only': bool(cmd.cli_only),
'gateway_only': bool(cmd.gateway_only),
})
return out

File diff suppressed because it is too large Load Diff

227
api/gateway_watcher.py Normal file
View File

@@ -0,0 +1,227 @@
"""
Hermes Web UI -- Gateway session watcher.
Background daemon thread that polls state.db every 5 seconds for changes
to gateway sessions (telegram, discord, slack, etc.). When changes are
detected, it pushes notifications to all subscribed SSE clients.
This enables real-time session list updates in the sidebar without
requiring any changes to hermes-agent.
"""
import hashlib
import json
import logging
import os
import queue
import threading
import time
from pathlib import Path
from api.config import HOME
from api.agent_sessions import read_importable_agent_session_rows
logger = logging.getLogger(__name__)
# ── State hash tracking ─────────────────────────────────────────────────────
def _snapshot_hash(sessions: list) -> str:
"""Create a lightweight hash of session IDs and timestamps for change detection."""
key = '|'.join(
f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
for s in sorted(sessions, key=lambda x: x['session_id'])
)
return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
# ── DB resolution (shared pattern with state_sync.py) ──────────────────────
def _get_state_db_path() -> Path:
"""Resolve state.db path for the active profile."""
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
return hermes_home / 'state.db'
def _get_agent_sessions_from_db() -> list:
"""Read all non-webui sessions from state.db.
Returns list of session dicts, or empty list on any error.
"""
db_path = _get_state_db_path()
if not db_path.exists():
return []
try:
sessions = []
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
sessions.append({
'session_id': row['id'],
'title': row['title'] or 'Agent Session',
'model': row['model'] or None,
'message_count': row['message_count'] or row['actual_message_count'] or 0,
'created_at': row['started_at'],
'updated_at': row['last_activity'] or row['started_at'],
'source': row['source'] or 'cli',
})
return sessions
except Exception:
return []
# ── GatewayWatcher ──────────────────────────────────────────────────────────
class GatewayWatcher:
"""Background thread that polls state.db for agent session changes.
Usage:
watcher = GatewayWatcher()
watcher.start()
q = watcher.subscribe()
# ... receive change events via q.get() ...
watcher.unsubscribe(q)
watcher.stop()
"""
POLL_INTERVAL = 5 # seconds between polls
SUBSCRIBER_TIMEOUT = 30 # seconds before sending keepalive comment
def __init__(self):
self._subscribers: list[queue.Queue] = []
self._sub_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._last_hash: str = ''
self._last_sessions: list = []
def start(self):
"""Start the watcher daemon thread."""
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name='gateway-watcher')
self._thread.start()
def is_alive(self) -> bool:
"""Return True when the poll thread is running.
Public accessor used by ``/api/sessions/gateway/stream`` probe mode and
the live SSE handler to detect a watcher instance whose poll thread
died silently (e.g. uncaught exception in ``_poll_loop``). Callers
use this to decide whether to return 503 and trigger the client-side
polling fallback, instead of handing out an SSE connection that would
never emit events.
"""
t = self._thread
return t is not None and t.is_alive()
def stop(self):
"""Stop the watcher thread."""
self._stop_event.set()
# Wake up any subscribers
with self._sub_lock:
for q in self._subscribers:
try:
q.put(None) # sentinel
except Exception:
logger.debug("Failed to send sentinel to subscriber")
if self._thread:
self._thread.join(timeout=3)
self._thread = None
def subscribe(self) -> queue.Queue:
"""Subscribe to change events. Returns a queue.Queue.
Events are dicts: {'type': 'sessions_changed', 'sessions': [...]}
A None sentinel means the watcher is stopping.
"""
q = queue.Queue(maxsize=10)
with self._sub_lock:
self._subscribers.append(q)
return q
def unsubscribe(self, q: queue.Queue):
"""Remove a subscriber queue."""
with self._sub_lock:
try:
self._subscribers.remove(q)
except ValueError:
pass
def _notify_subscribers(self, sessions: list):
"""Push change event to all subscribers."""
event = {
'type': 'sessions_changed',
'sessions': sessions,
}
with self._sub_lock:
dead = []
for q in self._subscribers:
try:
q.put_nowait(event)
except queue.Full:
dead.append(q) # remove slow consumers
except Exception:
dead.append(q)
for q in dead:
try:
self._subscribers.remove(q)
except ValueError:
pass
# Send a None sentinel so the SSE handler unblocks, closes,
# and lets the browser's EventSource auto-reconnect.
try:
q.put_nowait(None)
except Exception:
logger.debug("Failed to send sentinel to dead subscriber")
def _poll_loop(self):
"""Main polling loop. Runs in a daemon thread."""
while not self._stop_event.is_set():
try:
sessions = _get_agent_sessions_from_db()
current_hash = _snapshot_hash(sessions)
if current_hash != self._last_hash:
self._last_hash = current_hash
self._last_sessions = sessions
self._notify_subscribers(sessions)
except Exception:
logger.debug("Error in gateway watcher poll loop", exc_info=True)
# Sleep in small increments so we can stop promptly
for _ in range(self.POLL_INTERVAL * 10):
if self._stop_event.is_set():
return
time.sleep(0.1)
# ── Module-level singleton ─────────────────────────────────────────────────
_watcher: GatewayWatcher | None = None
_watcher_lock = threading.Lock()
def start_watcher():
"""Start the global gateway watcher (idempotent)."""
global _watcher
with _watcher_lock:
if _watcher is None:
_watcher = GatewayWatcher()
_watcher.start()
def stop_watcher():
"""Stop the global gateway watcher."""
global _watcher
with _watcher_lock:
if _watcher is not None:
_watcher.stop()
_watcher = None
def get_watcher() -> GatewayWatcher | None:
"""Get the global watcher instance (or None if not started)."""
with _watcher_lock:
return _watcher

View File

@@ -2,22 +2,32 @@
Hermes Web UI -- HTTP helper functions.
"""
import json as _json
import re as _re
from pathlib import Path
from api.config import IMAGE_EXTS, MD_EXTS
def require(body: dict, *fields):
def require(body: dict, *fields) -> None:
"""Phase D: Validate required fields. Raises ValueError with clean message."""
missing = [f for f in fields if not body.get(f) and body.get(f) != 0]
if missing:
raise ValueError(f"Missing required field(s): {', '.join(missing)}")
def bad(handler, msg, status=400):
def bad(handler, msg, status: int=400):
"""Return a clean JSON error response."""
return j(handler, {'error': msg}, status=status)
def _sanitize_error(e: Exception) -> str:
"""Strip filesystem paths from exception messages before returning to client."""
import re
msg = str(e)
# Remove absolute paths (Unix and Windows)
msg = re.sub(r'(?:(?:/[a-zA-Z0-9_.-]+)+|(?:[A-Z]:\\[^\s]+))', '<path>', msg)
return msg
def safe_resolve(root: Path, requested: str) -> Path:
"""Resolve a relative path inside root, raising ValueError on traversal."""
resolved = (root / requested).resolve()
@@ -30,21 +40,59 @@ def _security_headers(handler):
handler.send_header('X-Content-Type-Options', 'nosniff')
handler.send_header('X-Frame-Options', 'DENY')
handler.send_header('Referrer-Policy', 'same-origin')
handler.send_header(
'Content-Security-Policy',
"default-src 'self' https://*.cloudflareaccess.com; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self'; "
"manifest-src 'self' https://*.cloudflareaccess.com; "
"base-uri 'self'; form-action 'self'"
)
handler.send_header(
'Permissions-Policy',
'camera=(), microphone=(self), geolocation=(), clipboard-write=(self)'
)
def j(handler, payload, status=200):
"""Send a JSON response."""
def _accepts_gzip(handler) -> bool:
"""Check if the client accepts gzip encoding."""
headers = getattr(handler, 'headers', None)
if not headers:
return False
ae = headers.get('Accept-Encoding', '')
return 'gzip' in ae
def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
"""Send a JSON response.
*extra_headers*: optional dict of additional headers to include
(e.g., {'Set-Cookie': '...'}). Headers are sent before end_headers().
"""
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
handler.send_response(status)
handler.send_header('Content-Type', 'application/json; charset=utf-8')
# Gzip-compress responses over 1KB when the client accepts it.
# Typical JSON API responses compress 70-80%, giving a big speedup
# for large payloads (session history, message lists).
if _accepts_gzip(handler) and len(body) > 1024:
import gzip
body = gzip.compress(body, compresslevel=4)
handler.send_header('Content-Encoding', 'gzip')
handler.send_header('Content-Length', str(len(body)))
handler.send_header('Cache-Control', 'no-store')
_security_headers(handler)
if extra_headers:
for k, v in extra_headers.items():
handler.send_header(k, v)
handler.end_headers()
handler.wfile.write(body)
def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
def t(handler, payload, status: int=200, content_type: str='text/plain; charset=utf-8') -> None:
"""Send a plain text or HTML response."""
body = payload if isinstance(payload, bytes) else str(payload).encode('utf-8')
handler.send_response(status)
@@ -59,7 +107,89 @@ def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
def read_body(handler):
# ── Credential redaction ──────────────────────────────────────────────────────
def _build_redact_fn():
"""Return redact_sensitive_text from hermes-agent if available, else a fallback."""
try:
from agent.redact import redact_sensitive_text
return redact_sensitive_text
except ImportError:
pass
# Minimal fallback covering the most common credential prefixes
_CRED_RE = _re.compile(
r"(?<![A-Za-z0-9_-])("
r"sk-[A-Za-z0-9_-]{10,}" # OpenAI / Anthropic / OpenRouter
r"|ghp_[A-Za-z0-9]{10,}" # GitHub PAT (classic)
r"|github_pat_[A-Za-z0-9_]{10,}" # GitHub PAT (fine-grained)
r"|gho_[A-Za-z0-9]{10,}" # GitHub OAuth token
r"|ghu_[A-Za-z0-9]{10,}" # GitHub user-to-server token
r"|ghs_[A-Za-z0-9]{10,}" # GitHub server-to-server token
r"|ghr_[A-Za-z0-9]{10,}" # GitHub refresh token
r"|AKIA[A-Z0-9]{16}" # AWS Access Key ID
r"|xox[baprs]-[A-Za-z0-9-]{10,}" # Slack tokens
r"|hf_[A-Za-z0-9]{10,}" # HuggingFace token
r"|SG\.[A-Za-z0-9_-]{10,}" # SendGrid API key
r")(?![A-Za-z0-9_-])"
)
_AUTH_HDR_RE = _re.compile(r"(Authorization:\s*Bearer\s+)(\S+)", _re.IGNORECASE)
_ENV_RE = _re.compile(
r"([A-Z0-9_]{0,50}(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)[A-Z0-9_]{0,50})"
r"\s*=\s*(['\"]?)(\S+)\2"
)
_PRIVKEY_RE = _re.compile(
r"-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----"
)
def _mask(token: str) -> str:
return f"{token[:6]}...{token[-4:]}" if len(token) >= 18 else "***"
def _fallback_redact(text: str) -> str:
if not isinstance(text, str) or not text:
return text
text = _CRED_RE.sub(lambda m: _mask(m.group(1)), text)
text = _AUTH_HDR_RE.sub(lambda m: m.group(1) + _mask(m.group(2)), text)
text = _ENV_RE.sub(
lambda m: f"{m.group(1)}={m.group(2)}{_mask(m.group(3))}{m.group(2)}", text
)
text = _PRIVKEY_RE.sub("[REDACTED PRIVATE KEY]", text)
return text
return _fallback_redact
_redact_text = _build_redact_fn()
def _redact_value(v):
"""Recursively redact credentials from strings, dicts, and lists."""
if isinstance(v, str):
return _redact_text(v)
if isinstance(v, dict):
return {k: _redact_value(val) for k, val in v.items()}
if isinstance(v, list):
return [_redact_value(item) for item in v]
return v
def redact_session_data(session_dict: dict) -> dict:
"""Redact credentials from message content and tool_call data before API response.
Applies to: messages[], tool_calls[], and title.
The underlying session file is not modified; redaction is response-layer only.
"""
result = dict(session_dict)
if isinstance(result.get('title'), str):
result['title'] = _redact_text(result['title'])
if 'messages' in result:
result['messages'] = _redact_value(result['messages'])
if 'tool_calls' in result:
result['tool_calls'] = _redact_value(result['tool_calls'])
return result
def read_body(handler) -> dict:
"""Read and JSON-parse a POST request body (capped at 20MB)."""
length = int(handler.headers.get('Content-Length', 0))
if length > MAX_BODY_BYTES:
@@ -69,3 +199,50 @@ def read_body(handler):
return _json.loads(raw)
except Exception:
return {}
# ── Profile cookie helpers (issue #798) ─────────────────────────────────────
PROFILE_COOKIE_NAME = 'hermes_profile'
def get_profile_cookie(handler) -> str | None:
"""Extract the hermes_profile cookie value from the request, or None."""
cookie_header = handler.headers.get('Cookie', '')
if not cookie_header:
return None
import http.cookies as _hc
cookie = _hc.SimpleCookie()
try:
cookie.load(cookie_header)
except _hc.CookieError:
return None
morsel = cookie.get(PROFILE_COOKIE_NAME)
if morsel and morsel.value:
# Validate against profile-name pattern before trusting
from api.profiles import _PROFILE_ID_RE
val = morsel.value
if val == 'default' or _PROFILE_ID_RE.fullmatch(val):
return val
return None
def build_profile_cookie(name: str) -> str:
"""Build a Set-Cookie header value for the hermes_profile cookie.
Always persist the selected profile in the cookie, including 'default'.
Clearing the cookie causes the backend to fall back to process-global
_active_profile, which can unexpectedly switch clients back to another
profile.
Set HttpOnly because the UI reads the active profile from
/api/profile/active JSON and does not need to access this cookie via
document.cookie.
"""
import http.cookies as _hc
cookie = _hc.SimpleCookie()
cookie[PROFILE_COOKIE_NAME] = name
cookie[PROFILE_COOKIE_NAME]['path'] = '/'
cookie[PROFILE_COOKIE_NAME]['httponly'] = True
cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax'
return cookie[PROFILE_COOKIE_NAME].OutputString()

187
api/metering.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Hermes Web UI -- Streaming performance metering.
Tracks Tokens Per Second (TPS) across all active WebUI sessions, and the
HIGH/LOW TPS values observed over the past 60 minutes. Metering data is
emitted via SSE events so the header label can update live during a stream.
Architecture
────────────
Each streaming session is tracked independently. TPS per session is:
session_tps = total_tokens / (last_token_ts - first_token_ts)
The global tps is the average of all currently active sessions' TPS values.
This correctly represents the system's real-time capacity regardless of how
many sessions are running or how long each has been streaming.
For HIGH/LOW tracking, every stats snapshot records the current global tps
(only when > 0 — idle periods are skipped) into a rolling 60-minute history.
The max/min of that history gives the peak throughput observed over the past hour.
The ticker in streaming.py calls get_interval() — it returns 1.0 when sessions
are actively receiving tokens so the header updates at 1 Hz, and 10.0 when idle
so the ticker exits and no idle readings are emitted.
Usage from api/streaming.py
─────────────────────────────
from api.metering import meter
meter().begin_session(stream_id) # stream starts
meter().record_token(stream_id, running_output) # per output token
meter().record_reasoning(stream_id, running_reasoning_len) # per reasoning token
The SSE `metering` event payload:
{
"tps": 47.3, # average TPS across active sessions (real-time)
"high": 52.1, # highest average TPS observed in the past 60 minutes
"low": 31.4, # lowest average TPS (excl. readings < 1 tps, to ignore idle)
"active": 1, # sessions currently streaming
}
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
_HOUR_SECS = 3600.0 # rolling window for HIGH/LOW tracking
_STALE_SECS = 60.0 # consider a session inactive after this
@dataclass
class _SessionMeter:
output_tokens: int = 0
reasoning_tokens: int = 0
first_token_ts: float = 0.0 # time.monotonic() of first token received
last_token_ts: float = 0.0 # time.monotonic() of last token received
def total_tokens(self) -> int:
return self.output_tokens + self.reasoning_tokens
def tps(self) -> float:
if self.first_token_ts == 0.0 or self.last_token_ts <= self.first_token_ts:
return 0.0
return self.total_tokens() / (self.last_token_ts - self.first_token_ts)
class GlobalMeter:
"""Thread-safe global streaming meter.
Tracks per-session TPS, averages them for a global tps, and maintains a
60-minute rolling history of global tps snapshots for HIGH/LOW reporting.
"""
__slots__ = (
'_lock',
'_sessions', # stream_id -> _SessionMeter
'_readings', # [(monotonic_ts, tps), ...] rolling 60-minute history
'_window_start', # monotonic ts of current window
)
def __init__(self) -> None:
self._lock = threading.Lock()
self._sessions: dict[str, _SessionMeter] = {}
self._readings: list[tuple[float, float]] = []
self._window_start: float = time.monotonic()
# ── Public API ────────────────────────────────────────────────────────────
def begin_session(self, stream_id: str) -> None:
with self._lock:
self._sessions[stream_id] = _SessionMeter()
def get_interval(self) -> float:
"""Return 1.0 when sessions are actively receiving tokens, 10.0 when idle.
Used by the streaming ticker to run at 1 Hz during work and exit when
there is nothing to measure.
"""
now = time.monotonic()
with self._lock:
# Only count sessions that have received at least one token recently.
active_sids = {
sid for sid, s in self._sessions.items()
if s.first_token_ts > 0 and (now - s.last_token_ts) <= _STALE_SECS
}
return 1.0 if active_sids else 10.0
def record_token(self, stream_id: str, running_output_tokens: int) -> None:
now = time.monotonic()
with self._lock:
s = self._sessions.get(stream_id)
if s is None:
return
if s.first_token_ts == 0.0:
s.first_token_ts = now
s.last_token_ts = now
s.output_tokens = running_output_tokens
def record_reasoning(self, stream_id: str, running_reasoning_tokens: int) -> None:
now = time.monotonic()
with self._lock:
s = self._sessions.get(stream_id)
if s is None:
return
if s.first_token_ts == 0.0:
s.first_token_ts = now
s.last_token_ts = now
s.reasoning_tokens = running_reasoning_tokens
def end_session(self, stream_id: str, final_output_tokens: int, input_tokens: int = 0) -> None:
with self._lock:
self._sessions.pop(stream_id, None)
def get_stats(self) -> dict:
now = time.monotonic()
with self._lock:
# Prune stale sessions
stale = [
sid for sid, s in self._sessions.items()
if s.first_token_ts > 0 and (now - s.last_token_ts) > _STALE_SECS
]
for sid in stale:
self._sessions.pop(sid, None)
# Reset window if everything went stale
if not self._sessions:
self._window_start = now
# Compute global tps: average of per-session TPS values
active = [s for s in self._sessions.values() if s.first_token_ts > 0]
if active:
global_tps = sum(s.tps() for s in active) / len(active)
else:
global_tps = 0.0
# Prune readings older than 1 hour
cutoff = now - _HOUR_SECS
self._readings = [(ts, v) for ts, v in self._readings if ts > cutoff]
# Only record this snapshot for HIGH/LOW if there is active work.
# This prevents idle periods from flooding the history and keeps
# HIGH/LOW meaningful for the past hour of actual throughput.
if global_tps > 0:
self._readings.append((now, global_tps))
# HIGH/LOW from the past hour (skip near-zero idle readings)
active_readings = [v for _, v in self._readings if v >= 1.0]
high = max(active_readings) if active_readings else 0.0
low = min(active_readings) if active_readings else 0.0
return {
'tps': round(global_tps, 1),
'high': round(high, 1),
'low': round(low, 1),
'active': len(self._sessions),
}
# ── Module-level singleton ─────────────────────────────────────────────────────
_meter = GlobalMeter()
def meter() -> GlobalMeter:
return _meter

File diff suppressed because it is too large Load Diff

695
api/onboarding.py Normal file
View File

@@ -0,0 +1,695 @@
"""Hermes Web UI -- first-run onboarding helpers."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from urllib.parse import urlparse
from api.auth import is_auth_enabled
from api.config import (
DEFAULT_MODEL,
DEFAULT_WORKSPACE,
_FALLBACK_MODELS,
_HERMES_FOUND,
_PROVIDER_DISPLAY,
_PROVIDER_MODELS,
_get_config_path,
get_available_models,
get_config,
load_settings,
reload_config,
save_settings,
verify_hermes_imports,
)
from api.providers import _write_env_file # shared impl with _ENV_LOCK (#1164)
from api.workspace import get_last_workspace, load_workspaces
logger = logging.getLogger(__name__)
_SUPPORTED_PROVIDER_SETUPS = {
# ── Easy start ──────────────────────────────────────────────────────
"openrouter": {
"label": "OpenRouter",
"env_var": "OPENROUTER_API_KEY",
"default_model": "anthropic/claude-sonnet-4.6",
"requires_base_url": False,
"models": [
{"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS
],
"category": "easy_start",
"quick": True,
},
"anthropic": {
"label": "Anthropic",
"env_var": "ANTHROPIC_API_KEY",
"default_model": "claude-sonnet-4.6",
"requires_base_url": False,
"models": list(_PROVIDER_MODELS.get("anthropic", [])),
"category": "easy_start",
},
"openai": {
"label": "OpenAI",
"env_var": "OPENAI_API_KEY",
"default_model": "gpt-4o",
"default_base_url": "https://api.openai.com/v1",
"requires_base_url": False,
"models": list(_PROVIDER_MODELS.get("openai", [])),
"category": "easy_start",
},
# ── Open / self-hosted ─────────────────────────────────────────────
"ollama": {
"label": "Ollama",
"env_var": "OLLAMA_API_KEY",
"default_model": "qwen3:32b",
"default_base_url": "http://localhost:11434/v1",
"requires_base_url": True,
"models": [],
"category": "self_hosted",
},
"lmstudio": {
"label": "LM Studio",
"env_var": "LMSTUDIO_API_KEY",
"default_model": "gpt-4o-mini",
"default_base_url": "http://localhost:1234/v1",
"requires_base_url": True,
"models": [],
"category": "self_hosted",
},
"custom": {
"label": "Custom OpenAI-compatible",
"env_var": "OPENAI_API_KEY",
"default_model": "gpt-4o-mini",
"requires_base_url": True,
"models": [],
"category": "self_hosted",
},
# ── Specialized / extended ──────────────────────────────────────────
"gemini": {
"label": "Google Gemini",
"env_var": "GOOGLE_API_KEY",
"default_model": "gemini-3.1-pro-preview",
"default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"requires_base_url": False,
# _PROVIDER_MODELS in api/config.py is keyed under "google" even though
# the agent's alias map normalizes "google" → "gemini". Use the catalog
# key here so the wizard surfaces the actual model list.
"models": list(_PROVIDER_MODELS.get("google", [])),
"category": "specialized",
},
"deepseek": {
"label": "DeepSeek",
"env_var": "DEEPSEEK_API_KEY",
"default_model": "deepseek-v4-flash",
"default_base_url": "https://api.deepseek.com",
"requires_base_url": False,
"models": list(_PROVIDER_MODELS.get("deepseek", [])),
"category": "specialized",
},
"zai": {
"label": "Z.AI / GLM (智谱)",
"env_var": "GLM_API_KEY",
"default_model": "glm-5.1",
"default_base_url": "https://open.bigmodel.cn/api/paas/v4",
"requires_base_url": False,
"models": list(_PROVIDER_MODELS.get("zai", [])),
"category": "specialized",
},
"nvidia": {
"label": "NVIDIA NIM",
"env_var": "NVIDIA_API_KEY",
"default_model": "nvidia/llama-3.3-nemotron-super-49b-v1.5",
"default_base_url": "https://integrate.api.nvidia.com/v1",
"requires_base_url": False,
"models": list(_PROVIDER_MODELS.get("nvidia", [])),
"category": "specialized",
},
"mistralai": {
"label": "Mistral",
"env_var": "MISTRAL_API_KEY",
"default_model": "mistral-large-latest",
"default_base_url": "https://api.mistral.ai/v1",
"requires_base_url": False,
# No catalog entry for mistralai today — wizard shows a free-text input.
"models": list(_PROVIDER_MODELS.get("mistralai", [])),
"category": "specialized",
},
"x-ai": {
"label": "xAI (Grok)",
"env_var": "XAI_API_KEY",
"default_model": "grok-4.20",
"default_base_url": "https://api.x.ai/v1",
"requires_base_url": False,
# Agent normalizes "x-ai" → "xai"; _PROVIDER_MODELS is also keyed "xai"
# when populated, so check both keys for forward-compatibility.
"models": list(_PROVIDER_MODELS.get("xai", []) or _PROVIDER_MODELS.get("x-ai", [])),
"category": "specialized",
},
}
_PROVIDER_CATEGORIES = [
{"id": "easy_start", "label": "Easy start", "order": 0},
{"id": "self_hosted", "label": "Open / self-hosted", "order": 1},
{"id": "specialized", "label": "Specialized", "order": 2},
]
_UNSUPPORTED_PROVIDER_NOTE = (
"OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub "
"Copilot are still terminal-first. Use `hermes model` for those flows."
)
def _get_active_hermes_home() -> Path:
try:
from api.profiles import get_active_hermes_home
return get_active_hermes_home()
except ImportError:
return Path.home() / ".hermes"
def _load_env_file(env_path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not env_path.exists():
return values
try:
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
except Exception:
return {}
return values
def _load_yaml_config(config_path: Path) -> dict:
try:
import yaml as _yaml
except ImportError:
return {}
if not config_path.exists():
return {}
try:
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
return loaded if isinstance(loaded, dict) else {}
except Exception:
return {}
def _save_yaml_config(config_path: Path, config: dict) -> None:
try:
import yaml as _yaml
except ImportError as exc:
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
_yaml.safe_dump(config, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
def _normalize_model_for_provider(provider: str, model: str) -> str:
clean = (model or "").strip()
if not clean:
return ""
if provider in {"anthropic", "openai"} and clean.startswith(provider + "/"):
return clean.split("/", 1)[1]
return clean
def _normalize_base_url(base_url: str) -> str:
return (base_url or "").strip().rstrip("/")
def _extract_current_provider(cfg: dict) -> str:
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
provider = str(model_cfg.get("provider") or "").strip().lower()
if provider:
return provider
return ""
def _extract_current_model(cfg: dict) -> str:
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, str):
return model_cfg.strip()
if isinstance(model_cfg, dict):
return str(model_cfg.get("default") or "").strip()
return ""
def _extract_current_base_url(cfg: dict) -> str:
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
return _normalize_base_url(str(model_cfg.get("base_url") or ""))
return ""
def _provider_api_key_present(
provider: str, cfg: dict, env_values: dict[str, str]
) -> bool:
provider = (provider or "").strip().lower()
if not provider:
return False
env_var = _SUPPORTED_PROVIDER_SETUPS.get(provider, {}).get("env_var")
if env_var and env_values.get(env_var):
return True
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
return True
providers_cfg = cfg.get("providers", {})
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(provider, {})
if (
isinstance(provider_cfg, dict)
and str(provider_cfg.get("api_key") or "").strip()
):
return True
if provider == "custom":
custom_cfg = providers_cfg.get("custom", {})
if (
isinstance(custom_cfg, dict)
and str(custom_cfg.get("api_key") or "").strip()
):
return True
# For providers not in _SUPPORTED_PROVIDER_SETUPS (e.g. minimax-cn, deepseek,
# xai, etc.), ask the hermes_cli auth registry — it knows every provider's env
# var names and can check os.environ for a valid key.
# Exclude known OAuth/token-flow providers — those are handled separately by
# _provider_oauth_authenticated() and should not be short-circuited here.
_known_oauth = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
if provider not in _SUPPORTED_PROVIDER_SETUPS and provider not in _known_oauth:
try:
from hermes_cli.auth import get_auth_status as _gas
status = _gas(provider)
if isinstance(status, dict) and status.get("logged_in"):
return True
except Exception:
pass
return False
def _oauth_payload_has_token(payload: dict) -> bool:
"""Return True if an auth payload contains usable token material."""
if not isinstance(payload, dict):
return False
token_fields = (
payload,
payload.get("tokens") if isinstance(payload.get("tokens"), dict) else {},
)
for candidate in token_fields:
if not isinstance(candidate, dict):
continue
if any(
str(candidate.get(key) or "").strip()
for key in ("access_token", "refresh_token", "api_key")
):
return True
return False
def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
"""Return True if the provider has valid OAuth credentials.
Reads the profile-scoped auth.json directly so onboarding respects the
requested Hermes home. Known OAuth providers may store auth either in the
legacy providers[provider_id] singleton state or in credential_pool entries
used by current Hermes runtime auth resolution.
"""
provider = (provider or "").strip().lower()
if not provider:
return False
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
if provider not in _known_oauth_providers:
return False
try:
import json as _j
auth_path = hermes_home / "auth.json"
if not auth_path.exists():
return False
store = _j.loads(auth_path.read_text(encoding="utf-8"))
providers_store = store.get("providers")
if isinstance(providers_store, dict):
state = providers_store.get(provider)
if _oauth_payload_has_token(state):
return True
pool_store = store.get("credential_pool")
if isinstance(pool_store, dict):
entries = pool_store.get(provider)
if isinstance(entries, list):
return any(_oauth_payload_has_token(entry) for entry in entries)
return False
except Exception:
return False
def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
provider = _extract_current_provider(cfg)
model = _extract_current_model(cfg)
base_url = _extract_current_base_url(cfg)
env_values = _load_env_file(_get_active_hermes_home() / ".env")
provider_configured = bool(provider and model)
provider_ready = False
if provider_configured:
if provider == "custom":
provider_ready = bool(
base_url and _provider_api_key_present(provider, cfg, env_values)
)
elif provider in _SUPPORTED_PROVIDER_SETUPS:
provider_ready = _provider_api_key_present(provider, cfg, env_values)
else:
# Unknown provider — may be an OAuth flow (openai-codex, copilot, etc.)
# OR an API-key provider not in the quick-setup list (minimax-cn, deepseek,
# xai, etc.). Check both: api key presence first (covers the majority of
# third-party providers), then OAuth auth.json.
provider_ready = (
_provider_api_key_present(provider, cfg, env_values)
or _provider_oauth_authenticated(provider, _get_active_hermes_home())
)
chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready)
if not _HERMES_FOUND or not imports_ok:
state = "agent_unavailable"
note = (
"Hermes is not fully importable from the Web UI yet. Finish bootstrap or fix the "
"agent install before provider setup will work."
)
elif chat_ready:
state = "ready"
provider_name = _PROVIDER_DISPLAY.get(
provider, provider.title() if provider else "Hermes"
)
note = f"Hermes is minimally configured and ready to chat via {provider_name}."
elif provider_configured:
state = "provider_incomplete"
if provider == "custom" and not base_url:
note = (
"Hermes has a saved provider/model selection but still needs the "
"base URL and API key required to chat."
)
elif provider not in _SUPPORTED_PROVIDER_SETUPS:
# OAuth / unsupported provider: avoid misleading "API key" wording.
note = (
f"Provider '{provider}' is configured but not yet authenticated. "
"Run 'hermes auth' or 'hermes model' in a terminal to complete "
"setup, then reload the Web UI."
)
else:
note = (
"Hermes has a saved provider/model selection but still needs the "
"API key required to chat."
)
else:
state = "needs_provider"
note = "Hermes is installed, but you still need to choose a provider and save working credentials."
return {
"provider_configured": provider_configured,
"provider_ready": provider_ready,
"chat_ready": chat_ready,
"setup_state": state,
"provider_note": note,
"current_provider": provider or None,
"current_model": model or None,
"current_base_url": base_url or None,
"env_path": str(_get_active_hermes_home() / ".env"),
}
def _build_setup_catalog(cfg: dict) -> dict:
current_provider = _extract_current_provider(cfg) or "openrouter"
current_model = _extract_current_model(cfg)
current_base_url = _extract_current_base_url(cfg)
providers = []
for provider_id, meta in _SUPPORTED_PROVIDER_SETUPS.items():
providers.append(
{
"id": provider_id,
"label": meta["label"],
"env_var": meta["env_var"],
"default_model": meta["default_model"],
"default_base_url": meta.get("default_base_url") or "",
"requires_base_url": bool(meta.get("requires_base_url")),
"models": list(meta.get("models", [])),
"category": meta.get("category", "easy_start"),
"quick": meta.get("quick", False),
}
)
# Sort providers by category order, then alphabetically within each category.
cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES}
providers.sort(key=lambda p: (cat_order.get(p["category"], 99), p["label"]))
# Group providers by category for the frontend.
categories = []
for cat in sorted(_PROVIDER_CATEGORIES, key=lambda c: c["order"]):
categories.append({
"id": cat["id"],
"label": cat["label"],
"providers": [p["id"] for p in providers if p["category"] == cat["id"]],
})
# Flag whether the currently-configured provider is OAuth-based (not in the
# API-key flow). The frontend uses this to show a confirmation card instead
# of a key input when the user has already authenticated via 'hermes auth'.
current_is_oauth = current_provider not in _SUPPORTED_PROVIDER_SETUPS and bool(
current_provider
)
return {
"providers": providers,
"categories": categories,
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
"current_is_oauth": current_is_oauth,
"current": {
"provider": current_provider,
"model": current_model
or _SUPPORTED_PROVIDER_SETUPS.get(current_provider, {}).get(
"default_model", ""
),
"base_url": current_base_url,
},
}
def get_onboarding_status() -> dict:
settings = load_settings()
cfg = get_config()
imports_ok, missing, errors = verify_hermes_imports()
runtime = _status_from_runtime(cfg, imports_ok)
workspaces = load_workspaces()
last_workspace = get_last_workspace()
available_models = get_available_models()
# HERMES_WEBUI_SKIP_ONBOARDING=1 lets hosting providers (e.g. Agent37) ship
# a pre-configured instance without the wizard blocking the first load.
# This is an operator-level override and is honoured unconditionally —
# the operator knows their deployment is configured; we must not second-guess
# it by requiring chat_ready to also be true.
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
skip_requested = skip_env in {"1", "true", "yes"}
auto_completed = skip_requested # unconditional: operator says skip, we skip
# Auto-complete for existing Hermes users: if config.yaml already exists
# AND the provider is configured (or the system is chat_ready), treat onboarding
# as done. These users configured Hermes via the CLI before the Web UI existed;
# they must never be shown the first-run wizard — it would silently overwrite their
# config. We use provider_configured (not chat_ready) so that users with
# non-wizard providers (ollama-cloud, deepseek, xai, kimi, etc.) are not forced
# through the wizard just because their provider doesn't have a detectable API key
# — the wizard cannot represent their provider and would overwrite their config
# with whichever wizard-supported provider they accidentally select.
config_exists = Path(_get_config_path()).exists()
# For providers not in the wizard's quick-setup list (e.g. ollama-cloud, deepseek,
# xai, kimi-k2.6), the wizard can never help — it only knows how to configure
# openrouter/anthropic/openai/google/custom. If such a user has a configured
# provider + model in config.yaml, showing the wizard would only confuse them
# (or worse, let them accidentally overwrite their config with gpt-5.4-mini).
_current_provider = str(
(cfg.get("model", {}) or {}).get("provider", "") if isinstance(cfg.get("model"), dict)
else ""
).strip().lower()
_is_non_wizard_provider = bool(
_current_provider and _current_provider not in _SUPPORTED_PROVIDER_SETUPS
)
config_auto_completed = config_exists and (
bool(runtime.get("chat_ready"))
or (_is_non_wizard_provider and bool(runtime.get("provider_configured")))
)
# Persist the flag so it survives future transient import failures (e.g. after
# a git branch switch in the hermes-agent repo). Without this, a CLI-configured
# user who never ran the wizard has no onboarding_completed flag — any momentary
# imports_ok=False during restart makes chat_ready=False, config_auto_completed=False,
# and the wizard reappears with a broken dropdown that clobbers their config.
#
# Best-effort: if save_settings raises (read-only FS, disk full, permission error),
# log and continue. The `config_auto_completed` branch of `completed=` below still
# returns True for this request, so the user sees the correct state — only the
# persistence-across-restart guarantee is degraded. Raising here would turn every
# /api/onboarding/status call into a 500 until disk was writable, which is worse UX
# than losing the next-restart protection.
if config_auto_completed and not settings.get("onboarding_completed"):
try:
save_settings({"onboarding_completed": True})
settings["onboarding_completed"] = True
except Exception:
logger.debug("Failed to persist onboarding_completed", exc_info=True)
return {
"completed": bool(settings.get("onboarding_completed")) or auto_completed or config_auto_completed,
"settings": {
"default_model": settings.get("default_model") or DEFAULT_MODEL,
"default_workspace": settings.get("default_workspace")
or str(DEFAULT_WORKSPACE),
"password_enabled": is_auth_enabled(),
"bot_name": settings.get("bot_name") or "Hermes",
},
"system": {
"hermes_found": bool(_HERMES_FOUND),
"imports_ok": bool(imports_ok),
"missing_modules": missing,
"import_errors": errors,
"config_path": str(_get_config_path()),
"config_exists": Path(_get_config_path()).exists(),
**runtime,
},
"setup": _build_setup_catalog(cfg),
"workspaces": {
"items": workspaces,
"last": last_workspace,
},
"models": available_models,
}
def apply_onboarding_setup(body: dict) -> dict:
# Hard guard: if the operator set SKIP_ONBOARDING, the wizard should never
# have appeared. Even if the frontend somehow calls this endpoint anyway
# (e.g. a stale JS bundle or a curious user), we must not overwrite the
# operator's config.yaml or .env files. Just mark onboarding complete and
# return the current status — no file writes.
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
if skip_env in {"1", "true", "yes"}:
save_settings({"onboarding_completed": True})
return get_onboarding_status()
provider = str(body.get("provider") or "").strip().lower()
model = str(body.get("model") or "").strip()
api_key = str(body.get("api_key") or "").strip()
base_url = _normalize_base_url(str(body.get("base_url") or ""))
if provider not in _SUPPORTED_PROVIDER_SETUPS:
# Unsupported providers (openai-codex, copilot, nous, etc.) are already
# configured via the CLI. Just mark onboarding as complete and let the
# user through — the agent is already set up, no further setup needed.
save_settings({"onboarding_completed": True})
return get_onboarding_status()
if not model:
raise ValueError("model is required")
provider_meta = _SUPPORTED_PROVIDER_SETUPS[provider]
if provider_meta.get("requires_base_url"):
if not base_url:
raise ValueError("base_url is required for custom endpoints")
parsed = urlparse(base_url)
if parsed.scheme not in {"http", "https"}:
raise ValueError("base_url must start with http:// or https://")
config_path = _get_config_path()
# Guard: if config.yaml already exists and the caller did not explicitly
# acknowledge the overwrite, refuse to proceed. The frontend must pass
# confirm_overwrite=True after showing the user a confirmation step.
if Path(config_path).exists() and not body.get("confirm_overwrite"):
return {
"error": "config_exists",
"message": (
"Hermes is already configured (config.yaml exists). "
"Pass confirm_overwrite=true to overwrite it."
),
"requires_confirm": True,
}
cfg = _load_yaml_config(config_path)
env_path = _get_active_hermes_home() / ".env"
env_values = _load_env_file(env_path)
if not api_key and not _provider_api_key_present(provider, cfg, env_values):
raise ValueError(f"{provider_meta['env_var']} is required")
model_cfg = cfg.get("model", {})
if not isinstance(model_cfg, dict):
model_cfg = {}
model_cfg["provider"] = provider
model_cfg["default"] = _normalize_model_for_provider(provider, model)
if provider_meta.get("requires_base_url"):
model_cfg["base_url"] = base_url
elif provider_meta.get("default_base_url"):
model_cfg["base_url"] = provider_meta["default_base_url"]
else:
model_cfg.pop("base_url", None)
cfg["model"] = model_cfg
_save_yaml_config(config_path, cfg)
if api_key:
_write_env_file(env_path, {provider_meta["env_var"]: api_key})
# Reload the hermes_cli provider/config cache so the next streaming call
# picks up the new key without requiring a server restart.
try:
from api.profiles import _reload_dotenv
_reload_dotenv(_get_active_hermes_home())
except Exception:
logger.debug("Failed to reload dotenv")
# Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the
# value survives even if _reload_dotenv cleared it (e.g. when _write_env_file
# wrote to disk but the profile isolation tracking hasn't seen it yet).
if api_key:
os.environ[provider_meta["env_var"]] = api_key
try:
# hermes_cli may cache config at import time; ask it to reload if possible.
from hermes_cli.config import reload as _cli_reload
_cli_reload()
except Exception:
logger.debug("Failed to reload hermes_cli config")
reload_config()
return get_onboarding_status()
def complete_onboarding() -> dict:
save_settings({"onboarding_completed": True})
return get_onboarding_status()

View File

@@ -9,12 +9,15 @@ cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot
HERMES_HOME at import time.
"""
import json
import logging
import os
import re
import shutil
import threading
from pathlib import Path
logger = logging.getLogger(__name__)
# ── Constants (match hermes_cli.profiles upstream) ─────────────────────────
_PROFILE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
_PROFILE_DIRS = [
@@ -26,6 +29,13 @@ _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md']
# ── Module state ────────────────────────────────────────────────────────────
_active_profile = 'default'
_profile_lock = threading.Lock()
_loaded_profile_env_keys: set[str] = set()
# Thread-local profile context: set per-request by server.py, cleared after.
# Enables per-client profile isolation (issue #798) — each HTTP request thread
# reads its own profile from the hermes_profile cookie instead of the
# process-global _active_profile.
_tls = threading.local()
def _resolve_base_hermes_home() -> Path:
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
@@ -71,31 +81,166 @@ def _read_active_profile_file() -> str:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
if ap_file.exists():
try:
name = ap_file.read_text().strip()
name = ap_file.read_text(encoding="utf-8").strip()
if name:
return name
except Exception:
pass
logger.debug("Failed to read active profile file")
return 'default'
# ── Public API ──────────────────────────────────────────────────────────────
def get_active_profile_name() -> str:
"""Return the currently active profile name."""
"""Return the currently active profile name.
Priority:
1. Thread-local (set per-request from hermes_profile cookie) — issue #798
2. Process-level default (_active_profile)
"""
tls_name = getattr(_tls, 'profile', None)
if tls_name is not None:
return tls_name
return _active_profile
def set_request_profile(name: str) -> None:
"""Set the per-request profile context for this thread.
Called by server.py at the start of each request when a hermes_profile
cookie is present. Always paired with clear_request_profile() in a
finally block so the thread-local is released after the request.
"""
_tls.profile = name
def clear_request_profile() -> None:
"""Clear the per-request profile context for this thread.
Called by server.py in the finally block of do_GET / do_POST.
Safe to call even if set_request_profile() was never called.
"""
_tls.profile = None
def get_active_hermes_home() -> Path:
"""Return the HERMES_HOME path for the currently active profile."""
if _active_profile == 'default':
"""Return the HERMES_HOME path for the currently active profile.
Uses get_active_profile_name() so per-request TLS context (issue #798)
is respected, not just the process-level global.
"""
name = get_active_profile_name()
if name == 'default':
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
def get_hermes_home_for_profile(name: str) -> Path:
"""Return the HERMES_HOME Path for *name* without mutating any process state.
Safe to call from per-request context (streaming, session creation) because
it reads only the filesystem — it never touches os.environ, module-level
cached paths, or the process-level _active_profile global.
Falls back to _DEFAULT_HERMES_HOME (same as 'default') when *name* is None,
empty, 'default', or does not match the profile-name format (rejects path
traversal such as '../../etc').
"""
if not name or name == 'default' or not _PROFILE_ID_RE.match(name):
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
_TERMINAL_ENV_MAPPINGS = {
'backend': 'TERMINAL_ENV',
'env_type': 'TERMINAL_ENV',
'cwd': 'TERMINAL_CWD',
'timeout': 'TERMINAL_TIMEOUT',
'lifetime_seconds': 'TERMINAL_LIFETIME_SECONDS',
'modal_mode': 'TERMINAL_MODAL_MODE',
'docker_image': 'TERMINAL_DOCKER_IMAGE',
'docker_forward_env': 'TERMINAL_DOCKER_FORWARD_ENV',
'docker_env': 'TERMINAL_DOCKER_ENV',
'docker_mount_cwd_to_workspace': 'TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE',
'singularity_image': 'TERMINAL_SINGULARITY_IMAGE',
'modal_image': 'TERMINAL_MODAL_IMAGE',
'daytona_image': 'TERMINAL_DAYTONA_IMAGE',
'container_cpu': 'TERMINAL_CONTAINER_CPU',
'container_memory': 'TERMINAL_CONTAINER_MEMORY',
'container_disk': 'TERMINAL_CONTAINER_DISK',
'container_persistent': 'TERMINAL_CONTAINER_PERSISTENT',
'docker_volumes': 'TERMINAL_DOCKER_VOLUMES',
'persistent_shell': 'TERMINAL_PERSISTENT_SHELL',
'ssh_host': 'TERMINAL_SSH_HOST',
'ssh_user': 'TERMINAL_SSH_USER',
'ssh_port': 'TERMINAL_SSH_PORT',
'ssh_key': 'TERMINAL_SSH_KEY',
'ssh_persistent': 'TERMINAL_SSH_PERSISTENT',
'local_persistent': 'TERMINAL_LOCAL_PERSISTENT',
}
def _stringify_env_value(value) -> str:
if isinstance(value, bool):
return 'true' if value else 'false'
if isinstance(value, (list, dict)):
return json.dumps(value)
return str(value)
def get_profile_runtime_env(home: Path) -> dict[str, str]:
"""Return env vars needed to run an agent turn for a profile home.
WebUI profile switching is per-client/cookie scoped, so it intentionally
does not call ``switch_profile(..., process_wide=True)`` for every browser.
Agent/tool code still consumes terminal backend settings through
environment variables (matching ``hermes -p <profile>``), so streaming must
apply the selected profile's terminal config and ``.env`` for the duration
of that run.
"""
home = Path(home).expanduser()
env: dict[str, str] = {}
try:
import yaml as _yaml
cfg_path = home / 'config.yaml'
cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
if not isinstance(cfg, dict):
cfg = {}
except Exception:
cfg = {}
terminal_cfg = cfg.get('terminal', {}) if isinstance(cfg, dict) else {}
if isinstance(terminal_cfg, dict):
for key, env_key in _TERMINAL_ENV_MAPPINGS.items():
if key in terminal_cfg and terminal_cfg[key] is not None:
env[env_key] = _stringify_env_value(terminal_cfg[key])
env_path = home / '.env'
if env_path.exists():
try:
for line in env_path.read_text(encoding='utf-8').splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if k and v:
env[k] = v
except Exception:
logger.debug("Failed to read runtime env from %s", env_path)
return env
def _set_hermes_home(home: Path):
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
os.environ['HERMES_HOME'] = str(home)
@@ -106,7 +251,7 @@ def _set_hermes_home(home: Path):
_sk.HERMES_HOME = home
_sk.SKILLS_DIR = home / 'skills'
except (ImportError, AttributeError):
pass
logger.debug("Failed to patch skills_tool module")
# Patch cron/jobs module-level cache
try:
@@ -116,16 +261,29 @@ def _set_hermes_home(home: Path):
_cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
_cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
except (ImportError, AttributeError):
pass
logger.debug("Failed to patch cron.jobs module")
def _reload_dotenv(home: Path):
"""Load .env from the profile dir into os.environ (additive)."""
"""Load .env from the profile dir into os.environ with profile isolation.
Clears env vars that were loaded from the previously active profile before
applying the current profile's .env. This prevents API keys and other
profile-scoped secrets from leaking across profile switches.
"""
global _loaded_profile_env_keys
# Remove keys loaded from the previous profile first.
for key in list(_loaded_profile_env_keys):
os.environ.pop(key, None)
_loaded_profile_env_keys = set()
env_path = home / '.env'
if not env_path.exists():
return
try:
for line in env_path.read_text().splitlines():
loaded_keys: set[str] = set()
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
@@ -133,11 +291,14 @@ def _reload_dotenv(home: Path):
v = v.strip().strip('"').strip("'")
if k and v:
os.environ[k] = v
loaded_keys.add(k)
_loaded_profile_env_keys = loaded_keys
except Exception:
pass
_loaded_profile_env_keys = set()
logger.debug("Failed to reload dotenv from %s", env_path)
def init_profile_state():
def init_profile_state() -> None:
"""Initialize profile state at server startup.
Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches
@@ -150,12 +311,18 @@ def init_profile_state():
_reload_dotenv(home)
def switch_profile(name: str) -> dict:
def switch_profile(name: str, *, process_wide: bool = True) -> dict:
"""Switch the active profile.
Validates the profile exists, updates process state, patches module caches,
reloads .env, and reloads config.yaml.
Args:
name: Profile name to switch to.
process_wide: If True (default), updates the process-global
_active_profile. Set to False for per-client switches from the
WebUI where the profile is managed via cookie + thread-local (#798).
Returns: {'profiles': [...], 'active': name}
Raises ValueError if profile doesn't exist or agent is busy.
"""
@@ -176,29 +343,45 @@ def switch_profile(name: str) -> dict:
if name == 'default':
home = _DEFAULT_HERMES_HOME
else:
home = _DEFAULT_HERMES_HOME / 'profiles' / name
home = _resolve_named_profile_home(name)
if not home.is_dir():
raise ValueError(f"Profile '{name}' does not exist.")
with _profile_lock:
_active_profile = name
_set_hermes_home(home)
_reload_dotenv(home)
if process_wide:
global _active_profile
_active_profile = name
_set_hermes_home(home)
_reload_dotenv(home)
# Write sticky default for CLI consistency
try:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
ap_file.write_text(name if name != 'default' else '')
except Exception:
pass
if process_wide:
# Write sticky default for CLI consistency
try:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
ap_file.write_text(name if name != 'default' else '', encoding='utf-8')
except Exception:
logger.debug("Failed to write active profile file")
# Reload config.yaml from the new profile
reload_config()
# Reload config.yaml from the new profile
reload_config()
# Return profile-specific defaults so frontend can apply them
from api.workspace import get_last_workspace
from api.config import get_config
cfg = get_config()
# Return profile-specific defaults so frontend can apply them.
# For process_wide=False (per-client switch), read the target profile's
# config.yaml directly from disk rather than from _cfg_cache (process-global),
# since reload_config() was intentionally skipped.
if process_wide:
from api.config import get_config
cfg = get_config()
else:
# Direct disk read — does not touch _cfg_cache
try:
import yaml as _yaml
cfg_path = home / 'config.yaml'
cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
if not isinstance(cfg, dict):
cfg = {}
except Exception:
cfg = {}
model_cfg = cfg.get('model', {})
default_model = None
if isinstance(model_cfg, str):
@@ -206,11 +389,57 @@ def switch_profile(name: str) -> dict:
elif isinstance(model_cfg, dict):
default_model = model_cfg.get('default')
# Read the target profile's workspace directly from *home* rather than via
# get_last_workspace() which routes through the thread-local/process-global active
# profile — both of which still point to the OLD profile during process_wide=False
# switches (the Set-Cookie has been sent but hasn't been processed by a new request
# yet). We derive workspace in priority order:
# 1. {home}/webui_state/last_workspace.txt (previously chosen workspace for this profile)
# 2. cfg terminal.cwd / workspace / default_workspace keys
# 3. Boot-time DEFAULT_WORKSPACE constant
# Use the module-level ``Path`` (imported at line 17) rather than re-importing
# it locally — keeps the exception fallback simple and avoids a latent NameError
# if a future refactor moves the inner imports.
default_workspace = None
try:
from api.config import DEFAULT_WORKSPACE as _DW
lw_file = home / 'webui_state' / 'last_workspace.txt'
if lw_file.exists():
_p = lw_file.read_text(encoding='utf-8').strip()
if _p:
_pp = Path(_p).expanduser()
if _pp.is_dir():
default_workspace = str(_pp.resolve())
if default_workspace is None:
for _key in ('workspace', 'default_workspace'):
_v = cfg.get(_key)
if _v:
_pp = Path(str(_v)).expanduser().resolve()
if _pp.is_dir():
default_workspace = str(_pp)
break
if default_workspace is None:
_tc = cfg.get('terminal', {})
if isinstance(_tc, dict):
_cwd = _tc.get('cwd', '')
if _cwd and str(_cwd) not in ('.', ''):
_pp = Path(str(_cwd)).expanduser().resolve()
if _pp.is_dir():
default_workspace = str(_pp)
if default_workspace is None:
default_workspace = str(_DW)
except Exception:
try:
from api.config import DEFAULT_WORKSPACE as _DW2
default_workspace = str(_DW2)
except Exception:
default_workspace = str(Path.home())
return {
'profiles': list_profiles_api(),
'active': name,
'default_model': default_model,
'default_workspace': get_last_workspace(),
'default_workspace': default_workspace,
}
@@ -223,7 +452,7 @@ def list_profiles_api() -> list:
# hermes_cli not available -- return just the default
return [_default_profile_dict()]
active = _active_profile
active = get_active_profile_name()
result = []
for p in infos:
result.append({
@@ -267,6 +496,24 @@ def _validate_profile_name(name: str):
)
def _profiles_root() -> Path:
"""Return the canonical root that contains named profiles."""
return (_DEFAULT_HERMES_HOME / 'profiles').resolve()
def _resolve_named_profile_home(name: str) -> Path:
"""Resolve a named profile to a directory under the profiles root.
Validates *name* as a logical profile identifier first, then resolves the
final filesystem path and enforces containment under ~/.hermes/profiles.
"""
_validate_profile_name(name)
profiles_root = _profiles_root()
candidate = (profiles_root / name).resolve()
candidate.relative_to(profiles_root)
return candidate
def _create_profile_fallback(name: str, clone_from: str = None,
clone_config: bool = False) -> Path:
"""Create a profile directory without hermes_cli (Docker/standalone fallback)."""
@@ -294,8 +541,38 @@ def _create_profile_fallback(name: str, clone_from: str = None,
return profile_dir
def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: str = None) -> None:
"""Write custom endpoint fields into config.yaml for a profile."""
if not base_url and not api_key:
return
config_path = profile_dir / 'config.yaml'
try:
import yaml as _yaml
except ImportError:
return
cfg = {}
if config_path.exists():
try:
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
cfg = loaded
except Exception:
logger.debug("Failed to load config from %s", config_path)
model_section = cfg.get('model', {})
if not isinstance(model_section, dict):
model_section = {}
if base_url:
model_section['base_url'] = base_url
if api_key:
model_section['api_key'] = api_key
cfg['model'] = model_section
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
def create_profile_api(name: str, clone_from: str = None,
clone_config: bool = False) -> dict:
clone_config: bool = False,
base_url: str = None,
api_key: str = None) -> dict:
"""Create a new profile. Returns the new profile info dict."""
_validate_profile_name(name)
# Defense-in-depth: validate clone_from here too, even though routes.py
@@ -315,11 +592,26 @@ def create_profile_api(name: str, clone_from: str = None,
except ImportError:
_create_profile_fallback(name, clone_from, clone_config)
# Resolve the profile directory from the profile list when possible.
# hermes_cli and the webui runtime do not always agree on the exact root,
# so we prefer the path returned by list_profiles_api() and fall back to the
# standard profile location only if the profile cannot be found there yet.
profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name
for p in list_profiles_api():
if p['name'] == name:
try:
profile_path = Path(p.get('path') or profile_path)
except Exception:
logger.debug("Failed to parse profile path")
break
profile_path.mkdir(parents=True, exist_ok=True)
_write_endpoint_to_config(profile_path, base_url=base_url, api_key=api_key)
# Find and return the newly created profile info.
# When hermes_cli is not importable, list_profiles_api() also falls back
# to the stub default-only list and won't find the new profile by name.
# In that case, return a complete profile dict directly.
profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name
for p in list_profiles_api():
if p['name'] == name:
return p
@@ -340,6 +632,7 @@ def delete_profile_api(name: str) -> dict:
"""Delete a profile. Switches to default first if it's the active one."""
if name == 'default':
raise ValueError("Cannot delete the default profile.")
_validate_profile_name(name)
# If deleting the active profile, switch to default first
if _active_profile == name:
@@ -357,7 +650,7 @@ def delete_profile_api(name: str) -> dict:
except ImportError:
# Manual fallback: just remove the directory
import shutil
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
profile_dir = _resolve_named_profile_home(name)
if profile_dir.is_dir():
shutil.rmtree(str(profile_dir))
else:

553
api/providers.py Normal file
View File

@@ -0,0 +1,553 @@
"""Hermes Web UI -- provider management endpoints.
Provides CRUD operations for configuring provider API keys post-onboarding.
Closes #586 (allow provider key update) and part of #604 (model picker
multi-provider support).
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
from api.config import (
_PROVIDER_DISPLAY,
_PROVIDER_MODELS,
_get_config_path,
_save_yaml_config_file,
get_config,
invalidate_models_cache,
reload_config,
)
logger = logging.getLogger(__name__)
# SECTION: Provider ↔ env var mapping
# Maps canonical provider slug → env var name for API key.
# Providers not listed here (OAuth/token-flow providers like copilot, nous,
# openai-codex) cannot have their keys managed from the WebUI.
_PROVIDER_ENV_VAR: dict[str, str] = {
"openrouter": "OPENROUTER_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
"google": "GOOGLE_API_KEY",
"gemini": "GEMINI_API_KEY",
"zai": "GLM_API_KEY",
"kimi-coding": "KIMI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"minimax": "MINIMAX_API_KEY",
"minimax-cn": "MINIMAX_CN_API_KEY",
"mistralai": "MISTRAL_API_KEY",
"x-ai": "XAI_API_KEY",
"opencode-zen": "OPENCODE_ZEN_API_KEY",
"opencode-go": "OPENCODE_GO_API_KEY",
"ollama": "OLLAMA_API_KEY",
"ollama-cloud": "OLLAMA_API_KEY",
"nvidia": "NVIDIA_API_KEY",
}
# Providers that use OAuth or token flows — their credentials are managed
# through the Hermes CLI, not via API keys. The WebUI cannot set these.
_OAUTH_PROVIDERS = frozenset({
"copilot",
"copilot-acp",
"nous",
"openai-codex",
"qwen-oauth",
})
# SECTION: Helper functions
def _get_hermes_home() -> Path:
"""Return the active Hermes home directory."""
try:
from api.profiles import get_active_hermes_home
return get_active_hermes_home()
except ImportError:
return Path.home() / ".hermes"
def _load_env_file(env_path: Path) -> dict[str, str]:
"""Read key=value pairs from a .env file."""
values: dict[str, str] = {}
if not env_path.exists():
return values
try:
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
except Exception:
return {}
return values
def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
"""Write key=value pairs to the .env file.
Values of ``None`` cause the key to be removed.
Preserves comments, blank lines, and original key order (#1164).
New keys are appended at the end of the file with a blank-line separator.
Holds ``_ENV_LOCK`` from ``api.streaming`` for the entire load → modify →
write cycle to prevent TOCTOU races between concurrent POST /api/providers
calls (each reading the same file baseline and overwriting the other's key).
Also serialises os.environ mutations with streaming sessions.
"""
from api.streaming import _ENV_LOCK
import stat as _stat
with _ENV_LOCK:
# ── Read existing lines (preserving comments and blank lines) ──
existing_lines: list[str] = []
if env_path.exists():
try:
existing_lines = env_path.read_text(encoding="utf-8").splitlines()
except Exception:
existing_lines = []
# Map each existing key to its line index so we can update in-place.
existing_key_indices: dict[str, int] = {}
for _i, _raw in enumerate(existing_lines):
_stripped = _raw.strip()
if _stripped and not _stripped.startswith("#") and "=" in _stripped:
_existing_key_indices_key = _stripped.split("=", 1)[0].strip()
existing_key_indices[_existing_key_indices_key] = _i
output_lines = list(existing_lines)
new_keys: list[str] = []
for key, value in updates.items():
if value is None:
# Mark the line for removal (None sentinel) and clear env.
os.environ.pop(key, None)
if key in existing_key_indices:
output_lines[existing_key_indices[key]] = None # type: ignore[assignment]
continue
clean = str(value).strip()
if not clean:
continue
# Reject embedded newlines/carriage returns to prevent .env injection
if "\n" in clean or "\r" in clean:
raise ValueError("API key must not contain newline characters.")
os.environ[key] = clean
if key in existing_key_indices:
output_lines[existing_key_indices[key]] = f"{key}={clean}"
else:
new_keys.append(f"{key}={clean}")
# Remove deleted lines (None sentinels)
output_lines = [l for l in output_lines if l is not None]
# Append new keys after a blank-line separator
if new_keys:
if output_lines and output_lines[-1].strip() != "":
output_lines.append("")
output_lines.extend(new_keys)
env_path.parent.mkdir(parents=True, exist_ok=True)
content = "\n".join(output_lines)
if content:
content += "\n"
# Atomic write via tempfile + os.replace so cross-process readers
# (Telegram bot, CLI) never see a half-truncated file. The shared
# ``~/.hermes/.env`` is also written by ``hermes_cli.config.save_env_value``
# using the same atomic pattern; matching it here closes the
# cross-process leg of #1164 (within-process is covered by _ENV_LOCK).
_mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
import tempfile as _tempfile
_tmp_fd, _tmp_path = _tempfile.mkstemp(
dir=str(env_path.parent), prefix=".env_", suffix=".tmp"
)
try:
with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f:
_f.write(content)
_f.flush()
os.fsync(_f.fileno())
os.chmod(_tmp_path, _mode) # tighten before rename so readers see 0600
os.replace(_tmp_path, env_path)
except BaseException:
try:
os.unlink(_tmp_path)
except OSError:
pass
raise
try:
env_path.chmod(_mode)
except OSError:
pass
def _provider_has_key(provider_id: str) -> bool:
"""Check whether a provider has a configured API key.
Checks (in order):
1. ``~/.hermes/.env`` for the known env var
2. ``os.environ`` for the known env var
3. ``config.yaml → model.api_key`` (only if provider is the active one)
4. ``config.yaml → providers.<id>.api_key``
5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
"""
env_var = _PROVIDER_ENV_VAR.get(provider_id)
if env_var:
env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path)
if env_values.get(env_var):
return True
if os.getenv(env_var):
return True
cfg = get_config()
# Check model.api_key — only match if this provider is the active one.
# Previously this checked globally, causing all providers to show
# "configured" when the active provider had a top-level api_key.
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
active_provider = model_cfg.get("provider")
if active_provider and str(active_provider).strip().lower() == provider_id.lower():
return True
# Check providers.<id>.api_key
providers_cfg = cfg.get("providers", {})
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(provider_id, {})
if isinstance(provider_cfg, dict) and str(provider_cfg.get("api_key") or "").strip():
return True
# Check custom_providers
custom_providers = cfg.get("custom_providers", [])
if isinstance(custom_providers, list):
for cp in custom_providers:
if isinstance(cp, dict):
cp_name = (cp.get("name") or "").strip().lower().replace(" ", "-")
if f"custom:{cp_name}" == provider_id or cp.get("name", "").strip().lower() == provider_id:
if str(cp.get("api_key") or "").strip():
return True
return False
def _provider_is_oauth(provider_id: str) -> bool:
"""Check whether a provider uses OAuth/token flows (managed by CLI)."""
return provider_id in _OAUTH_PROVIDERS
# SECTION: Public API
def get_providers() -> dict[str, Any]:
"""Return a list of all known providers with their configuration status.
Each entry contains:
- ``id``: canonical provider slug
- ``display_name``: human-readable name
- ``has_key``: whether an API key is configured
- ``configurable``: whether the key can be set from the WebUI
- ``key_source``: where the key was found (``env_file``, ``env_var``,
``config_yaml``, ``oauth``, ``none``)
- ``models``: list of known model IDs for this provider
"""
providers = []
# Collect all known provider IDs from multiple sources
known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
# Also detect providers from config.yaml providers section
cfg = get_config()
providers_cfg = cfg.get("providers", {})
if isinstance(providers_cfg, dict):
known_ids.update(providers_cfg.keys())
# Add OAuth providers even if not in _PROVIDER_DISPLAY
known_ids.update(_OAUTH_PROVIDERS)
for pid in sorted(known_ids):
display_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
is_oauth = _provider_is_oauth(pid)
has_key = _provider_has_key(pid)
# Determine key source
key_source = "none"
auth_error = None
if is_oauth:
key_source = "oauth"
# Check if actually authenticated via hermes_cli.
# IMPORTANT: do not unconditionally overwrite has_key from _provider_has_key().
# A token in config.yaml is a valid credential even when get_auth_status()
# returns logged_in=False (e.g. token not in the hermes credential pool,
# or refresh token consumed by native Codex CLI / VS Code extension).
try:
from hermes_cli.auth import get_auth_status as _gas
status = _gas(pid)
if isinstance(status, dict) and status.get("logged_in"):
has_key = True
key_source = status.get("key_source", "oauth")
elif has_key:
# _provider_has_key() found a token in config.yaml — respect it
# rather than hiding a working credential from the Settings UI.
key_source = "config_yaml"
auth_error = status.get("error") if isinstance(status, dict) else None
else:
has_key = False
auth_error = status.get("error") if isinstance(status, dict) else None
except Exception:
# Import failed or auth check errored — don't override a known-good
# key just because the hermes_cli auth module is unavailable.
logger.debug("hermes_cli auth check failed for %s", pid, exc_info=True)
# keep has_key from _provider_has_key()
elif has_key:
env_var = _PROVIDER_ENV_VAR.get(pid)
if env_var:
env_path = _get_hermes_home() / ".env"
env_values = _load_env_file(env_path)
if env_values.get(env_var):
key_source = "env_file"
elif os.getenv(env_var):
key_source = "env_var"
else:
key_source = "config_yaml"
else:
key_source = "config_yaml"
elif pid not in _PROVIDER_ENV_VAR:
# Fallback: provider is not a known API-key provider and not in
# the hardcoded _OAUTH_PROVIDERS set. It may be a custom or
# newly-added OAuth provider (e.g. Anthropic connected via OAuth).
# Check live auth status so the Providers tab agrees with the
# model picker (#1212).
#
# IMPORTANT: we skip providers in _PROVIDER_ENV_VAR because they
# are pure API-key providers — calling get_auth_status() for every
# unconfigured API-key provider would add unnecessary latency
# (network round-trip per provider) on the Settings page.
# Validate pid looks like a real provider before probing
import re as _re
if _re.match(r'^[a-z][a-z0-9_-]{0,63}$', pid):
try:
from hermes_cli.auth import get_auth_status as _gas
status = _gas(pid)
if isinstance(status, dict) and status.get("logged_in"):
has_key = True
# Constrain key_source to a known-safe closed set
_raw_ks = status.get("key_source", "")
key_source = _raw_ks if _raw_ks in {"oauth", "env", "config", "token"} else "oauth"
is_oauth = True
except Exception:
pass
models = _PROVIDER_MODELS.get(pid, [])
# Also include models from config.yaml providers section
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(pid, {})
if isinstance(provider_cfg, dict) and "models" in provider_cfg:
cfg_models = provider_cfg["models"]
if isinstance(cfg_models, dict):
models = models + [{"id": k, "label": k} for k in cfg_models.keys()]
elif isinstance(cfg_models, list):
models = models + [{"id": k, "label": k} for k in cfg_models]
providers.append({
"id": pid,
"display_name": display_name,
"has_key": has_key,
"configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
"is_oauth": is_oauth,
"key_source": key_source,
"auth_error": auth_error,
"models": models,
})
# Scan custom_providers from config.yaml (e.g. glmcode, timicc)
custom_providers_cfg = cfg.get("custom_providers", [])
if isinstance(custom_providers_cfg, list):
for cp in custom_providers_cfg:
if not isinstance(cp, dict) or not cp.get("name"):
continue
cp_name = str(cp["name"]).strip()
cp_id = f"custom:{cp_name}"
# Collect models from `models` list or `model` single
cp_models = []
if isinstance(cp.get("models"), list):
cp_models = [{"id": str(m), "label": str(m)} for m in cp["models"]]
elif cp.get("model"):
cp_models = [{"id": cp["model"], "label": cp["model"]}]
# Check for env var reference (${VAR_NAME} pattern)
cp_api_key = str(cp.get("api_key") or "")
cp_has_key = bool(cp_api_key.strip())
# Replace env var reference to check actual value
if cp_api_key.startswith("${") and cp_api_key.endswith("}"):
env_var = cp_api_key[2:-1]
cp_has_key = bool(os.getenv(env_var, "").strip())
providers.append({
"id": cp_id,
"display_name": cp_name,
"has_key": cp_has_key,
"configurable": False, # custom providers managed via config.yaml
"key_source": "config_yaml" if cp_has_key else "none",
"models": cp_models,
})
# Determine active provider
active_provider = None
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
active_provider = model_cfg.get("provider")
return {
"providers": providers,
"active_provider": active_provider,
}
def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
"""Set or update the API key for a provider.
Writes the key to ``~/.hermes/.env`` using the standard env var name.
If ``api_key`` is None or empty, the key is removed.
Returns a status dict with the operation result.
"""
provider_id = provider_id.strip().lower()
if not provider_id:
return {"ok": False, "error": "Provider ID is required."}
if _provider_is_oauth(provider_id):
return {
"ok": False,
"error": f"'{_PROVIDER_DISPLAY.get(provider_id, provider_id)}' uses OAuth authentication. "
f"Use `hermes model` in the terminal to configure it.",
}
env_var = _PROVIDER_ENV_VAR.get(provider_id)
if not env_var:
return {
"ok": False,
"error": f"Cannot configure API key for '{_PROVIDER_DISPLAY.get(provider_id, provider_id)}'. "
f"This provider does not have a known env var mapping.",
}
# Validate API key format (basic sanity check)
if api_key:
api_key = api_key.strip()
if "\n" in api_key or "\r" in api_key:
return {"ok": False, "error": "API key must not contain newline characters."}
if len(api_key) < 8:
return {"ok": False, "error": "API key appears too short."}
env_path = _get_hermes_home() / ".env"
try:
_write_env_file(env_path, {env_var: api_key})
except ValueError as exc:
return {"ok": False, "error": str(exc)}
except Exception as exc:
logger.exception("Failed to write env file for provider %s", provider_id)
return {"ok": False, "error": f"Failed to save API key: {exc}"}
# Invalidate the model cache so the dropdown refreshes on next request.
# Using invalidate_models_cache() instead of reload_config() to avoid
# disrupting active streaming sessions that may be reading config.cfg.
invalidate_models_cache()
return {
"ok": True,
"provider": provider_id,
"display_name": _PROVIDER_DISPLAY.get(provider_id, provider_id),
"action": "updated" if api_key else "removed",
}
def remove_provider_key(provider_id: str) -> dict[str, Any]:
"""Remove the API key for a provider.
Removes the key from ``~/.hermes/.env`` (via ``set_provider_key``)
and also cleans up ``config.yaml`` if the key is stored there
(``providers.<id>.api_key`` or top-level ``model.api_key`` when this
provider is the active one).
Returns a status dict with the operation result.
"""
result = set_provider_key(provider_id, None)
# Even if the .env removal succeeded, the key might also live in
# config.yaml (e.g. providers.<id>.api_key or model.api_key).
# Clean those up so _provider_has_key() returns False after removal.
if result.get("ok"):
_clean_provider_key_from_config(provider_id)
return result
def _clean_provider_key_from_config(provider_id: str) -> None:
"""Remove provider API key entries from config.yaml.
Handles three storage locations:
1. ``providers.<id>.api_key`` — per-provider key
2. ``model.api_key`` — top-level key (only if provider is active)
3. ``custom_providers[].api_key`` — custom provider entries
Writes back to config.yaml only if something was actually removed.
Uses ``_cfg_lock`` to prevent TOCTOU races.
"""
from api.config import _cfg_lock
try:
config_path = _get_config_path()
except Exception:
return
if not config_path.exists():
return
try:
import yaml as _yaml
changed = False
with _cfg_lock:
raw = config_path.read_text(encoding="utf-8")
cfg = _yaml.safe_load(raw)
if not isinstance(cfg, dict):
return
# 1. Clean providers.<id>.api_key
providers_cfg = cfg.get("providers", {})
if isinstance(providers_cfg, dict):
provider_cfg = providers_cfg.get(provider_id, {})
if isinstance(provider_cfg, dict) and provider_cfg.get("api_key"):
del provider_cfg["api_key"]
changed = True
# 2. Clean model.api_key — only if this provider is the active one
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict) and model_cfg.get("api_key"):
active_provider = model_cfg.get("provider")
if active_provider and str(active_provider).strip().lower() == provider_id.lower():
del model_cfg["api_key"]
changed = True
# 3. Clean custom_providers[].api_key
custom_providers = cfg.get("custom_providers", [])
if isinstance(custom_providers, list):
for cp in custom_providers:
if isinstance(cp, dict):
cp_name = (cp.get("name") or "").strip().lower().replace(" ", "-")
if f"custom:{cp_name}" == provider_id or cp.get("name", "").strip().lower() == provider_id:
if cp.get("api_key"):
del cp["api_key"]
changed = True
if changed:
_save_yaml_config_file(config_path, cfg)
# Sync in-memory cache and bust model TTL cache
# MUST be called outside _cfg_lock to avoid deadlock:
# _cfg_lock is a threading.Lock (non-reentrant) and
# reload_config() also acquires _cfg_lock internally.
if changed:
reload_config()
except Exception:
logger.exception("Failed to clean provider key from config.yaml for %s", provider_id)

File diff suppressed because it is too large Load Diff

187
api/session_ops.py Normal file
View File

@@ -0,0 +1,187 @@
"""Session-mutation operations for slash commands (/retry, /undo) and
read-only aggregators (/status, /usage). Operates on the webui's own
JSON Session store (api/models.py), not on hermes-agent's SQLite.
Behavior parity reference: gateway/run.py:_handle_*_command in
the hermes-agent repo.
"""
from __future__ import annotations
import logging
from typing import Any
from api.config import LOCK, _get_session_agent_lock
from api.models import get_session, SESSIONS
logger = logging.getLogger(__name__)
def _truncate_at_last_user(messages):
history = messages or []
last_user_idx = None
for i in range(len(history) - 1, -1, -1):
if isinstance(history[i], dict) and history[i].get('role') == 'user':
last_user_idx = i
break
if last_user_idx is None:
return None
return history[:last_user_idx]
def retry_last(session_id: str) -> dict[str, Any]:
"""Truncate the session to before the last user message, return its text.
Mirrors gateway/run.py:_handle_retry_command. Caller (webui frontend)
is expected to put the returned text back in the composer and call
send() to resume the conversation -- the agent's gateway calls its own
_handle_message; the webui has no equivalent in-process pipeline.
Raises:
KeyError: session not found
ValueError: no user message in transcript
"""
# Acquire the per-session agent lock as the outermost lock so that the
# read-modify-write of s.messages is serialised with the periodic
# checkpoint thread, cancel_stream, and all other session writers.
# Lock ordering: _agent_lock → LOCK → _write_session_index (LOCK).
with _get_session_agent_lock(session_id):
# get_session() and Session.save() both acquire the module-level LOCK
# internally (the latter via _write_session_index()), and LOCK is a
# non-reentrant threading.Lock — so they MUST be called outside our
# own `with LOCK:` block to avoid self-deadlocking.
#
# The race we close is the read-modify-write of s.messages: two
# concurrent /api/session/retry calls could otherwise both compute the
# same last_user_idx from the same history and double-truncate. We
# serialize just the in-memory mutation; persistence happens inside
# the per-session lock so the checkpoint thread cannot race us.
#
# Stale-object guard: on a cache miss, two concurrent get_session()
# calls can each load and cache a *different* Session instance for the
# same session_id (the second store clobbers the first). Re-bind to
# the canonical cached instance inside the lock so the mutation lands
# on the object the next reader will see, not a stale parallel copy.
s = get_session(session_id) # raises KeyError if missing
with LOCK:
s = SESSIONS.get(session_id, s)
history = s.messages or []
last_user_idx = None
for i in range(len(history) - 1, -1, -1):
if history[i].get('role') == 'user':
last_user_idx = i
break
if last_user_idx is None:
raise ValueError('No previous message to retry.')
last_user_text = _extract_text(history[last_user_idx].get('content', ''))
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
truncated_context = _truncate_at_last_user(s.context_messages)
if truncated_context is not None:
s.context_messages = truncated_context
s.save()
return {'last_user_text': last_user_text, 'removed_count': removed_count}
def undo_last(session_id: str) -> dict[str, Any]:
"""Remove the most recent user message and everything after it.
Mirrors gateway/run.py:_handle_undo_command. Returns a preview of the
removed text so the UI can confirm to the user.
Raises:
KeyError: session not found
ValueError: no user message in transcript
"""
# Acquire the per-session agent lock as the outermost lock so that the
# read-modify-write of s.messages is serialised with the periodic
# checkpoint thread, cancel_stream, and all other session writers.
# Lock ordering: _agent_lock → LOCK → _write_session_index (LOCK).
with _get_session_agent_lock(session_id):
s = get_session(session_id) # acquires LOCK transiently
with LOCK:
# Stale-object guard — see retry_last for the rationale.
s = SESSIONS.get(session_id, s)
history = s.messages or []
last_user_idx = None
for i in range(len(history) - 1, -1, -1):
if history[i].get('role') == 'user':
last_user_idx = i
break
if last_user_idx is None:
raise ValueError('Nothing to undo.')
removed_text = _extract_text(history[last_user_idx].get('content', ''))
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
truncated_context = _truncate_at_last_user(s.context_messages)
if truncated_context is not None:
s.context_messages = truncated_context
s.save() # outside LOCK -- save() re-acquires LOCK via _write_session_index()
preview = (removed_text[:40] + '...') if len(removed_text) > 40 else removed_text
return {
'removed_count': removed_count,
'removed_preview': preview,
}
def session_status(session_id: str) -> dict[str, Any]:
"""Return a snapshot of session state for /status.
Webui equivalent of gateway/run.py:_handle_status_command. The agent's
"agent_running" comes from `session_key in self._running_agents`; the
webui equivalent is whether the session has an active stream
(active_stream_id is set).
"""
s = get_session(session_id)
inp = int(s.input_tokens or 0)
out = int(s.output_tokens or 0)
return {
'session_id': s.session_id,
'title': s.title,
'model': s.model,
'workspace': s.workspace,
'personality': s.personality,
'message_count': len(s.messages or []),
'created_at': s.created_at,
'updated_at': s.updated_at,
'agent_running': bool(getattr(s, 'active_stream_id', None)),
'input_tokens': inp,
'output_tokens': out,
'total_tokens': inp + out,
'estimated_cost': s.estimated_cost,
}
def session_usage(session_id: str) -> dict[str, Any]:
"""Return token usage and cost for /usage.
Mirrors gateway/run.py:_handle_usage_command's basic counters. The
agent shows additional fields (rate-limit headroom etc.) that depend
on provider API responses we don't have in webui -- those are deferred.
"""
s = get_session(session_id)
inp = int(s.input_tokens or 0)
out = int(s.output_tokens or 0)
return {
'input_tokens': inp,
'output_tokens': out,
'total_tokens': inp + out,
'estimated_cost': s.estimated_cost,
'model': s.model,
}
def _extract_text(content: Any) -> str:
"""Flatten message content to plain text. Agent stores either a string
or a list of {type, text|...} parts; webui needs the user-typed text."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for p in content:
if isinstance(p, dict) and p.get('type') == 'text':
parts.append(p.get('text', ''))
return ' '.join(parts)
return str(content)

104
api/startup.py Normal file
View File

@@ -0,0 +1,104 @@
"""Hermes Web UI -- startup helpers."""
from __future__ import annotations
import os, stat, subprocess, sys
from pathlib import Path
# Credential files that should never be world-readable
_SENSITIVE_FILES = (
'.env',
'google_token.json',
'google_client_secret.json',
'.signing_key',
'auth.json',
)
def fix_credential_permissions() -> None:
"""Ensure sensitive files in HERMES_HOME are chmod 600 (owner-only)."""
hermes_home = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes')))
if not hermes_home.is_dir():
return
for name in _SENSITIVE_FILES:
fpath = hermes_home / name
if not fpath.exists():
continue
try:
current = stat.S_IMODE(fpath.stat().st_mode)
if current & 0o077: # group or other bits set
fpath.chmod(0o600)
print(f' [security] fixed permissions on {fpath.name} ({oct(current)} -> 0600)', flush=True)
except OSError:
pass # best-effort; don't abort startup
def _agent_dir() -> Path | None:
hermes_home = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes')))
for raw in [os.environ.get('HERMES_WEBUI_AGENT_DIR', '').strip(), str(hermes_home / 'hermes-agent')]:
if not raw:
continue
p = Path(raw).expanduser()
if p.is_dir():
return p.resolve()
return None
def _trusted_agent_dir(agent_dir: Path) -> bool:
"""Return True if agent_dir passes ownership and permission checks.
Validates that the directory is not world- or group-writable and,
on POSIX systems, is owned by the current process user.
Intentionally does NOT enforce a canonical path (i.e. does not require
the dir to be ~/.hermes/hermes-agent), so custom HERMES_WEBUI_AGENT_DIR
paths work correctly when HERMES_WEBUI_AUTO_INSTALL=1 is set.
"""
try:
st = agent_dir.stat()
if stat.S_IMODE(st.st_mode) & 0o022:
# World- or group-writable — untrusted
return False
if hasattr(os, 'getuid') and st.st_uid != os.getuid():
# Not owned by current user (POSIX only; Windows fallback skips)
return False
return True
except OSError:
return False
def auto_install_agent_deps() -> bool:
enabled = os.environ.get('HERMES_WEBUI_AUTO_INSTALL', '').strip().lower() in ('1', 'true', 'yes')
if not enabled:
print('[!!] Auto-install disabled. Set HERMES_WEBUI_AUTO_INSTALL=1 to enable.', flush=True)
return False
agent_dir = _agent_dir()
if agent_dir is None:
print('[!!] Auto-install skipped: agent directory not found.', flush=True)
return False
if not _trusted_agent_dir(agent_dir):
print('[!!] Auto-install skipped: agent directory failed trust check (check ownership/permissions).', flush=True)
return False
req_file = agent_dir / 'requirements.txt'
pyproject = agent_dir / 'pyproject.toml'
if req_file.exists():
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', '-r', str(req_file)]
print(f' Installing from {req_file} ...', flush=True)
elif pyproject.exists():
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', str(agent_dir)]
print(f' Installing from {agent_dir} (pyproject.toml) ...', flush=True)
else:
print('[!!] Auto-install skipped: no requirements.txt or pyproject.toml in agent dir.', flush=True)
return False
try:
result = subprocess.run(install_args, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
print(f'[!!] pip install failed (exit {result.returncode}):', flush=True)
for line in (result.stderr or '').splitlines()[-10:]:
print(f' {line}', flush=True)
return False
print('[ok] pip install completed.', flush=True)
return True
except subprocess.TimeoutExpired:
print('[!!] Auto-install timed out after 120s.', flush=True)
return False
except Exception as e:
print(f'[!!] Auto-install error: {e}', flush=True)
return False

118
api/state_sync.py Normal file
View File

@@ -0,0 +1,118 @@
"""
Hermes Web UI -- Optional state.db sync bridge.
Mirrors WebUI session metadata (token usage, title, model) into the
hermes-agent state.db so that /insights, session lists, and cost
tracking include WebUI activity.
This is opt-in via the 'sync_to_insights' setting (default: off).
All operations are wrapped in try/except -- if state.db is unavailable,
locked, or the schema doesn't match, the WebUI continues normally.
The bridge uses absolute token counts (not deltas) because the WebUI
Session object already accumulates totals across turns. This avoids
any double-counting risk.
"""
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
def _get_state_db():
"""Get a SessionDB instance for the active profile's state.db.
Returns None if hermes_state is not importable or DB is unavailable.
Each caller is responsible for calling db.close() when done.
"""
try:
from hermes_state import SessionDB
except ImportError:
return None
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
logger.debug("Failed to resolve hermes home, using default")
hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
db_path = hermes_home / 'state.db'
if not db_path.exists():
return None
try:
return SessionDB(db_path)
except Exception:
logger.debug("Failed to open state.db")
return None
def sync_session_start(session_id: str, model=None) -> None:
"""Register a WebUI session in state.db (idempotent).
Called when a session's first message is sent.
"""
db = _get_state_db()
if not db:
return
try:
db.ensure_session(
session_id=session_id,
source='webui',
model=model,
)
except Exception:
logger.debug("Failed to sync session start to state.db")
finally:
try:
db.close()
except Exception:
logger.debug("Failed to close state.db")
def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
estimated_cost=None, model=None, title: str=None,
message_count: int=None) -> None:
"""Update token usage and title for a WebUI session in state.db.
Called after each turn completes. Uses absolute=True to set totals
(the WebUI Session already accumulates across turns).
"""
db = _get_state_db()
if not db:
return
try:
# Ensure session exists first (idempotent)
db.ensure_session(session_id=session_id, source='webui', model=model)
# Set absolute token counts
db.update_token_counts(
session_id=session_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
estimated_cost_usd=estimated_cost,
model=model,
absolute=True,
)
# Update title if we have one, using the public API
if title:
try:
db.set_session_title(session_id, title)
except Exception:
logger.debug("Failed to sync session title to state.db")
# Update message count
if message_count is not None:
try:
def _set_msg_count(conn):
conn.execute(
"UPDATE sessions SET message_count = ? WHERE id = ?",
(message_count, session_id),
)
db._execute_write(_set_msg_count)
except Exception:
logger.debug("Failed to sync message count to state.db")
except Exception:
logger.debug("Failed to sync session usage to state.db")
finally:
try:
db.close()
except Exception:
logger.debug("Failed to close state.db")

File diff suppressed because it is too large Load Diff

248
api/terminal.py Normal file
View File

@@ -0,0 +1,248 @@
"""Embedded workspace terminal support for Hermes Web UI.
The terminal is intentionally independent from the agent execution path. It
starts a shell with an explicit cwd/env per process and never mutates
process-global os.environ, which avoids expanding the session-env race tracked
in the agent execution layer.
"""
from __future__ import annotations
import errno
import codecs
import fcntl
import os
import queue
import select
import shutil
import signal
import struct
import subprocess
import termios
import threading
from dataclasses import dataclass, field
from pathlib import Path
def _set_nonblocking(fd: int) -> None:
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
def _winsize(rows: int, cols: int) -> bytes:
rows = max(8, min(int(rows or 24), 80))
cols = max(20, min(int(cols or 80), 240))
return struct.pack("HHHH", rows, cols, 0, 0)
@dataclass
class TerminalSession:
session_id: str
workspace: str
proc: subprocess.Popen
master_fd: int
rows: int = 24
cols: int = 80
output: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=2000))
closed: threading.Event = field(default_factory=threading.Event)
reader: threading.Thread | None = None
def is_alive(self) -> bool:
return not self.closed.is_set() and self.proc.poll() is None
def put_output(self, event: str, payload: dict) -> None:
try:
self.output.put_nowait((event, payload))
except queue.Full:
# Keep the terminal responsive by dropping the oldest queued chunk.
try:
self.output.get_nowait()
except queue.Empty:
pass
try:
self.output.put_nowait((event, payload))
except queue.Full:
pass
_TERMINALS: dict[str, TerminalSession] = {}
_LOCK = threading.RLock()
def _decode_terminal_output(decoder, data: bytes) -> str:
"""Decode PTY bytes without stripping terminal control sequences."""
return decoder.decode(data)
def _shell_path() -> str:
shell = os.environ.get("SHELL") or ""
if shell and Path(shell).exists():
return shell
return shutil.which("zsh") or shutil.which("bash") or shutil.which("sh") or "/bin/sh"
def _shell_argv(shell: str) -> list[str]:
name = Path(shell).name
if name in {"zsh", "bash", "sh"}:
return [shell, "-i"]
return [shell]
def _reader_loop(term: TerminalSession) -> None:
decoder = codecs.getincrementaldecoder("utf-8")("replace")
try:
while not term.closed.is_set():
if term.proc.poll() is not None:
break
try:
ready, _, _ = select.select([term.master_fd], [], [], 0.25)
except (OSError, ValueError):
break
if not ready:
continue
try:
data = os.read(term.master_fd, 8192)
except OSError as exc:
if exc.errno in (errno.EIO, errno.EBADF):
break
raise
if not data:
break
text = _decode_terminal_output(decoder, data)
if text:
term.put_output("output", {"text": text})
except Exception as exc:
term.put_output("terminal_error", {"error": str(exc)})
finally:
term.closed.set()
code = term.proc.poll()
term.put_output("terminal_closed", {"exit_code": code})
def _set_size(term: TerminalSession, rows: int, cols: int) -> None:
term.rows = max(8, min(int(rows or term.rows or 24), 80))
term.cols = max(20, min(int(cols or term.cols or 80), 240))
try:
fcntl.ioctl(term.master_fd, termios.TIOCSWINSZ, _winsize(term.rows, term.cols))
except OSError:
pass
try:
if term.proc.poll() is None:
os.killpg(term.proc.pid, signal.SIGWINCH)
except (OSError, ProcessLookupError):
pass
def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = 80, restart: bool = False) -> TerminalSession:
"""Start or return the embedded terminal for a WebUI session."""
sid = str(session_id or "").strip()
if not sid:
raise ValueError("session_id is required")
cwd = str(Path(workspace).expanduser().resolve())
if not Path(cwd).is_dir():
raise ValueError("workspace is not a directory")
with _LOCK:
current = _TERMINALS.get(sid)
if current and current.is_alive() and not restart and current.workspace == cwd:
_set_size(current, rows, cols)
return current
if current:
close_terminal(sid)
master_fd, slave_fd = os.openpty()
# Build a safe env: allowlist common shell vars, strip API keys and secrets.
# The PTY shell is an interactive UI surface — do not leak server credentials.
_SAFE_ENV_KEYS = {
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL",
"LC_CTYPE", "LC_MESSAGES", "LANGUAGE", "TZ", "TMPDIR", "TEMP",
"XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME",
}
env = {k: v for k, v in os.environ.items() if k in _SAFE_ENV_KEYS}
env.update(
{
"TERM": "xterm-256color",
"COLORTERM": "truecolor",
"COLUMNS": str(cols),
"LINES": str(rows),
"PWD": cwd,
"HERMES_WEBUI_TERMINAL": "1",
}
)
shell = _shell_path()
proc = subprocess.Popen(
_shell_argv(shell),
cwd=cwd,
env=env,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
start_new_session=True,
)
os.close(slave_fd)
_set_nonblocking(master_fd)
term = TerminalSession(
session_id=sid,
workspace=cwd,
proc=proc,
master_fd=master_fd,
rows=rows,
cols=cols,
)
_set_size(term, rows, cols)
term.reader = threading.Thread(target=_reader_loop, args=(term,), daemon=True)
term.reader.start()
_TERMINALS[sid] = term
return term
def get_terminal(session_id: str) -> TerminalSession | None:
with _LOCK:
term = _TERMINALS.get(str(session_id or ""))
if term and term.is_alive():
return term
return term
def write_terminal(session_id: str, data: str) -> None:
term = get_terminal(session_id)
if not term or not term.is_alive():
raise KeyError("terminal not running")
os.write(term.master_fd, str(data or "").encode("utf-8", errors="replace"))
def resize_terminal(session_id: str, rows: int, cols: int) -> None:
term = get_terminal(session_id)
if not term:
raise KeyError("terminal not running")
_set_size(term, rows, cols)
def close_terminal(session_id: str) -> bool:
sid = str(session_id or "")
with _LOCK:
term = _TERMINALS.pop(sid, None)
if not term:
return False
term.closed.set()
try:
if term.proc.poll() is None:
try:
os.killpg(term.proc.pid, signal.SIGHUP)
except ProcessLookupError:
pass
try:
term.proc.wait(timeout=1.5)
except subprocess.TimeoutExpired:
try:
os.killpg(term.proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
finally:
try:
os.close(term.master_fd)
except OSError:
pass
return True

431
api/updates.py Normal file
View File

@@ -0,0 +1,431 @@
"""
Hermes Web UI -- Self-update checker.
Checks if the webui and hermes-agent git repos are behind their upstream
branches. Results are cached server-side (30-min TTL) so git fetch runs
at most twice per hour regardless of client count.
Skips repos that are not git checkouts (e.g. Docker baked images where
.git does not exist).
"""
import subprocess
import threading
import time
from pathlib import Path
from api.config import REPO_ROOT
# Lazy -- may be None if agent not found
try:
from api.config import _AGENT_DIR
except ImportError:
_AGENT_DIR = None
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
_cache_lock = threading.Lock()
_check_in_progress = False
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
CACHE_TTL = 1800 # 30 minutes
def _run_git(args, cwd, timeout=10):
"""Run a git command and return (useful output, ok).
On failure, returns stderr (or stdout as fallback) so callers can
surface actionable git error messages instead of empty strings.
"""
try:
r = subprocess.run(
['git'] + args, cwd=str(cwd), capture_output=True,
text=True, timeout=timeout,
)
stdout = r.stdout.strip()
stderr = r.stderr.strip()
if r.returncode == 0:
return stdout, True
return stderr or stdout or f"git exited with status {r.returncode}", False
except subprocess.TimeoutExpired as exc:
detail = (getattr(exc, 'stderr', None) or getattr(exc, 'stdout', None) or '').strip()
return detail or f"git {' '.join(args)} timed out after {timeout}s", False
except FileNotFoundError:
return 'git executable not found', False
except OSError as exc:
return f'git failed to start: {exc}', False
def _detect_webui_version() -> str:
"""Detect the running WebUI version from git or a baked-in fallback file.
Resolution order:
1. ``git describe --tags --always --dirty`` — works in any git checkout.
Returns the exact tag on tagged commits (e.g. ``v0.50.124``), a
post-tag descriptor between releases (e.g. ``v0.50.124-1-ge91325d``),
or a bare SHA when no tags exist (shallow clones, fresh forks).
2. ``api/_version.py`` — a fallback written by the Docker / CI release
workflow when ``.git`` is not present in the image. Expected to define
``__version__ = 'vX.Y.Z'``.
3. ``'unknown'`` — last resort; displayed as-is in the settings badge.
"""
# Timeout capped at 3s: git describe on a healthy local repo is <50ms;
# a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable.
out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], REPO_ROOT, timeout=3)
if ok and out:
return out
# Docker / baked-image fallback: api/_version.py written by CI at build time.
# Parse with regex rather than exec() — the file holds exactly one assignment
# and regex is sufficient; exec() on a build artifact is an unnecessary surface.
version_file = REPO_ROOT / 'api' / '_version.py'
if version_file.exists():
try:
import re as _re
m = _re.search(
r"""__version__\s*=\s*['"]([^'"]+)['"]""",
version_file.read_text(encoding='utf-8'),
)
if m:
return m.group(1)
except Exception:
pass
return 'unknown'
# Resolved once at import time — tags cannot change without a process restart.
WEBUI_VERSION: str = _detect_webui_version()
def _split_remote_ref(ref):
"""Split 'origin/branch-name' into ('origin', 'branch-name').
Returns (None, ref) if ref contains no slash.
"""
if '/' not in ref:
return None, ref
remote, branch = ref.split('/', 1)
return remote, branch
def _detect_default_branch(path):
"""Detect the remote default branch (master or main)."""
out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path)
if ok and out:
# refs/remotes/origin/master -> master
return out.split('/')[-1]
# Fallback: try master, then main
for branch in ('master', 'main'):
_, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path)
if ok:
return branch
return 'master'
def _check_repo(path, name):
"""Check if a git repo is behind its upstream. Returns dict or None."""
if path is None or not (path / '.git').exists():
return None
# Fetch latest from origin (network call, cached by TTL)
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
if not fetch_ok:
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
# Use the current branch's upstream tracking branch, not the repo default.
# This avoids false "N updates behind" alerts when the user is on a feature
# branch and master/main has moved forward with unrelated commits.
# If no upstream is set (brand-new local branch), fall back to the default branch.
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
# upstream is like "origin/feat/foo" — use it directly in rev-list
compare_ref = upstream
else:
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
# Count commits behind
out, ok = _run_git(['rev-list', '--count', f'HEAD..{compare_ref}'], path)
behind = int(out) if ok and out.isdigit() else 0
# Get short SHAs for display
current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path)
latest, _ = _run_git(['rev-parse', '--short', compare_ref], path)
return {
'name': name,
'behind': behind,
'current_sha': current,
'latest_sha': latest,
'branch': compare_ref,
}
def check_for_updates(force=False):
"""Return cached update status for webui and agent repos."""
global _check_in_progress
with _cache_lock:
if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL:
return dict(_update_cache)
if _check_in_progress:
return dict(_update_cache) # another thread is already checking
_check_in_progress = True
try:
# Run checks outside the lock (network I/O)
webui_info = _check_repo(REPO_ROOT, 'webui')
agent_info = _check_repo(_AGENT_DIR, 'agent')
with _cache_lock:
_update_cache['webui'] = webui_info
_update_cache['agent'] = agent_info
_update_cache['checked_at'] = time.time()
return dict(_update_cache)
finally:
_check_in_progress = False
def _schedule_restart(delay: float = 2.0) -> None:
"""Re-exec this process after *delay* seconds.
Called after a successful update so that the freshly-pulled code is
loaded on the next request, rather than running with a mix of old and
new Python modules in sys.modules.
os.execv() replaces the current process image with a fresh interpreter
running the same argv — sessions are preserved on disk, the HTTP port
is reclaimed within the delay window, and the client's own
``setTimeout(() => location.reload(), 2500)`` lands after the restart.
Coordinates with ``_apply_lock``: when the user updates both webui
and agent, the client POSTs them sequentially. Without coordination
the restart timer scheduled by the first update's success would fire
while the second update's git-pull is still running, killing it mid-
stream and leaving the second repo in an unknown partial state.
Blocking on ``_apply_lock`` before ``os.execv`` means a pending
second update always completes before the restart happens.
"""
import os
import sys
def _do():
import time
time.sleep(delay)
# Hold _apply_lock through os.execv so no new update can start between
# the lock-release and the process replacement. Any in-flight update
# finishes first (since it holds the lock), and then the process is
# replaced while still holding the lock — meaning no new update can
# sneak in during the brief TOCTOU window that existed with the
# original acquire-release-execv sequence.
# Threads die when execv replaces the process image, so the lock is
# released atomically by the kernel.
with _apply_lock:
try:
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception:
# Last-resort: if execv fails (e.g. frozen binary), just exit
# so the process supervisor (start.sh / Docker) restarts us.
os._exit(0)
threading.Thread(target=_do, daemon=True).start()
def apply_force_update(target: str) -> dict:
"""Force-reset the target repo to the latest remote HEAD.
Unlike apply_update() which requires a clean working tree and refuses
merge conflicts, this discards all local modifications (checkout .) and
resets to origin/<branch> — equivalent to what the diverged/conflict
error messages ask the user to run manually.
Should only be called when apply_update() has already returned a
response with ``conflict: True`` or ``diverged: True`` and the user
has confirmed they want to discard local changes.
"""
if not _apply_lock.acquire(blocking=False):
return {'ok': False, 'message': 'Update already in progress'}
try:
if target == 'webui':
path = REPO_ROOT
elif target == 'agent':
path = _AGENT_DIR
else:
return {'ok': False, 'message': f'Unknown target: {target}'}
if path is None or not (path / '.git').exists():
return {'ok': False, 'message': 'Not a git repository'}
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
if not fetch_ok:
return {
'ok': False,
'message': 'Could not reach the remote repository. Check your connection.',
}
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
compare_ref = upstream
else:
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
# Discard local modifications then reset to remote HEAD
_run_git(['checkout', '.'], path)
_, ok = _run_git(['reset', '--hard', compare_ref], path)
if not ok:
return {'ok': False, 'message': f'Force reset to {compare_ref} failed'}
with _cache_lock:
_update_cache['checked_at'] = 0
_schedule_restart()
return {
'ok': True,
'message': f'{target} force-updated to {compare_ref}',
'target': target,
'restart_scheduled': True,
}
finally:
_apply_lock.release()
def apply_update(target):
"""Stash, pull --ff-only, pop for the given target repo."""
if not _apply_lock.acquire(blocking=False):
return {'ok': False, 'message': 'Update already in progress'}
try:
return _apply_update_inner(target)
finally:
_apply_lock.release()
def _apply_update_inner(target):
"""Inner implementation of apply_update, called under _apply_lock."""
if target == 'webui':
path = REPO_ROOT
elif target == 'agent':
path = _AGENT_DIR
else:
return {'ok': False, 'message': f'Unknown target: {target}'}
if path is None or not (path / '.git').exists():
return {'ok': False, 'message': 'Not a git repository'}
# Use the current branch's upstream for pull, matching the behaviour
# of _check_repo. Falls back to default branch if no upstream is set.
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
compare_ref = upstream
else:
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
# Fetch before attempting pull, so the remote ref is current.
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
if not fetch_ok:
return {
'ok': False,
'message': (
'Could not reach the remote repository. '
'Check your internet connection and try again.'
),
}
# Check for dirty working tree (ignore untracked files — git stash
# doesn't include them, so stashing on '??' alone leaves nothing to pop)
status_out, status_ok = _run_git(
['status', '--porcelain', '--untracked-files=no'], path
)
if not status_ok:
return {'ok': False, 'message': f'Failed to inspect repo status: {status_out[:200]}'}
# Fail early on unresolved merge conflicts
if any(line[:2] in {'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'}
for line in status_out.splitlines()):
return {
'ok': False,
'message': (
f'The local {target} repo has unresolved merge conflicts. '
'To reset to the latest remote version run: '
'git -C ' + str(path) + ' checkout . && '
'git -C ' + str(path) + ' pull --ff-only'
),
'conflict': True,
}
stashed = False
if status_out:
_, ok = _run_git(['stash'], path)
if not ok:
return {'ok': False, 'message': 'Failed to stash local changes'}
stashed = True
# Pull with ff-only (no merge commits).
# Split tracking refs like 'origin/main' into separate remote + branch
# arguments — git treats 'origin/main' as a repository name otherwise.
remote, branch = _split_remote_ref(compare_ref)
pull_args = ['pull', '--ff-only']
if remote:
pull_args.extend([remote, branch])
else:
pull_args.append(compare_ref)
pull_out, pull_ok = _run_git(pull_args, path, timeout=30)
if not pull_ok:
if stashed:
_run_git(['stash', 'pop'], path)
# Diagnose the most common failure modes and surface actionable messages.
pull_lower = pull_out.lower()
if 'not possible to fast-forward' in pull_lower or 'diverged' in pull_lower:
return {
'ok': False,
'message': (
f'The local {target} repo has commits that are not on the remote '
'branch, so a fast-forward update is not possible. '
'Run: git -C ' + str(path) + ' fetch origin && '
'git -C ' + str(path) + ' reset --hard ' + compare_ref
),
'diverged': True,
}
if 'does not track' in pull_lower or 'no tracking information' in pull_lower:
return {
'ok': False,
'message': (
f'The local {target} branch has no upstream tracking branch configured. '
'Run: git -C ' + str(path) + ' branch --set-upstream-to=' + compare_ref
),
}
# Generic fallback — include the raw git output for debugging.
detail = pull_out.strip()[:300] if pull_out.strip() else '(no output from git)'
return {'ok': False, 'message': f'Pull failed: {detail}'}
# Pop stash if we stashed
if stashed:
_, pop_ok = _run_git(['stash', 'pop'], path)
if not pop_ok:
return {
'ok': False,
'message': 'Updated but stash pop failed -- manual merge needed',
'stash_conflict': True,
}
# Invalidate cache
with _cache_lock:
_update_cache['checked_at'] = 0
# Schedule a self-restart so the updated code is loaded fresh. A plain
# git pull leaves stale Python modules in sys.modules — agent imports that
# reference new symbols (functions, classes) added in the update will fail
# on the next request with AttributeError / ImportError. os.execv() re-
# execs the same interpreter with the same argv, picking up the new code
# cleanly without requiring the user to restart manually.
#
# The 2 s delay gives the HTTP response time to flush to the client before
# the process replaces itself. The client already does
# setTimeout(() => location.reload(), 1500) on success, so the page reload
# and the restart land at roughly the same time.
_schedule_restart()
return {
'ok': True,
'message': f'{target} updated successfully',
'target': target,
'restart_scheduled': True,
}

View File

@@ -1,8 +1,10 @@
"""
Hermes Web UI -- File upload: multipart parser and upload handler.
"""
import mimetypes
import re as _re
import email.parser
import tempfile
from pathlib import Path
from api.config import MAX_UPLOAD_BYTES
@@ -11,7 +13,7 @@ from api.models import get_session
from api.workspace import safe_resolve_ws
def parse_multipart(rfile, content_type, content_length):
def parse_multipart(rfile, content_type, content_length) -> tuple:
import re as _re, email.parser as _ep
m = _re.search(r'boundary=([^;\s]+)', content_type)
if not m:
@@ -50,8 +52,15 @@ def parse_multipart(rfile, content_type, content_length):
return fields, files
def _sanitize_upload_name(filename: str) -> str:
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
if not safe_name or safe_name.strip('.') == '':
raise ValueError('Invalid filename')
return safe_name
def handle_upload(handler):
import re as _re, traceback as _tb
import traceback as _tb
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
@@ -69,10 +78,207 @@ def handle_upload(handler):
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
dest = workspace / safe_name
safe_name = _sanitize_upload_name(filename)
dest = safe_resolve_ws(workspace, safe_name)
dest.write_bytes(file_bytes)
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
except Exception as e:
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
return j(handler, {
'filename': safe_name,
'path': str(dest),
'size': dest.stat().st_size,
'mime': mime,
'is_image': mime.startswith('image/'),
})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
print('[webui] upload error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Upload failed'}, status=500)
# Maximum total extracted bytes — guards against zip/tar bombs.
# Set to 10x the upload limit; a legitimate archive rarely exceeds 3-4x.
_MAX_EXTRACTED_BYTES = 10 * 20 * 1024 * 1024 # 200 MB
def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
"""Extract a zip or tar archive into the workspace.
Returns a dict with ``extracted`` (int), ``files`` (list[str]).
Raises ValueError on zip-slip or unsupported format.
"""
import zipfile, tarfile, io, os, shutil
name = Path(filename).name
stem = Path(filename).stem # strip .zip / .tar.gz etc.
if name.lower().endswith(('.zip',)):
_mode = 'zip'
elif name.lower().endswith(('.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz')):
_mode = 'tar'
else:
raise ValueError(f'Unsupported archive format: {filename}')
# Determine destination directory — use archive stem as folder name
dest_dir = safe_resolve_ws(workspace, stem)
# Avoid overwriting existing files by appending a suffix
if dest_dir.exists():
import string, random
while dest_dir.exists():
suffix = ''.join(random.choices(string.digits, k=3))
dest_dir = dest_dir.with_name(stem + '_' + suffix)
dest_dir.mkdir(parents=True, exist_ok=True)
extracted_files = []
total_extracted = 0
try:
if _mode == 'zip':
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
for member in zf.infolist():
# Skip directories
if member.is_dir():
continue
# Zip-slip protection
member_path = (dest_dir / member.filename).resolve()
if not member_path.is_relative_to(dest_dir.resolve()):
raise ValueError(f'Zip-slip blocked: {member.filename}')
# Zip-bomb protection: track actual extracted bytes (not declared file_size)
if total_extracted > _MAX_EXTRACTED_BYTES:
raise ValueError(
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
f'Possible zip bomb.'
)
member_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(member_path, 'wb') as dst:
_chunk_size = 65536
while True:
chunk = src.read(_chunk_size)
if not chunk:
break
total_extracted += len(chunk)
if total_extracted > _MAX_EXTRACTED_BYTES:
raise ValueError(
f'Extraction too large (> '
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
f'Possible zip bomb.'
)
dst.write(chunk)
extracted_files.append(str(member_path.relative_to(workspace.resolve())))
elif _mode == 'tar':
with tarfile.open(fileobj=io.BytesIO(file_bytes)) as tf:
for member in tf.getmembers():
if not member.isfile():
continue
# Tar-slip protection
member_path = (dest_dir / member.name).resolve()
if not member_path.is_relative_to(dest_dir.resolve()):
raise ValueError(f'Tar-slip blocked: {member.name}')
# Tar-bomb protection: track actual extracted bytes (not declared size)
if total_extracted > _MAX_EXTRACTED_BYTES:
raise ValueError(
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
f'Possible zip bomb.'
)
member_path.parent.mkdir(parents=True, exist_ok=True)
src_obj = tf.extractfile(member)
if src_obj:
with src_obj as src, open(member_path, 'wb') as dst:
_chunk_size = 65536
while True:
chunk = src.read(_chunk_size)
if not chunk:
break
total_extracted += len(chunk)
if total_extracted > _MAX_EXTRACTED_BYTES:
raise ValueError(
f'Extraction too large (> '
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
f'Possible zip bomb.'
)
dst.write(chunk)
extracted_files.append(str(member_path.relative_to(workspace.resolve())))
except Exception:
# Clean up partially-extracted directory to avoid orphaned folders
try:
shutil.rmtree(dest_dir, ignore_errors=True)
except Exception:
pass
raise
return {'extracted': len(extracted_files), 'files': extracted_files, 'dest': str(dest_dir)}
def handle_upload_extract(handler):
"""Handle archive upload and extraction."""
import traceback as _tb
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
if content_length > MAX_UPLOAD_BYTES:
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
fields, files = parse_multipart(handler.rfile, content_type, content_length)
session_id = fields.get('session_id', '')
if 'file' not in files:
return j(handler, {'error': 'No file field in request'}, status=400)
filename, file_bytes = files['file']
if not filename:
return j(handler, {'error': 'No filename in upload'}, status=400)
try:
s = get_session(session_id)
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
result = extract_archive(file_bytes, filename, workspace)
return j(handler, {'ok': True, **result})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
print('[webui] upload extract error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Archive extraction failed'}, status=500)
def handle_transcribe(handler):
import traceback as _tb
temp_path = None
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
if content_length > MAX_UPLOAD_BYTES:
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
fields, files = parse_multipart(handler.rfile, content_type, content_length)
if 'file' not in files:
return j(handler, {'error': 'No file field in request'}, status=400)
filename, file_bytes = files['file']
if not filename:
return j(handler, {'error': 'No filename in upload'}, status=400)
safe_name = _sanitize_upload_name(filename)
suffix = Path(safe_name).suffix or '.webm'
with tempfile.NamedTemporaryFile(prefix='webui-stt-', suffix=suffix, delete=False) as tmp:
temp_path = tmp.name
tmp.write(file_bytes)
try:
from tools.transcription_tools import transcribe_audio
except ImportError:
return j(handler, {'error': 'Speech-to-text is unavailable on this server'}, status=503)
result = transcribe_audio(temp_path)
if not result.get('success'):
msg = str(result.get('error') or 'Transcription failed')
status = 503 if 'unavailable' in msg.lower() or 'not configured' in msg.lower() else 400
return j(handler, {'error': msg}, status=status)
transcript = str(result.get('transcript') or '').strip()
return j(handler, {'ok': True, 'transcript': transcript})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
print('[webui] transcribe error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Transcription failed'}, status=500)
finally:
if temp_path:
try:
Path(temp_path).unlink(missing_ok=True)
except Exception:
pass

View File

@@ -8,10 +8,14 @@ profile has its own workspace configuration. State files live at
paths are used as fallback when no profile module is available.
"""
import json
import logging
import os
import subprocess
import concurrent.futures
from pathlib import Path
logger = logging.getLogger(__name__)
from api.config import (
WORKSPACES_FILE as _GLOBAL_WS_FILE,
LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
@@ -37,7 +41,7 @@ def _profile_state_dir() -> Path:
d.mkdir(parents=True, exist_ok=True)
return d
except ImportError:
pass
logger.debug("Failed to import profiles module, using global state dir")
return _GLOBAL_WS_FILE.parent
@@ -80,7 +84,7 @@ def _profile_default_workspace() -> str:
if p.is_dir():
return str(p)
except (ImportError, Exception):
pass
logger.debug("Failed to load profile default workspace config")
return str(_BOOT_DEFAULT_WORKSPACE)
@@ -89,7 +93,6 @@ def _profile_default_workspace() -> str:
def _clean_workspace_list(workspaces: list) -> list:
"""Sanitize a workspace list:
- Remove entries whose paths no longer exist on disk.
- Remove entries that look like test artifacts (webui-mvp-test, test-workspace).
- Remove entries whose paths live inside another profile's directory
(e.g. ~/.hermes/profiles/X/... should not appear on a different profile).
- Rename any entry whose name is literally 'default' to 'Home' (avoids
@@ -102,18 +105,24 @@ def _clean_workspace_list(workspaces: list) -> list:
path = w.get('path', '')
name = w.get('name', '')
p = Path(path).resolve() if path else Path('/')
# Skip test artifacts
if 'test-workspace' in path or 'webui-mvp-test' in path:
continue
# Skip paths that no longer exist
if not p.is_dir():
continue
# Skip paths inside a named profile's directory (cross-profile leak)
# Skip paths inside a DIFFERENT profile's directory (cross-profile leak).
# Allow paths inside the CURRENT profile's own directory (e.g. test workspaces
# created under ~/.hermes/profiles/webui/webui-mvp-test/).
try:
p.relative_to(hermes_profiles)
continue # it IS under profiles/ — remove it
# p is under ~/.hermes/profiles/ — only skip if it's under a DIFFERENT profile
try:
from api.profiles import get_active_hermes_home
own_profile_dir = get_active_hermes_home().resolve()
p.relative_to(own_profile_dir)
# p is under our own profile dir — keep it
except (ValueError, Exception):
continue # under profiles/ but not our own — cross-profile leak, skip
except ValueError:
pass
pass # not under profiles/ at all — keep it
# Rename confusing 'default' label to 'Home'
if name.lower() == 'default':
name = 'Home'
@@ -156,10 +165,10 @@ def load_workspaces() -> list:
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
)
except Exception:
pass
logger.debug("Failed to persist cleaned workspace list")
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
except Exception:
pass
logger.debug("Failed to load workspaces from %s", ws_file)
# No profile-local file yet.
# For the DEFAULT profile: migrate from the legacy global file (one-time cleanup).
# For NAMED profiles: always start clean with just their own workspace.
@@ -176,7 +185,7 @@ def load_workspaces() -> list:
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
def save_workspaces(workspaces: list):
def save_workspaces(workspaces: list) -> None:
ws_file = _workspaces_file()
ws_file.parent.mkdir(parents=True, exist_ok=True)
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
@@ -190,7 +199,7 @@ def get_last_workspace() -> str:
if p and Path(p).is_dir():
return p
except Exception:
pass
logger.debug("Failed to read last workspace from %s", lw_file)
# Fallback: try global file
if _GLOBAL_LW_FILE.exists():
try:
@@ -198,44 +207,490 @@ def get_last_workspace() -> str:
if p and Path(p).is_dir():
return p
except Exception:
pass
logger.debug("Failed to read global last workspace")
return _profile_default_workspace()
def set_last_workspace(path: str):
def set_last_workspace(path: str) -> None:
try:
lw_file = _last_workspace_file()
lw_file.parent.mkdir(parents=True, exist_ok=True)
lw_file.write_text(str(path), encoding='utf-8')
except Exception:
logger.debug("Failed to set last workspace")
def _safe_resolve(p: Path) -> Path:
"""Path.resolve() that never raises — falls back to the input path on error."""
try:
return p.resolve()
except (OSError, RuntimeError):
return p
# Per-user temp directories that sit nominally under a "system" prefix but are
# actually user-writable scratch space. Workspaces registered here (e.g. by
# pytest's ``tmp_path_factory`` on macOS, which uses ``/var/folders/<hash>/T/``)
# must remain accepted even though their parent (``/var``) is blocked. These
# carve-outs apply to BOTH workspace registration and runtime file ops so a
# symlink target inside the carve-out is also reachable.
_USER_TMP_PREFIXES: tuple[Path, ...] = (
Path('/var/folders'), # macOS per-user tmp (literal form)
Path('/private/var/folders'), # macOS per-user tmp (resolved form)
Path('/var/tmp'), # Linux/macOS system-wide tmp (user-writable)
Path('/private/var/tmp'), # macOS resolved form
)
def _workspace_blocked_roots() -> tuple[Path, ...]:
"""System roots that must never be accepted as workspace candidates.
Returns both the literal path and its symlink-resolved canonical form,
deduped. This matters on macOS where ``/etc``, ``/var``, and ``/tmp``
are symlinks to ``/private/etc`` etc. Without the resolved forms,
callers that pass a ``.resolve()``-d candidate (every caller does)
would compare ``/private/etc`` against literal ``Path('/etc')`` and the
``relative_to`` check would miss — letting ``/etc`` through as a
registered workspace on macOS.
Carve-outs for legitimate user-tmp paths nominally under these roots
(e.g. ``/var/folders/.../T/`` on macOS) are handled by
:func:`_is_blocked_system_path`, not by exclusion from this list.
"""
_raw = (
# Linux / macOS
'/etc',
'/usr',
'/var',
'/bin',
'/sbin',
'/boot',
'/proc',
'/sys',
'/dev',
'/lib',
'/lib64',
'/opt/homebrew',
'/System',
'/Library',
)
_seen: set[Path] = set()
_out: list[Path] = []
for _p in _raw:
for _form in (Path(_p), _safe_resolve(Path(_p))):
if _form not in _seen:
_seen.add(_form)
_out.append(_form)
return tuple(_out)
def _is_blocked_system_path(candidate: Path) -> bool:
"""Return True if *candidate* falls under a blocked system root.
Honours :data:`_USER_TMP_PREFIXES` carve-outs so per-user tmp directories
nominally under ``/var`` (``/var/folders`` on macOS, ``/var/tmp`` on
Linux/macOS) remain valid workspace candidates and reachable file targets.
"""
for tmp in _USER_TMP_PREFIXES:
if _is_within(candidate, tmp):
return False
for blocked in _workspace_blocked_roots():
if _is_within(candidate, blocked):
return True
return False
def _workspace_blocked_resolved_subtrees() -> tuple[Path, ...]:
roots = list(_workspace_blocked_roots()) + [Path('/private/etc')]
resolved: list[Path] = []
for root in roots:
try:
p = root.expanduser().resolve()
except Exception:
p = root
if p not in resolved:
resolved.append(p)
return tuple(resolved)
def _workspace_blocked_exact_roots() -> tuple[Path, ...]:
roots = [Path('/'), Path('/private/var')]
for root in _workspace_blocked_roots():
try:
roots.append(root.expanduser().resolve())
except Exception:
roots.append(root)
unique: list[Path] = []
for root in roots:
if root not in unique:
unique.append(root)
return tuple(unique)
def _is_blocked_workspace_path(candidate: Path, raw_path: str | Path | None = None) -> bool:
"""Return True when candidate points at a known OS/system directory.
Compare both the original spelling and the resolved path. This closes the
macOS /etc -> /private/etc bypass without globally banning temporary pytest
paths under /private/var/folders.
"""
raw = None
if raw_path not in (None, ""):
try:
raw = Path(raw_path).expanduser()
except Exception:
raw = None
exact = _workspace_blocked_exact_roots()
if candidate in exact or (raw is not None and raw in _workspace_blocked_roots()):
return True
for tmp in _USER_TMP_PREFIXES:
if _is_within(candidate, tmp) or (raw is not None and _is_within(raw, tmp)):
return False
# Raw paths under literal roots (e.g. /etc/ssh, /var/db) are always blocked.
if raw is not None:
for blocked in _workspace_blocked_roots():
if _is_within(raw, blocked):
return True
# Resolved subtree checks catch symlink aliases such as /private/etc. The
# macOS temp root /private/var/folders is intentionally allowed for pytest
# and per-user temporary workspaces; other direct /private/var system data
# such as /private/var/db and /private/var/log remains blocked.
allowed_private_var = (Path('/private/var/folders'), Path('/private/var/tmp'))
for blocked in _workspace_blocked_resolved_subtrees():
if blocked == Path('/private/var'):
if candidate == blocked:
return True
if any(_is_within(candidate, allowed) for allowed in allowed_private_var):
continue
if _is_within(candidate, blocked):
return True
continue
if _is_within(candidate, blocked):
return True
return False
def _is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _trusted_workspace_roots() -> list[Path]:
roots: list[Path] = []
def add(candidate: str | Path | None) -> None:
if candidate in (None, ""):
return
try:
p = Path(candidate).expanduser().resolve()
except Exception:
return
if not p.exists() or not p.is_dir():
return
if _is_blocked_workspace_path(p, candidate):
return
if p not in roots:
roots.append(p)
add(Path.home())
add(_BOOT_DEFAULT_WORKSPACE)
for w in load_workspaces():
add(w.get("path"))
roots.sort(key=lambda p: len(str(p)))
return roots
def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
"""Return workspace path suggestions under trusted roots only.
Suggestions are limited to directories under one of:
- Path.home()
- the boot default workspace
- already-saved workspace roots
Arbitrary system prefixes return an empty list rather than an error so the
UI can safely autocomplete while the user types.
"""
roots = _trusted_workspace_roots()
if not roots:
return []
raw = (prefix or "").strip()
if not raw:
return [str(p) for p in roots[:limit]]
if raw.startswith("~"):
target = Path(raw).expanduser()
elif Path(raw).is_absolute():
target = Path(raw)
else:
target = Path.home() / raw
normalized = str(target)
normalized_lower = normalized.lower()
suggestions: list[str] = []
def add(path: Path) -> None:
value = str(path)
if value not in suggestions:
suggestions.append(value)
# If the user is typing a partial trusted root like /Users/xuef..., suggest
# the matching trusted roots without scanning arbitrary system parents.
for root in roots:
if str(root).lower().startswith(normalized_lower):
add(root)
in_root = [
root
for root in roots
if normalized == str(root) or normalized.startswith(str(root) + os.sep)
]
if not in_root:
return suggestions[:limit]
anchor_root = max(in_root, key=lambda p: len(str(p)))
ends_with_sep = raw.endswith(os.sep) or raw.endswith('/')
parent = target if ends_with_sep else target.parent
leaf = '' if ends_with_sep else target.name
show_hidden = leaf.startswith('.')
try:
parent_resolved = parent.expanduser().resolve()
except Exception:
return suggestions[:limit]
if not parent_resolved.exists() or not parent_resolved.is_dir():
return suggestions[:limit]
if not _is_within(parent_resolved, anchor_root):
return suggestions[:limit]
leaf_lower = leaf.lower()
try:
children = sorted(parent_resolved.iterdir(), key=lambda p: p.name.lower())
except OSError:
return suggestions[:limit]
for child in children:
if not child.is_dir():
continue
if child.name.startswith('.') and not show_hidden:
continue
if leaf_lower and not child.name.lower().startswith(leaf_lower):
continue
add(child.resolve())
if len(suggestions) >= limit:
break
return suggestions[:limit]
def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
"""Resolve and validate a workspace path.
A path is trusted if it satisfies at least one of:
(A) It is under the user's home directory (Path.home()).
Works cross-platform: ~/... on Linux/macOS, C:\\Users\\... on Windows.
(B) It is already in the profile's saved workspace list.
This covers self-hosted deployments where workspaces live outside home
(e.g. /data/projects, /opt/workspace) — once a workspace is saved by
an admin, it can be reused without re-validation.
Additionally enforced regardless of (A)/(B):
1. The path must exist.
2. The path must be a directory.
3. The path must not be a known system root (/etc, /usr, /var, /bin, /sbin,
/boot, /proc, /sys, /dev, /root on Linux/macOS; Windows system dirs).
This prevents even admin-saved workspaces from pointing at OS internals.
None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always
trusted (it was validated at server startup).
"""
if path in (None, ""):
return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
candidate = Path(path).expanduser().resolve()
if not candidate.exists():
raise ValueError(f"Path does not exist: {candidate}")
if not candidate.is_dir():
raise ValueError(f"Path is not a directory: {candidate}")
# (A) Trusted if under the user's home directory — cross-platform via Path.home()
# Must be checked before system roots to allow symlinks like /var/home.
_home = Path.home().resolve()
if _home != Path("/"):
try:
candidate.relative_to(_home)
return candidate
except ValueError:
pass
# Block known system roots and their children.
if _is_blocked_workspace_path(candidate, path):
raise ValueError(f"Path points to a system directory: {candidate}")
# (B) Trusted if already in the saved workspace list — covers non-home installs
try:
saved = load_workspaces()
saved_paths = {Path(w["path"]).resolve() for w in saved if w.get("path")}
if candidate in saved_paths:
return candidate
except Exception:
pass
# (C) Trusted if it is equal to or under the boot-time DEFAULT_WORKSPACE.
# In Docker deployments HERMES_WEBUI_DEFAULT_WORKSPACE is often set to a
# volume mount outside the user's home (e.g. /data/workspace). That path
# was already validated at server startup, so any sub-path of it is safe
# without requiring the user to add it to the workspace list manually.
try:
boot_default = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
candidate.relative_to(boot_default)
return candidate
except ValueError:
pass
raise ValueError(
f"Path is outside the user home directory, not in the saved workspace "
f"list, and not under the default workspace: {candidate}. "
f"Add it via Settings → Workspaces first."
)
def validate_workspace_to_add(path: str) -> Path:
"""Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace).
When a user explicitly adds a new workspace path, we trust their intent — they
have console or filesystem access to that path and are consciously registering it.
We only block: non-existent paths, non-directories, and known system roots.
The stricter ``resolve_trusted_workspace`` is used when *using* an existing workspace
(file reads/writes) to prevent path traversal after the list is built.
"""
candidate = Path(path).expanduser().resolve()
if not candidate.exists():
raise ValueError(f"Path does not exist: {candidate}")
if not candidate.is_dir():
raise ValueError(f"Path is not a directory: {candidate}")
# Home directory is always trusted regardless of where it lives on disk
# (e.g. /var/home/... on systemd-homed Fedora/RHEL).
_home = Path.home().resolve()
if _home != Path("/") and _is_within(candidate, _home):
return candidate
# Block known system roots and their immediate children.
if _is_blocked_workspace_path(candidate, path):
raise ValueError(f"Path points to a system directory: {candidate}")
return candidate
def safe_resolve_ws(root: Path, requested: str) -> Path:
"""Resolve a relative path inside a workspace root, raising ValueError on traversal."""
resolved = (root / requested).resolve()
resolved.relative_to(root.resolve())
"""Resolve a relative path inside a workspace root, raising ValueError on traversal.
Symlinks whose *unresolved* path is within the workspace root are allowed —
the user placed them there intentionally. Only raw ``..`` traversal outside
the root is blocked.
"""
import os
unresolved = root / requested
resolved = unresolved.resolve()
# Fast path: resolved path is inside root (covers most cases)
try:
resolved.relative_to(root.resolve())
return resolved
except ValueError:
pass
# Symlink path: normalize '..' (without following symlinks) and check
# os.path.normpath collapses '..' but does NOT follow symlinks.
norm = Path(os.path.normpath(str(unresolved)))
try:
norm.relative_to(root)
except ValueError:
raise ValueError(f"Path traversal blocked: {requested}")
# Symlink points outside workspace root — additionally block system directories.
# Even if the user placed the symlink intentionally, prevent reads from
# /etc, /proc, /sys, /dev and other blocked roots (LLM agents can call
# read_file_content via tool calls, not just human users).
if _is_blocked_system_path(resolved):
raise ValueError(f"Path traversal blocked (system dir): {requested}")
return resolved
def list_dir(workspace: Path, rel='.'):
def list_dir(workspace: Path, rel: str='.'):
target = safe_resolve_ws(workspace, rel)
if not target.is_dir():
raise FileNotFoundError(f"Not a directory: {rel}")
ws_resolved = workspace.resolve()
entries = []
for item in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
entries.append({
'name': item.name,
'path': str(item.relative_to(workspace)),
'type': 'dir' if item.is_dir() else 'file',
'size': item.stat().st_size if item.is_file() else None,
})
for item in sorted(target.iterdir(), key=lambda p: (not p.is_symlink(), p.is_file(), p.name.lower())):
if item.is_symlink():
# Resolve the symlink target and check if it stays within workspace
try:
link_target = item.resolve()
except OSError:
continue
# Cycle detection: skip if symlink points back to current dir,
# workspace root, or any ancestor of current dir.
# This must run REGARDLESS of whether target is inside workspace.
if (link_target == target.resolve() or link_target == target
or link_target == ws_resolved):
continue
try:
target.resolve().relative_to(link_target)
# target is under link_target — link_target is an ancestor → cycle
continue
except ValueError:
pass
# Block symlinks that resolve to system directories.
if _is_blocked_system_path(link_target):
continue
is_dir = link_target.is_dir()
# Keep the display path relative to workspace (don't follow the link)
display_path = str(Path(item.name))
if rel and rel != '.':
display_path = rel + '/' + display_path
entry = {
'name': item.name,
'path': display_path,
'type': 'symlink',
'target': str(link_target),
'is_dir': is_dir,
}
if not is_dir:
try:
entry['size'] = link_target.stat().st_size
except OSError:
entry['size'] = None
entries.append(entry)
else:
# Use rel-based path so entries under symlink targets (outside
# the workspace root) still get a valid workspace-relative path.
entry_path = item.name
if rel and rel != '.':
entry_path = rel + '/' + item.name
entries.append({
'name': item.name,
'path': entry_path,
'type': 'dir' if item.is_dir() else 'file',
'size': item.stat().st_size if item.is_file() else None,
})
if len(entries) >= 200:
break
return entries
def read_file_content(workspace: Path, rel: str):
def read_file_content(workspace: Path, rel: str) -> dict:
target = safe_resolve_ws(workspace, rel)
if not target.is_file():
raise FileNotFoundError(f"Not a file: {rel}")
@@ -267,22 +722,35 @@ def git_info_for_workspace(workspace: Path) -> dict:
branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], workspace)
if branch is None:
return None
# Status counts
status_out = _run_git(['status', '--porcelain'], workspace) or ''
lines = [l for l in status_out.splitlines() if l]
# git status --porcelain: XY format where X=index, Y=worktree
modified = sum(1 for l in lines if len(l) >= 2 and (l[0] in 'MAR' or l[1] in 'MAR'))
untracked = sum(1 for l in lines if l.startswith('??'))
dirty = len(lines)
# Ahead/behind
ahead = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace)
behind = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace)
# Run the remaining git commands in parallel via threads — they are
# independent subprocess calls and together can take 50-200ms when run
# serially. Threading is safe here because each call blocks only on the
# subprocess pipe, not on the GIL.
def _ahead():
r = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace)
return int(r) if r and r.isdigit() else 0
def _behind():
r = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace)
return int(r) if r and r.isdigit() else 0
def _status():
out = _run_git(['status', '--porcelain'], workspace) or ''
lines = [l for l in out.splitlines() if l]
modified = sum(1 for l in lines if len(l) >= 2 and (l[0] in 'MAR' or l[1] in 'MAR'))
untracked = sum(1 for l in lines if l.startswith('??'))
return len(lines), modified, untracked
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
f_status = pool.submit(_status)
f_ahead = pool.submit(_ahead)
f_behind = pool.submit(_behind)
dirty, modified, untracked = f_status.result()
ahead = f_ahead.result()
behind = f_behind.result()
return {
'branch': branch,
'dirty': dirty,
'modified': modified,
'untracked': untracked,
'ahead': int(ahead) if ahead and ahead.isdigit() else 0,
'behind': int(behind) if behind and behind.isdigit() else 0,
'ahead': ahead,
'behind': behind,
'is_git': True,
}

276
bootstrap.py Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""One-shot bootstrap launcher for Hermes Web UI."""
from __future__ import annotations
import argparse
import os
import platform
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import venv
import webbrowser
from pathlib import Path
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
REPO_ROOT = Path(__file__).resolve().parent
def _load_repo_dotenv() -> None:
"""Load REPO_ROOT/.env into os.environ.
Mirrors what start.sh does via ``set -a; source .env`` so that running
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
Variables are set unconditionally (matching shell source semantics), so a
value in .env overrides one already present in the shell environment.
To keep a CLI-supplied value, unset it from .env or launch via start.sh
and override there.
Only loads the webui repo .env — not ~/.hermes/.env, which the server
loads independently at startup for provider credentials.
Note: does not handle the ``export FOO=bar`` prefix — strip ``export``
from .env values if copy-pasting from a shell rc file.
"""
env_path = REPO_ROOT / ".env"
if not env_path.exists():
return
try:
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
# Strip optional 'export' prefix (common in copy-pasted shell snippets)
if k.startswith("export "):
k = k[7:].strip()
v = v.strip().strip('"').strip("'")
if k:
os.environ[k] = v
except Exception as exc:
import sys as _sys
print(f"[bootstrap] Warning: could not load .env — {exc}", file=_sys.stderr)
# Side effect: loads REPO_ROOT/.env into os.environ on import.
# Must run before DEFAULT_HOST / DEFAULT_PORT so os.getenv() picks up
# values from .env even when bootstrap.py is invoked directly (not via start.sh).
_load_repo_dotenv()
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when
# the environment is already fully configured (e.g. managed hosting).
def info(msg: str) -> None:
print(f"[bootstrap] {msg}", flush=True)
def is_wsl() -> bool:
if platform.system() != "Linux":
return False
release = platform.release().lower()
return (
"microsoft" in release or "wsl" in release or bool(os.getenv("WSL_DISTRO_NAME"))
)
def ensure_supported_platform() -> None:
if platform.system() == "Windows" and not is_wsl():
raise RuntimeError(
"Native Windows is not supported for this bootstrap yet. "
"Please run it from Linux, macOS, or inside WSL2."
)
def discover_agent_dir() -> Path | None:
home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
candidates = [
os.getenv("HERMES_WEBUI_AGENT_DIR", ""),
str(home / "hermes-agent"),
str(REPO_ROOT.parent / "hermes-agent"),
str(Path.home() / ".hermes" / "hermes-agent"),
str(Path.home() / "hermes-agent"),
]
for raw in candidates:
if not raw:
continue
candidate = Path(raw).expanduser().resolve()
if candidate.exists() and (candidate / "run_agent.py").exists():
return candidate
return None
def discover_launcher_python(agent_dir: Path | None) -> str:
env_python = os.getenv("HERMES_WEBUI_PYTHON")
if env_python:
return env_python
if agent_dir:
for rel in ("venv/bin/python", "venv/Scripts/python.exe", ".venv/bin/python", ".venv/Scripts/python.exe"):
candidate = agent_dir / rel
if candidate.exists():
return str(candidate)
for rel in (".venv/bin/python", ".venv/Scripts/python.exe"):
candidate = REPO_ROOT / rel
if candidate.exists():
return str(candidate)
return shutil.which("python3") or shutil.which("python") or sys.executable
def ensure_python_has_webui_deps(python_exe: str) -> str:
check = subprocess.run(
[python_exe, "-c", "import yaml"],
capture_output=True,
text=True,
)
if check.returncode == 0:
return python_exe
venv_dir = REPO_ROOT / ".venv"
venv_python = venv_dir / (
"Scripts/python.exe" if platform.system() == "Windows" else "bin/python"
)
if not venv_python.exists():
info(f"Creating local virtualenv at {venv_dir}")
venv.EnvBuilder(with_pip=True).create(venv_dir)
info("Installing WebUI dependencies into local virtualenv")
subprocess.run(
[str(venv_python), "-m", "pip", "install", "--quiet", "--upgrade", "pip"],
check=True,
)
subprocess.run(
[
str(venv_python),
"-m",
"pip",
"install",
"--quiet",
"-r",
str(REPO_ROOT / "requirements.txt"),
],
check=True,
)
return str(venv_python)
def hermes_command_exists() -> bool:
return shutil.which("hermes") is not None
def install_hermes_agent() -> None:
info(f"Hermes Agent not found. Attempting install via {INSTALLER_URL}")
subprocess.run(
["/bin/bash", "-lc", f"curl -fsSL {INSTALLER_URL} | bash"], check=True
)
def wait_for_health(url: str, timeout: float = 25.0) -> bool:
deadline = time.time() + timeout
# Validate URL scheme to prevent file:// and other dangerous schemes
if not url.startswith(("http://", "https://")):
raise ValueError(f"Invalid health check URL: {url}")
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as response: # nosec B310
if b'"status": "ok"' in response.read():
return True
except Exception:
time.sleep(0.4)
return False
def open_browser(url: str) -> None:
try:
webbrowser.open(url)
except Exception as exc:
info(f"Could not open browser automatically: {exc}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Bootstrap Hermes Web UI onboarding.")
parser.add_argument("port", nargs="?", type=int, default=DEFAULT_PORT)
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument(
"--no-browser",
action="store_true",
help="Do not open a browser tab automatically.",
)
parser.add_argument(
"--skip-agent-install",
action="store_true",
help="Fail instead of attempting the official Hermes installer.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
ensure_supported_platform()
agent_dir = discover_agent_dir()
if not agent_dir and not hermes_command_exists():
if args.skip_agent_install:
raise RuntimeError(
"Hermes Agent was not found and auto-install was disabled."
)
install_hermes_agent()
agent_dir = discover_agent_dir()
python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir))
state_dir = Path(
os.getenv("HERMES_WEBUI_STATE_DIR", str(Path.home() / ".hermes" / "webui"))
).expanduser()
state_dir.mkdir(parents=True, exist_ok=True)
log_path = state_dir / f"bootstrap-{args.port}.log"
env = os.environ.copy()
env["HERMES_WEBUI_HOST"] = args.host
env["HERMES_WEBUI_PORT"] = str(args.port)
env.setdefault("HERMES_WEBUI_STATE_DIR", str(state_dir))
if agent_dir:
env["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir)
info(f"Starting Hermes Web UI on http://{args.host}:{args.port}")
with log_path.open("ab") as log_file:
proc = subprocess.Popen(
[python_exe, str(REPO_ROOT / "server.py")],
cwd=str(agent_dir or REPO_ROOT),
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
)
health_url = f"http://{args.host}:{args.port}/health"
if not wait_for_health(health_url):
raise RuntimeError(
f"Web UI did not become healthy at {health_url}. "
f"Check the log at {log_path}. Server PID: {proc.pid}"
)
app_url = (
f"http://localhost:{args.port}"
if args.host in ("127.0.0.1", "localhost")
else f"http://{args.host}:{args.port}"
)
info(f"Web UI is ready: {app_url}")
info(f"Log file: {log_path}")
if not args.no_browser:
open_browser(app_url)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"[bootstrap] ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)

View File

@@ -0,0 +1,121 @@
# Three-container Docker Compose: Hermes Agent + Dashboard + WebUI
#
# This extends the two-container setup with the Hermes Dashboard for
# monitoring agent activity, sessions, and resource usage.
#
# Usage:
# docker compose -f docker-compose.three-container.yml up -d
#
# Services:
# hermes-agent — gateway API on port 8642 (CLI, Telegram, cron, tools)
# hermes-dashboard — monitoring dashboard on port 9119
# hermes-webui — browser chat interface on port 8787
#
# All three share the same hermes-home volume so config, sessions,
# skills, and memory are consistent across all surfaces.
#
# NOTE ON VOLUMES:
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
# see the two-container compose file for a bind-mount example.
# When using bind mounts, ALL containers must mount the same host path.
services:
hermes-agent:
image: nousresearch/hermes-agent:latest
container_name: hermes-agent
command: gateway run
ports:
- "127.0.0.1:8642:8642"
volumes:
# Persist config, state, sessions, skills, memory across restarts
- hermes-home:/home/hermes/.hermes
# Expose agent source so the WebUI can install dependencies from it
- hermes-agent-src:/opt/hermes
environment:
- HERMES_HOME=/home/hermes/.hermes
- HERMES_UID=${HERMES_UID:-10000}
- HERMES_GID=${HERMES_GID:-10000}
restart: unless-stopped
deploy:
resources:
limits:
memory: 4G
cpus: "2.0"
networks:
- hermes-net
hermes-dashboard:
image: nousresearch/hermes-agent:latest
container_name: hermes-dashboard
command: dashboard --host 0.0.0.0 --insecure
ports:
- "127.0.0.1:9119:9119"
volumes:
- hermes-home:/home/hermes/.hermes
environment:
- HERMES_HOME=/home/hermes/.hermes
- HERMES_UID=${HERMES_UID:-10000}
- HERMES_GID=${HERMES_GID:-10000}
# Dashboard connects to the gateway for health/session data
- GATEWAY_HEALTH_URL=http://hermes-agent:8642
depends_on:
- hermes-agent
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
networks:
- hermes-net
hermes-webui:
image: ghcr.io/nesquena/hermes-webui:latest
container_name: hermes-webui
depends_on:
- hermes-agent
ports:
# Expose on localhost only. Remove 127.0.0.1: to expose on all interfaces
# (set HERMES_WEBUI_PASSWORD if doing so).
- "127.0.0.1:8787:8787"
volumes:
# Same hermes home as the agent — shares config, sessions, state
- hermes-home:/home/hermeswebui/.hermes
# Agent source mounted where docker_init.bash expects it.
# At startup the init script runs:
# uv pip install /home/hermeswebui/.hermes/hermes-agent
# which installs the agent and all its Python dependencies.
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
# Workspace directory — browse and edit files from the WebUI.
# Adapt the host path to your project directory.
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
environment:
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
# Match your host user's UID/GID for correct file permissions.
# Run `id -u` and `id -g` to find your values.
# On macOS, UIDs start at 501 (not 1000) — set these in a .env file:
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
- WANTED_UID=${UID:-1000}
- WANTED_GID=${GID:-1000}
# NOTE: When using bind-mount volumes shared across containers, ALL containers
# that write to the same host directory must run as the same UID/GID.
# If hermes-agent initialises the state dir as root (UID 0), hermes-webui
# will get a PermissionError accessing those paths — including a crash on every
# HTTP request if the auth signing-key file is unreadable. Either set WANTED_UID
# to match the agent container's UID, or use a named Docker volume (preferred).
# Optional: set a password for remote access
# - HERMES_WEBUI_PASSWORD=your-secret-password
restart: unless-stopped
networks:
- hermes-net
networks:
hermes-net:
driver: bridge
volumes:
hermes-home:
hermes-agent-src:

View File

@@ -0,0 +1,95 @@
# Two-container Docker Compose: Hermes Agent + Hermes WebUI
#
# This runs the agent and web UI in separate containers connected via
# shared volumes. The WebUI installs the agent's Python dependencies
# at startup from the shared agent source volume.
#
# Usage:
# docker compose -f docker-compose.two-container.yml up -d
#
# The agent container runs the gateway (CLI, Telegram, cron, etc.).
# The WebUI container serves the browser interface on port 8787.
# Both share ~/.hermes for config, sessions, and state.
#
# NOTE ON VOLUMES:
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
# replace the named volumes at the bottom. Example for hermes-agent-src:
#
# hermes-agent-src:
# driver: local
# driver_opts:
# type: none
# o: bind
# device: /opt/hermes-agent
#
# When using bind mounts, BOTH containers must mount the same host path.
# The agent exposes source at /opt/hermes, the WebUI reads it from
# /home/hermeswebui/.hermes/hermes-agent — as long as both point to the
# same host directory, the paths align correctly.
services:
hermes-agent:
image: nousresearch/hermes-agent:latest
container_name: hermes-agent
command: gateway run
ports:
# Gateway API — exposed on localhost only.
# Other containers on hermes-net reach it via http://hermes-agent:8642.
# Remove 127.0.0.1: to expose on the host network (e.g. for remote clients).
- "127.0.0.1:8642:8642"
volumes:
# Persist config, state, sessions, skills, memory across restarts
- hermes-home:/home/hermes/.hermes
# Expose agent source so the WebUI can install dependencies from it
- hermes-agent-src:/opt/hermes
environment:
- HERMES_HOME=/home/hermes/.hermes
restart: unless-stopped
networks:
- hermes-net
hermes-webui:
image: ghcr.io/nesquena/hermes-webui:latest
container_name: hermes-webui
depends_on:
- hermes-agent
ports:
- "127.0.0.1:8787:8787"
volumes:
# Same hermes home as the agent — shares config, sessions, state
- hermes-home:/home/hermeswebui/.hermes
# Agent source mounted where docker_init.bash expects it.
# At startup the init script runs:
# uv pip install /home/hermeswebui/.hermes/hermes-agent
# which installs the agent and all its Python dependencies.
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
# Workspace directory — browse and edit files from the WebUI.
# Adapt the host path to your project directory.
# Override with: HERMES_WORKSPACE=/your/path docker compose up
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
environment:
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
# Match your host user's UID/GID for correct file permissions.
# In two-container setups the WebUI auto-detects UID/GID from the shared
# hermes-home volume, but you can override explicitly if needed (#668):
# Run `id -u` and `id -g` to find your values.
# On macOS, UIDs start at 501 — set these in a .env file:
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
- WANTED_UID=${UID:-1000}
- WANTED_GID=${GID:-1000}
# Optional: set a password for remote access
# - HERMES_WEBUI_PASSWORD=***
restart: unless-stopped
networks:
- hermes-net
networks:
hermes-net:
driver: bridge
volumes:
hermes-home:
hermes-agent-src:

View File

@@ -4,19 +4,34 @@ services:
hermes-webui:
build: .
ports:
# select only one; use 127.0.0.1 version to expose to localhost only
- "127.0.0.1:8787:8787"
# - "8787:8787"
volumes:
# Persist session data, settings, and projects across restarts
- hermes-data:/data
# Mount hermes home for agent features and profile management
- ${HERMES_HOME:-${HOME}/.hermes}:/root/.hermes
# Mount your Hermes home directory into the container.
# The default (${HOME}/.hermes) works on both macOS (/Users/<you>/.hermes)
# and Linux (/home/<you>/.hermes) — no change needed for standard installs.
# Only set HERMES_HOME explicitly if your .hermes lives somewhere non-standard.
# macOS note: set UID and GID below to match your user ID (run `id -u` and `id -g`).
- ${HERMES_HOME:-${HOME}/.hermes}:/home/hermeswebui/.hermes
# Your workspace directory shown on first launch (adapt if yours is different, the container will use the mounted /workspace)
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
environment:
# Set to your host user ID: run `id -u` and `id -g` to find them.
# On macOS, UIDs start at 501 (not 1000), so set UID and GID in a .env file:
# echo "UID=$(id -u)" >> .env
# echo "GID=$(id -g)" >> .env
# Without this, the container may not be able to read your mounted files.
- WANTED_UID=${UID:-1000}
- WANTED_GID=${GID:-1000}
# Required: bind address and port
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
- HERMES_WEBUI_STATE_DIR=/data
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui)
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
# Default workspace directory shown on first launch
# - HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace
# Optional: set a password for remote access
# - HERMES_WEBUI_PASSWORD=your-secret-password
restart: unless-stopped
volumes:
hermes-data:

345
docker_init.bash Normal file
View File

@@ -0,0 +1,345 @@
#!/bin/bash
set -e
error_exit() {
echo -n "!! ERROR: "
echo $*
echo "!! Exiting script (ID: $$)"
exit 1
}
ok_exit() {
echo $*
echo "++ Exiting script (ID: $$)"
exit 0
}
## Environment variables loaded when passing environment variables from user to user
# Ignore list: variables to ignore when loading environment variables from user to user
export ENV_IGNORELIST="HOME PWD USER SHLVL TERM OLDPWD SHELL _ SUDO_COMMAND HOSTNAME LOGNAME MAIL SUDO_GID SUDO_UID SUDO_USER CHECK_NV_CUDNN_VERSION VIRTUAL_ENV VIRTUAL_ENV_PROMPT ENV_IGNORELIST ENV_OBFUSCATE_PART"
# Obfuscate part: part of the key to obfuscate when loading environment variables from user to user, ex: HF_TOKEN, ...
export ENV_OBFUSCATE_PART="TOKEN API KEY"
# Check for ENV_IGNORELIST and ENV_OBFUSCATE_PART
if [ -z "${ENV_IGNORELIST+x}" ]; then error_exit "ENV_IGNORELIST not set"; fi
if [ -z "${ENV_OBFUSCATE_PART+x}" ]; then error_exit "ENV_OBFUSCATE_PART not set"; fi
whoami=`whoami`
script_dir=$(dirname $0)
script_name=$(basename $0)
echo ""; echo ""
echo "======================================"
echo "=================== Starting script (ID: $$)"
echo "== Running ${script_name} in ${script_dir} as ${whoami}"
script_fullname=$0
echo " - script_fullname: ${script_fullname}"
ignore_value="VALUE_TO_IGNORE"
# everyone can read our files by default
umask 0022
# Write a world-writeable file (preferably inside /tmp -- ie within the container)
write_worldtmpfile() {
tmpfile=$1
if [ -z "${tmpfile}" ]; then error_exit "write_worldfile: missing argument"; fi
if [ -f $tmpfile ]; then rm -f $tmpfile; fi
echo -n $2 > ${tmpfile}
chmod 777 ${tmpfile}
}
itdir=/tmp/hermeswebui_init
if [ ! -d $itdir ]; then mkdir $itdir; chmod 777 $itdir; fi
if [ ! -d $itdir ]; then error_exit "Failed to create $itdir"; fi
# Set user and group id
# logic: if not set and file exists, use file value, else use default. Create file for persistence when the container is re-run
# reasoning: needed when using docker compose as the file will exist in the stopped container, and changing the value from environment variables or configuration file must be propagated from hermeswebuitoo to hermeswebuitoo transition (those values are the only ones loaded before the environment variables dump file are loaded)
it=$itdir/hermeswebui_user_uid
if [ -z "${WANTED_UID+x}" ]; then
if [ -f $it ]; then WANTED_UID=$(cat $it); fi
fi
# Auto-detect from mounted volumes if still unset (#569, #668).
# On macOS, host UIDs start at 501. Using the wrong UID means the container
# user cannot read the bind-mounted files, making the workspace appear empty.
# In two-container setups (hermes-agent + hermes-webui), the shared hermes-home
# volume may be owned by the agent container's UID — detect from there first.
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
# Priority 1: hermes-home shared volume — covers two-container Zeabur/Compose setups (#668)
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
if [ -d "$_probe_dir" ]; then
_detected_uid=$(stat -c '%u' "$_probe_dir" 2>/dev/null || echo "")
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
echo "-- Auto-detected UID: $_detected_uid (from $_probe_dir)"
WANTED_UID=$_detected_uid
break
fi
fi
done
fi
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
# Priority 2: /workspace bind-mount — the standard single-container mount point
if [ -d "/workspace" ]; then
_detected_uid=$(stat -c '%u' "/workspace" 2>/dev/null || echo "")
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
echo "-- Auto-detected workspace UID: $_detected_uid (from /workspace)"
WANTED_UID=$_detected_uid
fi
fi
fi
WANTED_UID=${WANTED_UID:-1024}
write_worldtmpfile $it "$WANTED_UID"
echo "-- WANTED_UID: \"${WANTED_UID}\""
it=$itdir/hermeswebui_user_gid
if [ -z "${WANTED_GID+x}" ]; then
if [ -f $it ]; then WANTED_GID=$(cat $it); fi
fi
# Auto-detect GID from mounted volumes to match (#569, #668)
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
# Priority 1: hermes-home shared volume
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
if [ -d "$_probe_dir" ]; then
_detected_gid=$(stat -c '%g' "$_probe_dir" 2>/dev/null || echo "")
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
echo "-- Auto-detected GID: $_detected_gid (from $_probe_dir)"
WANTED_GID=$_detected_gid
break
fi
fi
done
fi
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
# Priority 2: /workspace bind-mount
if [ -d "/workspace" ]; then
_detected_gid=$(stat -c '%g' "/workspace" 2>/dev/null || echo "")
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
echo "-- Auto-detected workspace GID: $_detected_gid (from /workspace)"
WANTED_GID=$_detected_gid
fi
fi
fi
WANTED_GID=${WANTED_GID:-1024}
write_worldtmpfile $it "$WANTED_GID"
echo "-- WANTED_GID: \"${WANTED_GID}\""
echo "== Most Environment variables set"
# Check user id and group id
new_gid=`id -g`
new_uid=`id -u`
echo "== user ($whoami)"
echo " uid: $new_uid / WANTED_UID: $WANTED_UID"
echo " gid: $new_gid / WANTED_GID: $WANTED_GID"
save_env() {
tosave=$1
echo "-- Saving environment variables to $tosave"
env | sort > "$tosave"
}
load_env() {
tocheck=$1
overwrite_if_different=$2
ignore_list="${ENV_IGNORELIST}"
obfuscate_part="${ENV_OBFUSCATE_PART}"
if [ -f "$tocheck" ]; then
echo "-- Loading environment variables from $tocheck (overwrite existing: $overwrite_if_different) (ignorelist: $ignore_list) (obfuscate: $obfuscate_part)"
while IFS='=' read -r key value; do
doit=false
# checking if the key is in the ignorelist
for i in $ignore_list; do
if [[ "A$key" == "A$i" ]]; then doit=ignore; break; fi
done
if [[ "A$doit" == "Aignore" ]]; then continue; fi
rvalue=$value
# checking if part of the key is in the obfuscate list
doobs=false
for i in $obfuscate_part; do
if [[ "A$key" == *"$i"* ]]; then doobs=obfuscate; break; fi
done
if [[ "A$doobs" == "Aobfuscate" ]]; then rvalue="**OBFUSCATED**"; fi
if [ -z "${!key}" ]; then
echo " ++ Setting environment variable $key [$rvalue]"
doit=true
elif [ "A$overwrite_if_different" == "Atrue" ]; then
cvalue="${!key}"
if [[ "A${doobs}" == "Aobfuscate" ]]; then cvalue="**OBFUSCATED**"; fi
if [[ "A${!key}" != "A${value}" ]]; then
echo " @@ Overwriting environment variable $key [$cvalue] -> [$rvalue]"
doit=true
else
echo " == Environment variable $key [$rvalue] already set and value is unchanged"
fi
fi
if [[ "A$doit" == "Atrue" ]]; then
export "$key=$value"
fi
done < "$tocheck"
fi
}
# hermeswebuitoo is a specfiic user not existing by default on ubuntu, we can check its whomai
if [ "A${whoami}" == "Ahermeswebuitoo" ]; then
echo "-- Running as hermeswebuitoo, will switch hermeswebui to the desired UID/GID"
# The script is started as hermeswebuitoo -- UID/GID 1025/1025
# We are altering the UID/GID of the hermeswebui user to the desired ones and restarting as that user
# using usermod for the already create hermeswebui user, knowing it is not already in use
# per usermod manual: "You must make certain that the named user is not executing any processes when this command is being executed"
sudo groupmod -o -g ${WANTED_GID} hermeswebui || error_exit "Failed to set GID of hermeswebui user"
sudo usermod -o -u ${WANTED_UID} hermeswebui || error_exit "Failed to set UID of hermeswebui user"
sudo chown -R ${WANTED_UID}:${WANTED_GID} /home/hermeswebui || error_exit "Failed to set owner of /home/hermeswebui"
save_env /tmp/hermeswebuitoo_env.txt
# restart the script as hermeswebui set with the correct UID/GID this time
echo "-- Restarting as hermeswebui user with UID ${WANTED_UID} GID ${WANTED_GID}"
sudo su hermeswebui $script_fullname || error_exit "subscript failed"
ok_exit "Clean exit"
fi
# If we are here, the script is started as another user than hermeswebuitoo
# because the whoami value for the hermeswebui user can be any existing user, we can not check against it
# instead we check if the UID/GID are the expected ones
if [ "$WANTED_GID" != "$new_gid" ]; then error_exit "hermeswebui MUST be running as UID ${WANTED_UID} GID ${WANTED_GID}, current UID ${new_uid} GID ${new_gid}"; fi
if [ "$WANTED_UID" != "$new_uid" ]; then error_exit "hermeswebui MUST be running as UID ${WANTED_UID} GID ${WANTED_GID}, current UID ${new_uid} GID ${new_gid}"; fi
########## 'hermeswebui' specific section below
# We are therefore running as hermeswebui
echo ""; echo "== Running as hermeswebui"
# Load environment variables one by one if they do not exist from /tmp/hermeswebuitoo_env.txt
it=/tmp/hermeswebuitoo_env.txt
if [ -f $it ]; then
echo "-- Loading not already set environment variables from $it"
load_env $it true
fi
##
echo ""; echo "-- Making sure /app is owned by the hermeswebui user to avoid permission issues when running the server "
sudo mkdir -p /app || error_exit "Failed to create /app directory"
sudo chown hermeswebui:hermeswebui /app || error_exit "Failed to set owner of /app to hermeswebui user"
sudo rsync -av --chown=hermeswebui:hermeswebui /apptoo/ /app/ || error_exit "Failed to sync /apptoo to /app with correct ownership"
it=/app/.testfile; touch $it || error_exit "Failed to verify /app directory"
rm -f $it || error_exit "Failed to delete test file in /app"
######## Environment variables (consume AFTER the load_env)
echo ""; echo "== Checking required environment variables for hermes-webui"
echo ""; echo "-- HERMES_WEBUI_VERSION: Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)"
if [ -z "${HERMES_WEBUI_STATE_DIR+x}" ]; then error_exit "HERMES_WEBUI_STATE_DIR not set"; fi;
echo "-- HERMES_WEBUI_STATE_DIR: $HERMES_WEBUI_STATE_DIR"
if [ ! -d "$HERMES_WEBUI_STATE_DIR" ]; then mkdir -p $HERMES_WEBUI_STATE_DIR || error_exit "Failed to create state directory at $HERMES_WEBUI_STATE_DIR"; fi
if [ ! -d "$HERMES_WEBUI_STATE_DIR" ]; then error_exit "HERMES_WEBUI_STATE_DIR directory does not exist at $HERMES_WEBUI_STATE_DIR"; fi
it="$HERMES_WEBUI_STATE_DIR/.testfile"; touch $it || error_exit "Failed to verify state directory at $HERMES_WEBUI_STATE_DIR"
rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_STATE_DIR"
echo ""; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: Default workspace directory shown on first launch"
if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then echo "HERMES_WEBUI_DEFAULT_WORKSPACE not set, setting to /workspace"; export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi;
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: $HERMES_WEBUI_DEFAULT_WORKSPACE"
# Use sudo for mkdir — Docker may auto-create bind-mount directories as root (#357).
# Skip mkdir if the directory already exists (e.g. a read-only mount — #670).
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
fi
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
# Only chown and write-test if the workspace is writable. Read-only bind-mounts
# (:ro) are valid — the workspace is used for browsing, not writing by the server.
if [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || echo "!! WARNING: Could not chown $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it && rm -f $it || echo "!! WARNING: Could not write to $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
else
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE is read-only — skipping chown/write check (read-only workspace is supported)"
fi
echo ""; echo "==================="
echo ""; echo "== Installing uv and creating a new virtual environment for hermes-webui"
export PATH="/home/hermeswebui/.local/bin/:$PATH"
if command -v uv &>/dev/null; then
echo "-- uv already installed ($(uv --version)), skipping download"
else
echo "-- uv not found, downloading..."
curl -LsSf https://astral.sh/uv/install.sh | sh || error_exit "Failed to install uv — check network connectivity"
fi
export UV_PROJECT_ENVIRONMENT=venv
export UV_CACHE_DIR=/uv_cache
sudo mkdir -p ${UV_CACHE_DIR} || error_exit "Failed to create /uv_cache directory"
sudo chown hermeswebui:hermeswebui ${UV_CACHE_DIR} || error_exit "Failed to set owner of ${UV_CACHE_DIR} to hermeswebui user"
cd /app
if [ -f /app/venv/bin/python3 ]; then
echo ""; echo "== Existing virtual environment found — reusing (fast restart)"
else
echo ""; echo "== Creating new virtual environment"
uv venv venv
fi
export VIRTUAL_ENV=/app/venv
test -d /app/venv
test -f /app/venv/bin/activate
echo "";echo "== Activating hermes webui's virtual environment"
source /app/venv/bin/activate || error_exit "Failed to activate hermeswebui virtual environment"
test -x /app/venv/bin/python3
ensure_hindsight_client_docker_dependency() {
# Keep this outside the .deps_installed fast-restart guard so existing
# two-container Docker venvs self-heal after this dependency was added.
_hindsight_client_requirement="hindsight-client>=0.4.22"
echo ""; echo "== Checking Hindsight memory provider dependency"
if uv pip show hindsight-client >/dev/null 2>&1; then
echo "-- hindsight-client already installed"
else
echo "-- Installing ${_hindsight_client_requirement} for Hindsight memory provider support"
uv pip install "${_hindsight_client_requirement}" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hindsight-client"
fi
}
if [ -f /app/venv/.deps_installed ]; then
echo ""; echo "== Dependencies already installed — skipping (fast restart)"
else
echo ""; echo "== Installing hermes-webui dependencies"
uv pip install -r requirements.txt --trusted-host pypi.org --trusted-host files.pythonhosted.org
uv pip install -U pip setuptools --trusted-host pypi.org --trusted-host files.pythonhosted.org
test -x /app/venv/bin/pip
echo ""; echo "== Adding hermes-agent's pyproject.toml base dependencies to the virtual environment"
_agent_paths=(
"/home/hermeswebui/.hermes/hermes-agent"
"/opt/hermes"
)
_agent_src=""
for _p in "${_agent_paths[@]}"; do
if [ -d "$_p" ] && [ -f "$_p/pyproject.toml" ]; then
_agent_src="$_p"
break
fi
done
if [ -n "$_agent_src" ]; then
uv pip install "$_agent_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hermes-agent's requirements"
else
echo ""
echo "!! WARNING: hermes-agent source not found."
echo "!! Looked in: ${_agent_paths[0]}"
echo "!! ${_agent_paths[1]}"
echo "!! The WebUI will start with reduced functionality (no model auto-detection,"
echo "!! no personality routing, no CLI session imports)."
echo "!! To fix: mount the agent source volume into the container:"
echo "!! -v /path/to/hermes-agent:/home/hermeswebui/.hermes/hermes-agent"
echo "!! Or see the two-container compose example:"
echo "!! https://github.com/nesquena/hermes-webui/blob/master/docker-compose.two-container.yml"
echo ""
fi
touch /app/venv/.deps_installed
fi
ensure_hindsight_client_docker_dependency
echo ""; echo "== Running hermes-webui"
cd /app; python server.py || error_exit "hermes-webui failed or exited with an error"
# we should never be here because the server should be running indefinitely, but if we are, we exit safely
ok_exit "Clean exit"

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

838
docs/ui-ux/index.html Normal file
View File

@@ -0,0 +1,838 @@
<!doctype html>
<html lang="en" data-theme="slate">
<head>
<meta charset="utf-8">
<title>Hermes WebUI — Messages UI Inventory</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<!-- Real app stylesheet -->
<link rel="stylesheet" href="../../static/style.css">
<!-- Prism (same theme the app pulls at runtime) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<!-- KaTeX -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<style>
/* Showcase scaffold — styles only for the doc chrome. Everything inside
.messages uses the real app CSS unchanged. */
/* Real app CSS makes <body> a fixed-height flex shell. Undo that so this
doc page can scroll normally with a stacked header + main. */
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
.doc-main{display:block;}
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
.doc-main{max-width:1100px;margin:0 auto;padding:24px 24px 120px;}
.doc-section{margin:40px 0 8px;padding-top:20px;border-top:1px dashed var(--border);}
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
.doc-h{font-size:18px;font-weight:700;color:var(--text);margin:4px 0 4px;}
.doc-note{font-size:12px;color:var(--muted);line-height:1.55;max-width:760px;margin-bottom:10px;}
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:12px;padding:4px 6px;margin:12px 0;}
.doc-label{position:absolute;top:-9px;left:12px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 8px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
/* Force-show hover-only affordances inside explicitly flagged demos */
.force-show .msg-actions,
.force-show .msg-time,
.force-show .msg-foot{opacity:1 !important;}
/* Chat demo container mimics the app's .messages scroll wrapper but not fullscreen */
.messages.doc-messages{overflow:visible;display:block;}
.messages-inner.doc-inner{padding:14px 16px;}
/* Make the in-page demos of approval/clarify cards visible without JS */
.approval-card.doc-visible,
.clarify-card.doc-visible{display:block;}
.reconnect-banner.doc-visible{display:flex;align-items:center;justify-content:space-between;gap:12px;background:rgba(201,168,76,.12);border:1px solid rgba(201,168,76,.3);color:var(--gold);padding:8px 14px;border-radius:8px;font-size:12px;}
.reconnect-banner.doc-visible .reconnect-btn{background:none;border:1px solid rgba(201,168,76,.35);color:var(--gold);padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;}
.bg-error-banner.doc-visible{border-radius:8px;}
/* Two-up grid for short comparisons */
.doc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;}
</style>
</head>
<body>
<header class="doc-header">
<div class="doc-title">Hermes WebUI — Messages UI Inventory<small>Every message-area element &amp; combination, wired to the real <code>static/style.css</code>. &nbsp;·&nbsp; <a href="./two-stage-proposal.html" style="color:var(--blue);text-decoration:none;">Two-stage proposal (#536) →</a></small></div>
<div class="doc-toggles">
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
<button data-theme-btn="default">Default</button>
<button data-theme-btn="slate" class="on">Slate</button>
<button data-theme-btn="light">Light</button>
<button data-theme-btn="solarized">Solarized</button>
<button data-theme-btn="monokai">Monokai</button>
<button data-theme-btn="nord">Nord</button>
<button data-theme-btn="oled">OLED</button>
<span style="width:1px;height:18px;background:var(--border);margin:0 4px;align-self:center;"></span>
</div>
</header>
<main class="doc-main">
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">1 · Empty state</div>
<h2 class="doc-h">First load / no messages</h2>
<p class="doc-note">Renders inside <code>#messages</code> when <code>S.messages</code> is empty. Logo + title + subtitle + 3 suggestion buttons.</p>
<div class="doc-card"><span class="doc-label">.empty-state</span>
<div class="messages doc-messages">
<div class="empty-state" style="min-height:340px;flex:0 0 auto;">
<div class="empty-logo">H</div>
<h2>What can I help with?</h2>
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
<div class="suggestion-grid">
<button class="suggestion">📁 What files are in this workspace?</button>
<button class="suggestion">📅 What's on my schedule today?</button>
<button class="suggestion">🗺️ Help me plan a small project.</button>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">2 · User messages</div>
<h2 class="doc-h">Right-aligned bubble, attachments, and edit mode</h2>
<p class="doc-note">User rows have no avatar/label — the right-edge alignment and tinted bubble identify the sender. Timestamp + edit/copy live in a <code>.msg-foot</code> below the bubble, revealed on hover (forced visible here).</p>
<div class="doc-card"><span class="doc-label">.msg-row[data-role="user"] — plain</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row force-show" data-role="user" data-raw-text="How do I run the dev server and point it at a specific workspace path?">
<div class="msg-body"><p>How do I run the dev server and point it at a specific workspace path?</p></div>
<div class="msg-foot">
<span class="msg-time" title="Thu, Apr 16 2026, 10:42 AM">10:42</span>
<span class="msg-actions">
<button class="msg-action-btn" title="Edit"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
</span>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-files — attachments above body (right-aligned)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user">
<div class="msg-files">
<span class="msg-file-badge">📎 architecture-notes.pdf</span>
<span class="msg-file-badge">📎 Q1-forecast.xlsx</span>
<span class="msg-file-badge">📎 meeting.docx</span>
<span class="msg-file-badge">📎 screenshot.png</span>
</div>
<div class="msg-body"><p>Please review these docs and summarise the key decisions.</p></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-edit-area + .msg-edit-bar — edit mode</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user" data-editing="1">
<textarea class="msg-edit-area">How do I run the dev server and point it at a specific workspace path — and can I do it without docker?</textarea>
<div class="msg-edit-bar">
<button class="msg-edit-send">Send edit</button>
<button class="msg-edit-cancel">Cancel</button>
</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">3 · Assistant — markdown basics</div>
<h2 class="doc-h">Paragraphs, emphasis, lists, blockquote, hr, links</h2>
<p class="doc-note">Assistant output is a single <code>.msg-row.assistant-turn</code> that holds one role header + an <code>.assistant-turn-blocks</code> column of one-or-more <code>.assistant-segment</code> children. Each segment may contain a <code>.thinking-card</code>, a <code>.msg-body</code>, and its own <code>.msg-foot</code> (copy / regen). This lets a turn stream reasoning → text → tool calls → more text without repeating the Hermes avatar each time.</p>
<div class="doc-card"><span class="doc-label">.msg-body — rich prose</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn force-show" data-role="assistant">
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
<span class="role-icon assistant">H</span>
<span>Hermes</span>
</div>
<div class="assistant-turn-blocks">
<div class="assistant-segment" data-raw-text="Running the dev server...">
<div class="msg-body">
<h1>Running the dev server</h1>
<p>You can start Hermes with the built-in launcher. The <strong>simplest path</strong> is <em>no docker, no proxy</em> — the CLI handles everything.</p>
<h2>Prerequisites</h2>
<ul>
<li>Node <code>&gt;= 18</code></li>
<li>A workspace directory you own
<ul>
<li>Read/write permissions</li>
<li>No existing <code>.hermes</code> folder</li>
</ul>
</li>
<li>An API key set via <code>HERMES_API_KEY</code></li>
</ul>
<h2>Steps</h2>
<ol>
<li>Clone the repo</li>
<li>Run <code>npm install</code></li>
<li>Start with <code>npm run dev -- --workspace ~/code</code></li>
</ol>
<blockquote>Tip: the <code>--workspace</code> flag accepts absolute or <code>~</code>-prefixed paths. Relative paths are resolved against the CWD.</blockquote>
<hr>
<p>For full setup options see the <a href="#">configuration guide</a>.</p>
</div>
<div class="msg-foot">
<span class="msg-actions">
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
</span>
</div>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-body table</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks">
<div class="assistant-segment">
<div class="msg-body">
<p>Model comparison:</p>
<table>
<thead><tr><th>Model</th><th>Context</th><th>Good for</th><th>Cost / 1M in</th></tr></thead>
<tbody>
<tr><td>Opus 4.6</td><td>1M</td><td>Deep reasoning, long code</td><td><code>$15.00</code></td></tr>
<tr><td>Sonnet 4.6</td><td>1M</td><td>Daily driver, agents</td><td><code>$3.00</code></td></tr>
<tr><td>Haiku 4.5</td><td>200k</td><td>Fast tasks, tool loops</td><td><code>$0.80</code></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">4 · Code blocks</div>
<h2 class="doc-h">Plain, with header, with copy button, multi-language</h2>
<div class="doc-card"><span class="doc-label">pre + code (no header)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<pre><code class="language-bash">npm install
npm run dev -- --workspace ~/code</code></pre>
</div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.pre-header + pre + .code-copy-btn</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<div style="position:relative;">
<div class="pre-header">typescript <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
<pre><code class="language-typescript">export async function startServer(opts: ServerOptions) {
const port = opts.port ?? 3000;
const app = createApp();
app.listen(port, () =&gt; {
console.log(`Hermes listening on :${port}`);
});
return app;
}</code></pre>
</div>
<div style="position:relative;margin-top:14px;">
<div class="pre-header">python <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
<pre><code class="language-python">from hermes import Agent
def main() -&gt; None:
agent = Agent(model="claude-opus-4-6")
reply = agent.run("Summarise today's commits")
print(reply)
if __name__ == "__main__":
main()</code></pre>
</div>
<div style="position:relative;margin-top:14px;">
<div class="pre-header">json <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
<pre><code class="language-json">{
"model": "claude-sonnet-4-6",
"stream": true,
"tools": ["bash", "edit_file", "search"]
}</code></pre>
</div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">5 · Inline media</div>
<h2 class="doc-h">Images (default &amp; zoomed) and downloadable links</h2>
<div class="doc-card"><span class="doc-label">.msg-media-img (default + .msg-media-img--full)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>Here's the screenshot you asked for (click to zoom):</p>
<img class="msg-media-img" alt="demo" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='360'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' x2='1'%3E%3Cstop offset='0' stop-color='%237cb9ff'/%3E%3Cstop offset='1' stop-color='%23c9a84c'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23g)' width='640' height='360'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='white' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img (480×400 cap)%3C/text%3E%3C/svg%3E">
<p style="margin-top:10px;">And the full-width variant:</p>
<img class="msg-media-img msg-media-img--full" alt="demo-full" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1280' height='320'%3E%3Crect fill='%231e2023' width='1280' height='320'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='%2382aaff' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img--full (unbounded)%3C/text%3E%3C/svg%3E">
</div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-media-link — non-image downloads</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>I saved the generated files:</p>
<p><a class="msg-media-link" href="#">📎 report-2026-Q1.pdf</a> <a class="msg-media-link" href="#">📎 revenue.csv</a> <a class="msg-media-link" href="#">📎 diagram.svg</a></p>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">6 · Math &amp; diagrams</div>
<h2 class="doc-h">KaTeX inline / block &amp; Mermaid block</h2>
<div class="doc-card"><span class="doc-label">.katex-inline + .katex-block</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>Inline math: <span class="katex-inline" data-math-inline>\(E = mc^2\)</span> and the quadratic formula below:</p>
<div class="katex-block" data-math-block>$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$</div>
<p>A tidier form: <span class="katex-inline" data-math-inline>\(\sum_{i=1}^{n} i = \frac{n(n+1)}{2}\)</span>.</p>
<div class="katex-block" data-math-block>$$\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}$$</div>
</div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.mermaid-block (pre-render placeholder)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>The request flow:</p>
<div class="mermaid-block"><pre style="margin:0;background:none;border:none;padding:0;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:12px;">graph LR
U[User] --&gt; C[Composer]
C --&gt; API[/api/chat/]
API --&gt; M((Model))
M --&gt; T{tool?}
T -- yes --&gt; X[Tool Runner]
T -- no --&gt; R[Reply]
X --&gt; R
R --&gt; U</pre></div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">7 · Thinking / reasoning</div>
<h2 class="doc-h">Bordered panel (collapsed / open, animated), live loader, streaming cursor</h2>
<p class="doc-note">Thinking cards are rendered at the top of an <code>.assistant-segment</code>. They're now bordered gold-tinted panels (no more left-rule-only look) and expand/collapse with a <code>max-height</code> + opacity transition. Click the header in either example below to see the animation live.</p>
<div class="doc-grid">
<div class="doc-card"><span class="doc-label">.thinking-card (collapsed, inside .assistant-segment)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="thinking-card">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 4.3s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>The user asked about the dev server...</pre></div>
</div>
<div class="msg-body"><p>Here's the shortest path…</p></div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.thinking-card.open (animated — max-height + opacity)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 4.3s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>The user is asking about launching the dev server.
Options: npm script, docker, or the bundled CLI.
The CLI is the simplest — no container runtime needed.
I should show the exact commands and the --workspace flag,
then mention the env var for the API key at the end.</pre></div>
</div>
<div class="msg-body"><p>Here's the shortest path…</p></div>
</div></div>
</div>
</div></div>
</div>
</div>
<div class="doc-card"><span class="doc-label">.thinking — live 3-dot loader (pre-reasoning)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="thinking">Thinking <span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">[data-live-assistant="1"] — streaming cursor at end of last child</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant" id="liveAssistantTurn">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="msg-body"><p>Sure — the simplest way is to run <code>npm run dev</code>. The CLI will pick up the default</p></div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">8 · Tool cards</div>
<h2 class="doc-h">Running, done, expanded, subagent, error, multi-card toggle</h2>
<p class="doc-note">Tool cards sit in <code>.tool-card-row</code> wrappers (no longer nested under <code>.msg-row</code>). The details panel now animates open/closed via <code>max-height</code> + opacity — click any header below to see the transition.</p>
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-running (collapsed, pulsing dot)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card tool-card-running">
<div class="tool-card-header">
<span class="tool-card-running-dot"></span>
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">npm run build</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card — done, collapsed</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">static/style.css · 1155 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card.open — args table + result snippet + Show more (animated detail)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card open">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "msg-role" static/ · exit 0 · 380ms</span>
<span class="tool-card-toggle"></span>
</div>
<div class="tool-card-detail">
<div class="tool-card-args">
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">grep -rn "msg-role" static/</span></div>
<div><span class="tool-arg-key">cwd:</span> <span class="tool-arg-val">/Users/aron/hermes-webui</span></div>
<div><span class="tool-arg-key">timeout:</span> <span class="tool-arg-val">30000</span></div>
</div>
<div class="tool-card-result">
<pre>static/style.css:430: .msg-role{font-size:12px;font-weight:500...}
static/style.css:431: .msg-role.user{color:rgba(124,185,255,0.65);}
static/style.css:432: .msg-role.assistant{color:rgba(201,168,76,0.6);}
static/ui.js:1141: const roleEl = el('div', 'msg-role ' + role);</pre>
<button class="tool-card-more">Show more (+142 lines)</button>
</div>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-subagent — delegated work</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card tool-card-subagent">
<div class="tool-card-header">
<span class="tool-card-icon">🤖</span>
<span class="tool-card-name">Subagent</span>
<span class="tool-card-preview">Explore · Map chat messages UI elements</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card tool-card-subagent">
<div class="tool-card-header">
<span class="tool-card-icon">🤖</span>
<span class="tool-card-name">Delegate task</span>
<span class="tool-card-preview">Plan · Propose redesign variants</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card (error snippet)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card open">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span>
<span class="tool-card-toggle"></span>
</div>
<div class="tool-card-detail">
<div class="tool-card-args">
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">npm run typecheck</span></div>
</div>
<div class="tool-card-result">
<pre style="color:#fca5a5;">src/server.ts:42:7 - error TS2345: Argument of type 'string | undefined'
is not assignable to parameter of type 'number'.
42 app.listen(opts.port, () =&gt; {
~~~~~~~~~</pre>
</div>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-cards-toggle — Expand/Collapse All (≥2 cards)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-cards-toggle">
<button>Expand all (3)</button>
<button>Collapse all</button>
</div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">package.json</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">🔎</span><span class="tool-card-name">grep</span><span class="tool-card-preview">"listen" in src/</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 0 · 4.1s</span><span class="tool-card-toggle"></span></div></div></div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">9 · Meta affordances</div>
<h2 class="doc-h">Role timestamp tooltip, footer action toolbar, token-usage badge</h2>
<p class="doc-note">Assistant timestamps live on the <code>.msg-role</code> <code>title</code> attribute (hover for full date). Copy/regen buttons sit in the per-segment <code>.msg-foot</code>, 45% opacity at rest, full on turn hover. The <code>.msg-usage</code> badge is always visible at the bottom of the turn.</p>
<div class="doc-card"><span class="doc-label">Full hover state — .msg-foot actions + .msg-usage</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn force-show" data-role="assistant">
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
<span class="role-icon assistant">H</span>
<span>Hermes</span>
</div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body"><p>Built and type-checked successfully — server is running on <code>:3000</code>.</p></div>
<div class="msg-foot">
<span class="msg-actions">
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
</span>
</div>
</div></div>
<div class="msg-usage">3.2K in · 481 out · ~$0.012</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">10 · Full composition</div>
<h2 class="doc-h">User turn → assistant turn (segment 1: thinking + body + tool cards) → usage</h2>
<p class="doc-note">A realistic turn: one role header up top, then the segment hosting a thinking card plus the first body; tool cards follow as siblings of the turn inside <code>.messages-inner</code>; the usage badge closes the turn.</p>
<div class="doc-card"><span class="doc-label">All-in-one turn</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row force-show" data-role="user">
<div class="msg-files"><span class="msg-file-badge">📎 server.ts</span></div>
<div class="msg-body"><p>The build fails — can you type-check and explain?</p></div>
<div class="msg-foot">
<span class="msg-time">10:40</span>
<span class="msg-actions">
<button class="msg-action-btn" title="Edit"></button>
<button class="msg-copy-btn msg-action-btn" title="Copy"></button>
</span>
</div>
</div>
<div class="msg-row assistant-turn force-show" data-role="assistant">
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
<span class="role-icon assistant">H</span><span>Hermes</span>
</div>
<div class="assistant-turn-blocks">
<div class="assistant-segment">
<div class="thinking-card open">
<div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.1s</span><span class="thinking-card-toggle"></span></div>
<div class="thinking-card-body"><pre>Attached server.ts — probably typing issue.
Run typecheck to confirm, then patch.</pre></div>
</div>
<div class="msg-body">
<p>The build fails because <code>opts.port</code> can be <code>undefined</code>. Two fixes below — pick the one that matches your intent.</p>
<h3>Option A — require the port</h3>
<pre><code class="language-typescript">export function startServer(opts: { port: number }) {
app.listen(opts.port);
}</code></pre>
<h3>Option B — default to 3000</h3>
<pre><code class="language-typescript">export function startServer(opts: { port?: number } = {}) {
const port = opts.port ?? 3000;
app.listen(port);
}</code></pre>
<p>I ran the checks below to confirm.</p>
</div>
<div class="msg-foot">
<span class="msg-actions">
<button class="msg-copy-btn msg-action-btn" title="Copy"></button>
<button class="msg-action-btn" title="Regenerate"></button>
</span>
</div>
</div>
</div>
<div class="msg-usage">11.4K in · 612 out · ~$0.049</div>
</div>
<div class="tool-cards-toggle">
<button>Expand all (3)</button><button>Collapse all</button>
</div>
<div class="tool-card-row"><div class="tool-card open">
<div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">src/server.ts · 58 lines</span><span class="tool-card-toggle"></span></div>
<div class="tool-card-detail">
<div class="tool-card-args"><div><span class="tool-arg-key">path:</span> <span class="tool-arg-val">src/server.ts</span></div></div>
<div class="tool-card-result"><pre>export function startServer(opts: ServerOptions) {
app.listen(opts.port, () =&gt; { ... });
}</pre></div>
</div>
</div></div>
<div class="tool-card-row"><div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span><span class="tool-card-toggle"></span></div>
</div></div>
<div class="tool-card-row"><div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">src/server.ts +1 / -1</span><span class="tool-card-toggle"></span></div>
</div></div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">12 · System / inline notes</div>
<h2 class="doc-h">Compression, cancellation, errors — rendered as italicised assistant messages</h2>
<div class="doc-card"><span class="doc-label">Italic system notices (still italic — info, not errors)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks">
<div class="assistant-segment"><div class="msg-body"><p><em>[Context was auto-compressed to continue the conversation]</em></p></div></div>
<div class="assistant-segment"><div class="msg-body"><p><em>Task cancelled.</em></p></div></div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.assistant-segment[data-error="1"] — real error card, red accent, no italic</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks">
<div class="assistant-segment" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Connection lost. Your last message was saved — refresh to continue.</p></div></div>
<div class="assistant-segment" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Upstream rate-limited (429). Retrying in 30s…</p></div></div>
</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">12b · Turn boundaries &amp; date separators</div>
<h2 class="doc-h">Right-alignment separates user turns · day-change separator</h2>
<p class="doc-note">The dashed divider before each user turn was removed — the right-edge bubble alignment is its own visual break, so only a small vertical gap (10px top margin) remains between turns. Day changes still get a centred <code>.msg-date-sep</code>.</p>
<div class="doc-card"><span class="doc-label">.msg-date-sep — Today / Yesterday / weekday / date</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-date-sep">Yesterday</div>
<div class="msg-row" data-role="user"><div class="msg-body"><p>Can you summarise the PR I opened earlier?</p></div></div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment"><div class="msg-body"><p>Yes — three files changed, net +42 / -18. Main change is the new rail variable…</p></div></div></div>
</div>
<div class="msg-date-sep">Today</div>
<div class="msg-row" data-role="user"><div class="msg-body"><p>Did CI pass overnight?</p></div></div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment"><div class="msg-body"><p>All green — three jobs, 4m 12s total. Here's the breakdown:</p></div></div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">13 · Overlay cards (adjacent to transcript)</div>
<h2 class="doc-h">Approval &amp; Clarify cards + reconnect banner</h2>
<div class="doc-card"><span class="doc-label">.approval-card — 4 button variants (once / session / always / deny)</span>
<div class="approval-card doc-visible">
<div class="approval-inner">
<div class="approval-header">⚠ Approval required</div>
<div class="approval-desc" style="font-size:12px;color:var(--muted);margin-bottom:8px;">The agent wants to run a shell command in <code>/Users/aron/hermes-webui</code>.</div>
<div class="approval-cmd">rm -rf node_modules &amp;&amp; npm install</div>
<div class="approval-btns">
<button class="approval-btn once"><span class="approval-btn-label">Allow once</span><kbd class="approval-kbd"></kbd></button>
<button class="approval-btn session">🔒 <span class="approval-btn-label">Allow session</span></button>
<button class="approval-btn always"><span class="approval-btn-label">Always allow</span></button>
<button class="approval-btn deny"><span class="approval-btn-label">Deny</span></button>
</div>
</div>
</div>
</div>
<div class="doc-card"><span class="doc-label">.clarify-card — choice buttons + free-text fallback</span>
<div class="clarify-card doc-visible">
<div class="clarify-inner">
<div class="clarify-header">? Clarification needed</div>
<div class="clarify-question">Which environment should I deploy this to?</div>
<div class="clarify-choices">
<button class="clarify-choice"><span class="clarify-choice-badge">A</span><span class="clarify-choice-text">Staging — safe sandbox, auto-teardown nightly</span></button>
<button class="clarify-choice"><span class="clarify-choice-badge">B</span><span class="clarify-choice-text">Production EU — customer-facing, requires change ticket</span></button>
<button class="clarify-choice"><span class="clarify-choice-badge">C</span><span class="clarify-choice-text">Production US — same caveats as EU</span></button>
<button class="clarify-choice other"><span class="clarify-choice-badge other"></span><span class="clarify-choice-text">Other — I'll type it below</span></button>
</div>
<div class="clarify-response">
<input class="clarify-input" type="text" placeholder="Type your response…">
<button class="clarify-submit">Send</button>
</div>
<div class="clarify-hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
</div>
<div class="doc-card"><span class="doc-label">Reconnect / mid-stream recovery banner</span>
<div class="reconnect-banner doc-visible">
<span>⚠ A response may have been in progress when you last left. Reload messages?</span>
<div style="display:flex;gap:8px;">
<button class="reconnect-btn">Dismiss</button>
<button class="reconnect-btn">↻ Reload</button>
</div>
</div>
<div class="bg-error-banner doc-visible" style="margin-top:8px;">
<span>⚠ Agent run exited with non-zero status (code 1). Check the logs.</span>
<button class="reconnect-btn">Dismiss</button>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">14 · Structure &amp; data-attribute cheat sheet</div>
<h2 class="doc-h">Wrappers and state markers produced by <code>renderMessages()</code></h2>
<div class="doc-card" style="padding:14px 18px;">
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">Wrappers</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>.msg-row[data-role="user"]</code> — one user turn (right-aligned bubble, 60% max-width)</li>
<li><code>.msg-row.assistant-turn[data-role="assistant"]</code> — one assistant turn; contains <strong>one</strong> <code>.msg-role</code> and <strong>one</strong> <code>.assistant-turn-blocks</code></li>
<li><code>.assistant-turn-blocks</code> — flex-column holder for segments</li>
<li><code>.assistant-segment</code> — a single logical chunk inside a turn: optional <code>.thinking-card</code> + optional <code>.msg-body</code> + optional <code>.msg-foot</code></li>
<li><code>.assistant-segment-anchor</code> — hidden segment kept as a DOM anchor for tool cards when the model emitted no text</li>
<li><code>.tool-card-row</code> — per-tool-card wrapper, sibling of the turn inside <code>.messages-inner</code></li>
<li><code>.msg-foot</code> — per-segment (or per-user-row) footer holding <code>.msg-time</code> + <code>.msg-actions</code></li>
</ul>
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Data attributes &amp; IDs</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>data-role="user|assistant"</code> — role marker on the row</li>
<li><code>data-msgIdx="N"</code> — index into <code>S.messages</code>; on user rows <em>and</em> assistant segments</li>
<li><code>data-raw-text="…"</code> — plain-text source for copy (now lives on <code>.assistant-segment</code> for assistant output)</li>
<li><code>data-live-assistant="1"</code> — the segment that's currently streaming</li>
<li><code>data-editing="1"</code> — row is in edit mode</li>
<li><code>data-error="1"</code> — error state; applies to <code>.msg-row</code> (user) or <code>.assistant-segment</code></li>
<li><code>id="liveAssistantTurn"</code> — on the turn that contains the streaming segment</li>
<li><code>.tool-card-row[data-live-tid="…"]</code> — live tool-call card (removed when the turn settles)</li>
<li><code>data-mermaid-id</code>, <code>data-katex</code>, <code>data-rendered</code> — block rendering state</li>
</ul>
</div>
</section>
</main>
<!-- ============================================================= -->
<!-- Prism autoloader for real syntax highlighting -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<!-- KaTeX auto-render -->
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false},{left:'$',right:'$',display:false}],throwOnError:false});"></script>
<script>
// Theme picker
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.themeBtn;
if (t === 'default') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', t);
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
});
});
// Bubble-layout toggle
// Thinking / tool-card click-to-toggle (so the demo feels live)
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
h.addEventListener('click', () => h.parentElement.classList.toggle('open'));
});
</script>
</body>
</html>

View File

@@ -0,0 +1,742 @@
<!doctype html>
<html lang="en" data-theme="slate">
<head>
<meta charset="utf-8">
<title>Hermes WebUI — Two-Stage Chat Proposal (Issue #536)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="../../static/style.css">
<style>
/* ──────────────────────────────────────────────────────────────
Doc-chrome scaffold (same pattern as index.html) — real app CSS
is used unchanged inside .messages / .msg-row. New proposed
elements are prefixed .p2s- so nothing collides with the app.
────────────────────────────────────────────────────────────── */
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
.doc-title a{color:var(--blue);text-decoration:none;}
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
.doc-main{max-width:1180px;margin:0 auto;padding:24px 24px 120px;}
.doc-section{margin:48px 0 8px;padding-top:22px;border-top:1px dashed var(--border);}
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
.doc-h{font-size:20px;font-weight:700;color:var(--text);margin:4px 0 6px;letter-spacing:-.01em;}
.doc-note{font-size:12.5px;color:var(--muted);line-height:1.6;max-width:780px;margin-bottom:14px;}
.doc-note code{color:var(--text);background:rgba(255,255,255,.05);padding:1px 5px;border-radius:4px;font-size:11.5px;}
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:14px;padding:6px 8px;margin:14px 0;}
.doc-label{position:absolute;top:-9px;left:14px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 9px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
.doc-label.current{color:var(--muted);}
.doc-label.proposed{color:var(--gold);border-color:rgba(201,168,76,.35);background:var(--bg);}
.force-show .msg-actions,.force-show .msg-time,.force-show .msg-foot{opacity:1 !important;}
.messages.doc-messages{overflow:visible;display:block;}
.messages-inner.doc-inner{padding:14px 16px;}
.approval-card.doc-visible,.clarify-card.doc-visible{display:block;}
.doc-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(440px,1fr));gap:14px;}
/* ──────────────────────────────────────────────────────────────
Proposed two-stage elements (prefix .p2s-)
The proposal introduces one container (.p2s-stage1) that wraps
the execution history (thinking + tool cards) and one visual
treatment (.p2s-answer) for the final-answer segment. The same
DOM can be rendered in three modes:
.p2s-stage1.is-live → Working timer + expanded history
.p2s-stage1.is-settled → Collapsed to one-line summary
.p2s-stage1.is-settled.is-open → expanded on demand
Everything else (thinking-card, tool-card-row, msg-body) is the
existing app CSS unchanged.
────────────────────────────────────────────────────────────── */
/* Worklog bar — the header of Stage 1.
Aligns with every other rail child via --msg-rail / --msg-max. */
.p2s-worklog{
display:flex;align-items:center;gap:10px;
margin:4px 0 6px var(--msg-rail);
max-width:var(--msg-max);
padding:8px 12px;
border:1px solid var(--border);
border-radius:10px;
background:rgba(255,255,255,.025);
font-size:12px;color:var(--muted);
cursor:pointer;user-select:none;
transition:border-color .15s,background .15s;
}
.p2s-worklog:hover{border-color:var(--border2);background:rgba(255,255,255,.04);}
.p2s-worklog-dot{
width:8px;height:8px;border-radius:50%;background:var(--gold);flex-shrink:0;
box-shadow:0 0 0 0 rgba(201,168,76,.4);
}
.p2s-stage1.is-live .p2s-worklog-dot{
animation:p2sPulse 1.4s ease-in-out infinite;
}
.p2s-stage1.is-settled .p2s-worklog-dot{
background:var(--muted);opacity:.6;
}
@keyframes p2sPulse{
0%,100%{box-shadow:0 0 0 0 rgba(201,168,76,.45);}
50%{box-shadow:0 0 0 6px rgba(201,168,76,0);}
}
.p2s-worklog-label{color:var(--text);font-weight:500;}
.p2s-worklog-stats{margin-left:auto;display:flex;gap:12px;color:var(--muted);font-size:11.5px;}
.p2s-worklog-stats b{color:var(--text);font-weight:600;}
.p2s-worklog-caret{
display:inline-block;width:14px;height:14px;line-height:14px;text-align:center;
color:var(--muted);font-size:10px;transition:transform .2s;
margin-left:6px;
}
.p2s-stage1.is-live .p2s-worklog-caret{display:none;}
.p2s-stage1.is-settled.is-open .p2s-worklog-caret{transform:rotate(90deg);}
/* Stage 1 body — holds thinking + tool cards + round separators. */
.p2s-stage1-body{
overflow:hidden;
transition:max-height .35s ease,opacity .25s ease;
}
.p2s-stage1.is-live .p2s-stage1-body,
.p2s-stage1.is-settled.is-open .p2s-stage1-body{
max-height:2000px;opacity:1;
}
.p2s-stage1.is-settled:not(.is-open) .p2s-stage1-body{
max-height:0;opacity:0;pointer-events:none;
}
/* Round separator — shown inside Stage 1 between execution rounds. */
.p2s-round-sep{
display:flex;align-items:center;gap:10px;
margin:10px 0 6px var(--msg-rail);
max-width:var(--msg-max);
color:var(--muted);
font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
}
.p2s-round-sep::before,.p2s-round-sep::after{
content:"";flex:1;height:1px;background:var(--border);
}
/* Stage 1 → Stage 2 transition divider. */
.p2s-transition{
margin:14px 0 10px var(--msg-rail);
max-width:var(--msg-max);
height:1px;
background:linear-gradient(
to right,transparent,var(--border) 20%,var(--border) 80%,transparent
);
}
/* Stage 2 — the final answer wrapper.
Design intent: nothing loud. A small "Answer" kicker in gold,
slightly taller line-height, the existing .msg-body styling,
and a gentle top breathing-space. The user arrives at this
block and it *feels* like a conclusion, not another tool row.
*/
.p2s-answer{margin-top:8px;}
.p2s-answer-kicker{
margin:0 0 4px var(--msg-rail);
max-width:var(--msg-max);
font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;
color:var(--gold);opacity:.8;
}
.p2s-answer .msg-body{
font-size:14.5px;line-height:1.78;
}
/* Clarify slot — placed at the transition rather than inline. */
.p2s-clarify-slot{
margin:12px 0 4px var(--msg-rail);
max-width:var(--msg-max);
}
.p2s-clarify-slot .clarify-card{margin:0;}
/* Comparison-grid accents. */
.doc-compare-caption{
font-size:11px;color:var(--muted);text-align:center;padding:6px 0;
}
</style>
</head>
<body>
<header class="doc-header">
<div class="doc-title">
Two-Stage Chat UX — Proposal for <a href="https://github.com/nesquena/hermes-webui/issues/536" target="_blank">issue #536</a>
<small>Companion to <a href="./index.html">index.html</a> — shows <em>Working → Final answer</em> as a distinct two-phase interaction model.</small>
</div>
<div class="doc-toggles">
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
<button data-theme-btn="default">Default</button>
<button data-theme-btn="slate" class="on">Slate</button>
<button data-theme-btn="light">Light</button>
<button data-theme-btn="solarized">Solarized</button>
<button data-theme-btn="monokai">Monokai</button>
<button data-theme-btn="nord">Nord</button>
<button data-theme-btn="oled">OLED</button>
</div>
</header>
<main class="doc-main">
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">0 · The model</div>
<h2 class="doc-h">One turn, two stages</h2>
<p class="doc-note">
Today an assistant turn is a flat stream: thinking card → tool cards → answer, all stacked
inline with equal visual weight. The proposal wraps the execution history in a
<code>.p2s-stage1</code> container with a <em>worklog bar</em> as its header, and marks the
final answer as <code>.p2s-answer</code>. The same DOM renders three ways:
</p>
<ul class="doc-note" style="padding-left:18px;list-style:disc;">
<li><b>Live</b> — worklog shows <em>Working… 0:42 · 2 tools</em> with a pulsing dot; history is fully visible.</li>
<li><b>Settled</b> — worklog collapses to a single line (<em>Worked 1:42 · 4 tools · 2 thinking</em>); final answer sits below as the calm conclusion.</li>
<li><b>Settled + opened</b> — user clicks the worklog to re-expand the history for audit.</li>
</ul>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">1 · Current vs proposed — settled turn</div>
<h2 class="doc-h">Side-by-side comparison</h2>
<p class="doc-note">
Same turn, same tool calls, same answer. Left is what #587 ships today. Right is the
proposal: execution history collapses to a one-line summary; the final answer stands alone
with a small <em>Answer</em> kicker.
</p>
<div class="doc-grid-2">
<!-- CURRENT ──────────────────────────────────────────────── -->
<div class="doc-card"><span class="doc-label current">Current (PR #587)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user">
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</p></div>
</div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 3.1s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
grep for HERMES_WORKSPACE and --workspace
inspect argv vs env precedence.</pre></div>
</div>
</div></div>
</div>
<div class="tool-card-row">
<div class="tool-card open">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
<div class="tool-card-detail">
<div class="tool-card-result"><pre>cli/main.py:14:WORKSPACE_ENV = "HERMES_WORKSPACE"
cli/main.py:92: ws = os.getenv(WORKSPACE_ENV) or args.workspace</pre></div>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">cli/main.py · 148 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>Both work, but <strong>env wins</strong>. The CLI reads
<code>HERMES_WORKSPACE</code> first and only falls back to the
<code>--workspace</code> flag if the env var is unset.</p>
<p>So in practice:</p>
<ul>
<li>CI / daemons → set the env var.</li>
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
</ul>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Everything stacks equally — the answer is just the next block.</div>
</div>
<!-- PROPOSED ─────────────────────────────────────────────── -->
<div class="doc-card"><span class="doc-label proposed">Proposed — two-stage, settled</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user">
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</p></div>
</div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<!-- Stage 1 — settled, collapsed to summary (click to expand) -->
<div class="p2s-stage1 is-settled" data-p2s-toggle>
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Worked for 0:08</span>
<span class="p2s-worklog-stats">
<span><b>2</b> tools</span>
<span><b>1</b> thinking round</span>
</span>
<span class="p2s-worklog-caret"></span>
</div>
<div class="p2s-stage1-body">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 3.1s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
grep for HERMES_WORKSPACE and --workspace
inspect argv vs env precedence.</pre></div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">cli/main.py · 148 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Stage 2 — the final answer -->
<div class="p2s-transition"></div>
<div class="p2s-answer">
<div class="p2s-answer-kicker">Answer</div>
<div class="msg-body">
<p>Both work, but <strong>env wins</strong>. The CLI reads
<code>HERMES_WORKSPACE</code> first and only falls back to the
<code>--workspace</code> flag if the env var is unset.</p>
<p>So in practice:</p>
<ul>
<li>CI / daemons → set the env var.</li>
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
</ul>
</div>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Click the worklog bar to expand the execution history.</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">2 · Stage 1 · Live run</div>
<h2 class="doc-h">Working timer + live execution history</h2>
<p class="doc-note">
The worklog bar at the top is the anchor for the whole active run: pulsing dot, elapsed
timer that ticks every second, and live counts that increment as tool cards resolve.
Thinking cards and tool cards render inside <code>.p2s-stage1-body</code> exactly as today.
A <em>Round N</em> separator is inserted when the agent starts a new reasoning/tool cycle.
</p>
<div class="doc-card"><span class="doc-label proposed">.p2s-stage1.is-live — Round 1 done, Round 2 running</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="p2s-stage1 is-live">
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Working… <span id="p2sTimer">0:42</span></span>
<span class="p2s-worklog-stats">
<span><b>3</b> tools</span>
<span><b>2</b> thinking</span>
</span>
</div>
<div class="p2s-stage1-body">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 2.4s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Need to map the streaming code path first,
then check the persistence layer.</pre></div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">api/streaming.py · 612 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "tool_call_id" api/ · exit 0 · 88ms</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="p2s-round-sep">Round 2</div>
<div class="thinking-card">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 1.8s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Streaming looks fine — drill into how
tool_calls get attached before save.</pre></div>
</div>
<div class="tool-card-row">
<div class="tool-card tool-card-running">
<div class="tool-card-header">
<span class="tool-card-running-dot"></span>
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">pytest tests/test_tool_call_persistence.py -q</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">3 · Approve vs Clarify — placement</div>
<h2 class="doc-h">Approvals stay in Stage 1; Clarify moves to the transition</h2>
<p class="doc-note">
Per the issue: <em>approvals are part of doing the work</em> (they gate a single tool),
<em>clarifications stabilise the answer path</em> (they precede the conclusion). The
proposal keeps <code>.approval-card</code> inline among tool cards, and places
<code>.clarify-card</code> at the Stage 1 → Stage 2 seam, above the final answer.
</p>
<div class="doc-grid-2">
<!-- Approve inline in Stage 1 -->
<div class="doc-card"><span class="doc-label proposed">Approve card — inline in Stage 1</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="p2s-stage1 is-live">
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Working… 0:18</span>
<span class="p2s-worklog-stats"><span><b>1</b> tool</span></span>
</div>
<div class="p2s-stage1-body">
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">ls -la ~/.hermes/sessions · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="approval-card doc-visible">
<div class="approval-card-header">
<span class="approval-card-icon">🔐</span>
<span class="approval-card-title">Approve command</span>
</div>
<div class="approval-card-body">
<p class="approval-card-desc">Hermes wants to run a potentially destructive command:</p>
<pre class="approval-card-cmd">rm -rf ~/.hermes/sessions/*.json.bak</pre>
</div>
<div class="approval-card-actions">
<button class="approval-btn approve">Approve</button>
<button class="approval-btn deny">Deny</button>
</div>
</div>
</div>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Permission gate sits next to the tools it gates.</div>
</div>
<!-- Clarify at transition -->
<div class="doc-card"><span class="doc-label proposed">Clarify card — Stage 1 → Stage 2 transition</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="p2s-stage1 is-settled" data-p2s-toggle>
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Worked for 0:12</span>
<span class="p2s-worklog-stats"><span><b>2</b> tools</span></span>
<span class="p2s-worklog-caret"></span>
</div>
<div class="p2s-stage1-body">
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">package.json · 48 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">ls src/ · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div>
</div>
<div class="p2s-transition"></div>
<div class="p2s-clarify-slot">
<div class="clarify-card doc-visible">
<div class="clarify-card-header">
<span class="clarify-card-icon"></span>
<span class="clarify-card-title">One quick question before I answer</span>
</div>
<div class="clarify-card-body">
<p>I can wire the dev server either as an <strong>npm script</strong> in the
existing <code>package.json</code>, or as a standalone <strong>CLI
entry-point</strong>. Which would you prefer?</p>
</div>
<div class="clarify-card-actions">
<button class="clarify-opt">npm script</button>
<button class="clarify-opt">CLI entry-point</button>
<button class="clarify-opt">Let Hermes pick</button>
</div>
</div>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Stage 1 is already settled; the answer is paused on clarification.</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">4 · Stage 2 · Calm conclusion</div>
<h2 class="doc-h">What the "Answer" stage looks like on its own</h2>
<p class="doc-note">
Three small choices distinguish Stage 2 from a regular text block:
(1) a thin horizontal divider above it, (2) a tiny gold <em>Answer</em> kicker aligned to
the text rail, (3) a slightly taller line-height. No heavy borders, no boxed treatment —
the emphasis comes from <em>what is missing around it</em>, not ornament.
</p>
<div class="doc-card"><span class="doc-label proposed">.p2s-answer (Stage 1 collapsed above)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="p2s-stage1 is-settled" data-p2s-toggle>
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Worked for 1:42</span>
<span class="p2s-worklog-stats">
<span><b>4</b> tools</span>
<span><b>2</b> thinking</span>
<span><b>1</b> approval</span>
</span>
<span class="p2s-worklog-caret"></span>
</div>
<div class="p2s-stage1-body">
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.4s</span><span class="thinking-card-toggle"></span></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">api/streaming.py</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">grep -rn "tool_call_id" api/</span><span class="tool-card-toggle"></span></div></div></div>
<div class="p2s-round-sep">Round 2</div>
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 1.8s</span><span class="thinking-card-toggle"></span></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">pytest -q · exit 0 · 2.4s</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">✍️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">api/streaming.py · +12 3</span><span class="tool-card-toggle"></span></div></div></div>
</div>
</div>
<div class="p2s-transition"></div>
<div class="p2s-answer">
<div class="p2s-answer-kicker">Answer</div>
<div class="msg-body">
<p>Tool-call persistence was breaking because <code>session.tool_calls</code> was
written <em>after</em> <code>s.save()</code> in <code>api/streaming.py</code>.
I moved the attach step above the save, and added a fallback that reconstructs
ordering from live tool-progress events when <code>tool_call_id</code> is absent
on older sessions.</p>
<p>Net result:</p>
<ul>
<li>Reloading mid-stream now preserves every tool card with args + output snippet.</li>
<li>Last-turn reasoning survives reload.</li>
<li>No schema migration needed — old sessions degrade gracefully.</li>
</ul>
<p>Covered by the new regression in <code>tests/test_tool_call_persistence.py</code>.</p>
</div>
<div class="msg-foot" style="opacity:1;padding-left:var(--msg-rail);">
<span class="msg-time">11:42 AM · 2,481 tokens · 1.42s</span>
<span class="msg-actions">
<button class="msg-act" title="Copy"></button>
<button class="msg-act" title="Regenerate"></button>
</span>
</div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">5 · Open-question answers (picked defaults)</div>
<h2 class="doc-h">What this proposal commits to</h2>
<div class="doc-card" style="padding:16px 20px;">
<ul style="color:var(--muted);font-size:13px;line-height:1.85;list-style:disc;padding-left:22px;margin:0;">
<li><b style="color:var(--text);">Stage 1 on settle →</b> <em>partial</em> collapse to a
single worklog bar with counts. Click to re-expand. No "nuke to black box", no "keep
everything open forever".</li>
<li><b style="color:var(--text);">Final answer placement →</b> sits <em>beneath</em> Stage 1,
not replacing it. Visual distinction comes from the divider + kicker + spacing, not from
a two-panel layout.</li>
<li><b style="color:var(--text);">Clarify placement →</b> at the Stage 1 → Stage 2 seam.
Approvals stay inline with tools.</li>
<li><b style="color:var(--text);">Timer →</b> lives on Stage 1 only. Stops when the agent
emits the first Stage 2 token; final label becomes "Worked for N:NN".</li>
<li><b style="color:var(--text);">Signal for "answer has started" →</b> first assistant
text delta after all tool calls have resolved and no new <code>tool_use</code> is pending
in the current round. Already present in the SSE stream per maintainer comment.</li>
</ul>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">6 · DOM cheat-sheet</div>
<h2 class="doc-h">What changes vs index.html</h2>
<div class="doc-card" style="padding:14px 18px;">
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">New wrappers</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>.p2s-stage1[is-live|is-settled][is-open]</code> — wraps the execution history inside an <code>.assistant-segment</code>.</li>
<li><code>.p2s-worklog</code> — header of Stage 1. Pulsing dot + label + counts + caret. Clickable when settled.</li>
<li><code>.p2s-stage1-body</code> — holds <code>.thinking-card</code> + <code>.tool-card-row</code> + <code>.p2s-round-sep</code>. Animated via <code>max-height</code>.</li>
<li><code>.p2s-round-sep</code> — inline horizontal separator between tool/reasoning rounds.</li>
<li><code>.p2s-transition</code> — thin gradient divider between Stage 1 and Stage 2.</li>
<li><code>.p2s-answer</code> — wraps the final <code>.msg-body</code> + <code>.msg-foot</code>.</li>
<li><code>.p2s-answer-kicker</code> — small gold <em>Answer</em> label.</li>
<li><code>.p2s-clarify-slot</code> — placement slot for <code>.clarify-card</code> at the Stage 1/2 seam.</li>
</ul>
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Unchanged</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>.thinking-card</code>, <code>.tool-card</code>, <code>.approval-card</code>, <code>.clarify-card</code>, <code>.msg-body</code>, <code>.msg-foot</code> — all existing app CSS and existing markup.</li>
<li><code>.assistant-turn-blocks</code> and <code>.assistant-segment</code> remain the top-level wrappers.</li>
<li>Tool cards still live as <code>.tool-card-row</code> siblings — now nested <em>inside</em> <code>.p2s-stage1-body</code> rather than as direct children of <code>.messages-inner</code>.</li>
</ul>
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Implementation notes</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li>Renderer in <code>static/messages.js</code> wraps an assistant turn's non-final blocks in <code>.p2s-stage1-body</code> and appends the <code>.p2s-worklog</code> header once; toggles <code>is-live</code>/<code>is-settled</code> based on <code>data-live-assistant</code>.</li>
<li><code>static/boot.js</code> SSE handler ticks the timer while <code>is-live</code>, increments counts on each <code>tool_use</code>, and flips the class when the first Stage 2 delta arrives.</li>
<li>Persistence: no schema change needed — the worklog summary can be derived on reload from the existing persisted tool-call list + thinking rounds.</li>
</ul>
</div>
</section>
</main>
<script>
// Theme picker (matches index.html)
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.themeBtn;
if (t === 'default') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', t);
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
});
});
// Existing thinking/tool cards click-to-toggle.
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
h.addEventListener('click', (e) => {
e.stopPropagation();
h.parentElement.classList.toggle('open');
});
});
// Click the worklog bar on a settled Stage 1 to expand/collapse the history.
document.querySelectorAll('.p2s-stage1[data-p2s-toggle] .p2s-worklog').forEach(bar => {
bar.addEventListener('click', () => {
const stage = bar.closest('.p2s-stage1');
if (!stage.classList.contains('is-settled')) return;
stage.classList.toggle('is-open');
});
});
// Live timer demo in section 2 — ticks so the page feels alive.
(function(){
const el = document.getElementById('p2sTimer');
if (!el) return;
let [m, s] = el.textContent.split(':').map(Number);
setInterval(() => {
s = (s + 1) % 60;
if (s === 0) m += 1;
el.textContent = m + ':' + String(s).padStart(2,'0');
}, 1000);
})();
</script>
</body>
</html>

137
server.py
View File

@@ -3,22 +3,54 @@ Hermes Web UI -- Main server entry point.
Thin routing shell: imports Handler, delegates to api/routes.py, runs server.
All business logic lives in api/*.
"""
import logging
import socket
import sys
import time
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
from api.auth import check_auth
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
from api.helpers import j
from api.helpers import j, get_profile_cookie
from api.profiles import set_request_profile, clear_request_profile
from api.routes import handle_get, handle_post
from api.startup import auto_install_agent_deps, fix_credential_permissions
from api.updates import WEBUI_VERSION
class QuietHTTPServer(ThreadingHTTPServer):
"""Custom HTTP server that silently handles common network errors."""
def handle_error(self, request, client_address):
"""Override to suppress logging for common client disconnect errors."""
exc_type, exc_value, _ = sys.exc_info()
# Silently ignore common connection errors caused by client disconnects
if exc_type in (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
return
# Also handle socket errors that indicate client disconnect
if exc_type is socket.error:
# errno 54 is Connection reset by peer on macOS/BSD
# errno 104 is Connection reset by peer on Linux
if exc_value.errno in (54, 104, 32): # ECONNRESET, EPIPE
return
# For other errors, use default logging
super().handle_error(request, client_address)
class Handler(BaseHTTPRequestHandler):
server_version = 'HermesWebUI/0.2'
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
_ver_suffix = WEBUI_VERSION.removeprefix('v')
server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI'
def log_message(self, fmt, *args): pass # suppress default Apache-style log
def log_request(self, code='-', size='-'):
def log_request(self, code: str='-', size: str='-') -> None:
"""Structured JSON logs for each request."""
import json as _json
duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)
@@ -31,8 +63,12 @@ class Handler(BaseHTTPRequestHandler):
})
print(f'[webui] {record}', flush=True)
def do_GET(self):
def do_GET(self) -> None:
self._req_t0 = time.time()
# Per-request profile context from cookie (issue #798)
cookie_profile = get_profile_cookie(self)
if cookie_profile:
set_request_profile(cookie_profile)
try:
parsed = urlparse(self.path)
if not check_auth(self, parsed): return
@@ -42,9 +78,15 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
return j(self, {'error': 'Internal server error'}, status=500)
finally:
clear_request_profile()
def do_POST(self):
def do_POST(self) -> None:
self._req_t0 = time.time()
# Per-request profile context from cookie (issue #798)
cookie_profile = get_profile_cookie(self)
if cookie_profile:
set_request_profile(cookie_profile)
try:
parsed = urlparse(self.path)
if not check_auth(self, parsed): return
@@ -54,30 +96,101 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
return j(self, {'error': 'Internal server error'}, status=500)
finally:
clear_request_profile()
def main():
def main() -> None:
from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
print_startup_config()
# Fix sensitive file permissions before doing anything else
fix_credential_permissions()
within_container = False
# Check for the "/.within_container" file to determine if we're running inside a container; this file is created in the Dockerfile
try:
with open('/.within_container', 'r') as f:
within_container = True
except FileNotFoundError:
pass
if within_container:
print('[ok] Running within container.', flush=True)
# Security: warn if binding non-loopback without authentication
from api.auth import is_auth_enabled
if HOST not in ('127.0.0.1', '::1', 'localhost') and not is_auth_enabled():
print(f'[!!] WARNING: Binding to {HOST} with NO PASSWORD SET.', flush=True)
print(f' Anyone on the network can access your filesystem and agent.', flush=True)
print(f' Set a password via Settings or HERMES_WEBUI_PASSWORD env var.', flush=True)
print(f' To suppress: bind to 127.0.0.1 or set a password.', flush=True)
if within_container:
print(f' Note: You are running within a container, must bind to 0.0.0.0 to publish the port.', flush=True)
elif not is_auth_enabled():
print(f' [tip] No password set. Any process on this machine can read sessions', flush=True)
print(f' and memory via the local API. Set HERMES_WEBUI_PASSWORD to', flush=True)
print(f' enable authentication.', flush=True)
ok, missing, errors = verify_hermes_imports()
if not ok and _HERMES_FOUND:
print(f'[!!] Warning: Hermes agent found but missing modules: {missing}', flush=True)
for mod, err in errors.items():
print(f' {mod}: {err}', flush=True)
print(' Agent features may not work correctly.', flush=True)
print(' Attempting to install missing dependencies from agent requirements.txt...', flush=True)
auto_install_agent_deps()
ok, missing, errors = verify_hermes_imports()
if not ok:
print(f'[!!] Still missing after install attempt: {missing}', flush=True)
for mod, err in errors.items():
print(f' {mod}: {err}', flush=True)
print(' Agent features may not work correctly.', flush=True)
else:
print('[ok] Agent dependencies installed successfully.', flush=True)
STATE_DIR.mkdir(parents=True, exist_ok=True)
SESSION_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_WORKSPACE.mkdir(parents=True, exist_ok=True)
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
print(f' Hermes Web UI listening on http://{HOST}:{PORT}', flush=True)
if HOST == '127.0.0.1':
# Start the gateway session watcher for real-time SSE updates
try:
from api.gateway_watcher import start_watcher
start_watcher()
except Exception as e:
print(f'[!!] WARNING: Gateway watcher failed to start: {e}', flush=True)
httpd = QuietHTTPServer((HOST, PORT), Handler)
# ── TLS/HTTPS setup (optional) ─────────────────────────────────────────
from api.config import TLS_ENABLED, TLS_CERT, TLS_KEY
scheme = 'https' if TLS_ENABLED else 'http'
if TLS_ENABLED:
try:
import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(TLS_CERT, TLS_KEY)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
print(f' TLS enabled: cert={TLS_CERT}, key={TLS_KEY}', flush=True)
except Exception as e:
print(f'[!!] WARNING: TLS setup failed ({e}), falling back to HTTP', flush=True)
scheme = 'http'
print(f' Hermes Web UI listening on {scheme}://{HOST}:{PORT}', flush=True)
if HOST == '127.0.0.1' or within_container:
print(f' Remote access: ssh -N -L {PORT}:127.0.0.1:{PORT} <user>@<your-server>', flush=True)
print(f' Then open: http://localhost:{PORT}', flush=True)
print(f' Then open: {scheme}://localhost:{PORT}', flush=True)
print('', flush=True)
httpd.serve_forever()
try:
httpd.serve_forever()
finally:
# Stop the gateway watcher on shutdown
try:
from api.gateway_watcher import stop_watcher
stop_watcher()
except Exception:
logger.debug("Failed to stop gateway watcher during shutdown")
if __name__ == '__main__':
main()

265
start.sh
View File

@@ -1,260 +1,25 @@
#!/usr/bin/env bash
# ============================================================
# Hermes Web UI -- portable bootstrap
# Usage: ./start.sh [port]
#
# One-command startup. Discovers your Hermes install, sets up
# a local virtualenv if needed, installs dependencies, then
# launches the server and prints everything you need to know.
#
# Override any step with environment variables:
# HERMES_WEBUI_AGENT_DIR path to hermes-agent checkout
# HERMES_WEBUI_PYTHON python executable to use
# HERMES_WEBUI_PORT port to listen on (default: 8787)
# HERMES_WEBUI_HOST bind address (default: 127.0.0.1)
# HERMES_HOME override ~/.hermes base
# HERMES_WEBUI_STATE_DIR override state directory
# ============================================================
set -euo pipefail
# ── Load .env if present (machine-local overrides, not committed) ─────────────
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "${_SCRIPT_DIR}/.env" ]]; then
set -a
# shellcheck source=/dev/null
source "${_SCRIPT_DIR}/.env"
set +a
fi
# ── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
ok() { echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[!!]${RESET} $*"; }
die() { echo -e "${RED}[XX]${RESET} $*" >&2; exit 1; }
info() { echo -e "${CYAN}[--]${RESET} $*"; }
hdr() { echo -e "\n${BOLD}$*${RESET}"; }
# ── Resolve repo root (the directory this script lives in) ───────────────────
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
info "Repo root: ${REPO_ROOT}"
# ── Port ─────────────────────────────────────────────────────────────────────
PORT="${1:-${HERMES_WEBUI_PORT:-8787}}"
export HERMES_WEBUI_PORT="${PORT}"
# ── Python discovery ─────────────────────────────────────────────────────────
hdr "Discovering Python..."
_find_python() {
# 1. Explicit env var
if [[ -n "${HERMES_WEBUI_PYTHON:-}" ]]; then
echo "${HERMES_WEBUI_PYTHON}"; return
fi
# 2. Agent venv (discovered below -- call again after agent dir found)
# (handled after agent dir discovery)
# 3. Local .venv in repo
if [[ -x "${REPO_ROOT}/.venv/bin/python" ]]; then
echo "${REPO_ROOT}/.venv/bin/python"; return
fi
# 4. System python3
if command -v python3 &>/dev/null; then
echo "$(command -v python3)"; return
fi
echo ""
}
PYTHON="$(_find_python)"
# ── Hermes agent discovery ────────────────────────────────────────────────────
hdr "Discovering Hermes agent..."
HERMES_HOME="${HERMES_HOME:-${HOME}/.hermes}"
AGENT_DIR=""
_find_agent() {
local candidates=(
"${HERMES_WEBUI_AGENT_DIR:-}"
"${HERMES_HOME}/hermes-agent"
"${REPO_ROOT}/../hermes-agent"
"${HOME}/.hermes/hermes-agent"
"${HOME}/hermes-agent"
)
for d in "${candidates[@]}"; do
[[ -z "$d" ]] && continue
d="$(cd "${d}" 2>/dev/null && pwd || true)"
if [[ -n "$d" && -f "${d}/run_agent.py" ]]; then
echo "$d"; return
fi
done
echo ""
}
AGENT_DIR="$(_find_agent)"
if [[ -n "${AGENT_DIR}" ]]; then
ok "Hermes agent: ${AGENT_DIR}"
export HERMES_WEBUI_AGENT_DIR="${AGENT_DIR}"
# Now that we have agent dir, prefer its venv if we don't already have a python
if [[ -z "${HERMES_WEBUI_PYTHON:-}" && -x "${AGENT_DIR}/venv/bin/python" ]]; then
PYTHON="${AGENT_DIR}/venv/bin/python"
fi
else
warn "Hermes agent not found. Agent features will not work."
warn "Fix with: export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent"
if [[ -f "${REPO_ROOT}/.env" ]]; then
set -a
# shellcheck source=/dev/null
source "${REPO_ROOT}/.env"
set +a
fi
if [[ -n "${PYTHON}" ]]; then
ok "Python: ${PYTHON} ($(${PYTHON} --version 2>&1))"
else
warn "No Python found. Attempting to install..."
if command -v apt-get &>/dev/null; then
sudo apt-get install -y python3 python3-venv python3-pip
elif command -v brew &>/dev/null; then
brew install python3
else
die "Could not find or install Python. Please install Python 3.8+ and re-run."
fi
PYTHON="${HERMES_WEBUI_PYTHON:-}"
if [[ -z "${PYTHON}" ]]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON="$(command -v python3)"
ok "Python installed: ${PYTHON}"
elif command -v python >/dev/null 2>&1; then
PYTHON="$(command -v python)"
else
echo "[XX] Python 3 is required to run bootstrap.py" >&2
exit 1
fi
fi
# ── Minimum Python version check ─────────────────────────────────────────────
PY_VER="$(${PYTHON} -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
PY_MAJOR="$(echo "${PY_VER}" | cut -d. -f1)"
PY_MINOR="$(echo "${PY_VER}" | cut -d. -f2)"
if [[ "${PY_MAJOR}" -lt 3 || ( "${PY_MAJOR}" -eq 3 && "${PY_MINOR}" -lt 8 ) ]]; then
die "Python 3.8+ required. Found: ${PY_VER}"
fi
# ── Dependency check / local venv setup ──────────────────────────────────────
hdr "Checking dependencies..."
VENV_NEEDED=false
VENV_PATH="${REPO_ROOT}/.venv"
# If the chosen python is already the agent venv, its deps are already installed.
# If it is a system python, check if we can import the webui deps, create a local
# .venv if not.
_check_deps() {
"${PYTHON}" -c "import yaml" 2>/dev/null
}
if ! _check_deps; then
info "PyYAML not found in ${PYTHON}. Creating local .venv..."
if [[ ! -d "${VENV_PATH}" ]]; then
"${PYTHON}" -m venv "${VENV_PATH}" || die "Failed to create virtualenv at ${VENV_PATH}"
fi
VENV_PY="${VENV_PATH}/bin/python"
"${VENV_PY}" -m pip install --quiet --upgrade pip
if [[ -f "${REPO_ROOT}/requirements.txt" ]]; then
info "Installing from requirements.txt..."
"${VENV_PY}" -m pip install --quiet -r "${REPO_ROOT}/requirements.txt"
else
info "Installing minimal deps (pyyaml)..."
"${VENV_PY}" -m pip install --quiet pyyaml
fi
PYTHON="${VENV_PY}"
ok "Local venv ready: ${VENV_PATH}"
else
ok "Dependencies satisfied."
fi
# ── Kill any stale instance on the same port ─────────────────────────────────
hdr "Checking for existing instances..."
EXISTING=$(lsof -ti tcp:"${PORT}" 2>/dev/null || true)
if [[ -n "${EXISTING}" ]]; then
warn "Killing existing process on port ${PORT} (PID ${EXISTING})"
kill "${EXISTING}" 2>/dev/null || true
sleep 0.5
fi
# Also kill any server.py process from this repo
pkill -f "${REPO_ROOT}/server.py" 2>/dev/null || true
# ── Set up working directory for Hermes imports ───────────────────────────────
# server.py / api/config.py inject agent dir into sys.path at import time,
# but we also cd into the agent dir so relative imports in run_agent work.
if [[ -n "${AGENT_DIR}" ]]; then
WORKDIR="${AGENT_DIR}"
else
WORKDIR="${REPO_ROOT}"
fi
# ── Launch ───────────────────────────────────────────────────────────────────
hdr "Starting Hermes Web UI..."
LOG="/tmp/hermes-webui-${PORT}.log"
export HERMES_WEBUI_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
export HERMES_WEBUI_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${HERMES_HOME}/webui}"
nohup "${PYTHON}" "${REPO_ROOT}/server.py" \
> "${LOG}" 2>&1 &
PID=$!
echo -e "\n${CYAN} PID ${PID} starting...${RESET}"
sleep 1.5
# ── Health check ─────────────────────────────────────────────────────────────
HEALTH_URL="http://${HERMES_WEBUI_HOST:-127.0.0.1}:${PORT}/health"
MAX_WAIT=15
ELAPSED=0
while [[ $ELAPSED -lt $MAX_WAIT ]]; do
if curl -sf "${HEALTH_URL}" | grep -q '"status"' 2>/dev/null; then
break
fi
sleep 0.5
ELAPSED=$((ELAPSED + 1))
done
if ! curl -sf "${HEALTH_URL}" | grep -q '"status"' 2>/dev/null; then
warn "Health check did not pass within ${MAX_WAIT}s. Check log:"
tail -20 "${LOG}"
echo ""
warn "Server may still be starting. Try: curl ${HEALTH_URL}"
else
ok "Server is healthy."
fi
# ── Print access instructions ─────────────────────────────────────────────────
BIND_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
echo ""
echo -e "${BOLD}========================================${RESET}"
echo -e "${GREEN} Hermes Web UI is running${RESET}"
echo -e "${BOLD}========================================${RESET}"
echo ""
if [[ "${BIND_HOST}" == "127.0.0.1" || "${BIND_HOST}" == "localhost" ]]; then
# Server is bound to loopback -- detect if we are on a remote machine
# by checking if $SSH_CLIENT or $SSH_TTY is set
if [[ -n "${SSH_CLIENT:-}" || -n "${SSH_TTY:-}" ]]; then
SERVER_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo "<your-server-ip>")"
echo -e " You are on a remote machine. To access from your local browser:"
echo ""
echo -e " ${CYAN}ssh -N -L ${PORT}:127.0.0.1:${PORT} \$(whoami)@${SERVER_IP}${RESET}"
echo ""
echo -e " Then open: ${BOLD}http://localhost:${PORT}${RESET}"
else
echo -e " Open: ${BOLD}http://localhost:${PORT}${RESET}"
fi
else
echo -e " Open: ${BOLD}http://${BIND_HOST}:${PORT}${RESET}"
fi
echo ""
echo -e " Log: ${LOG}"
echo -e " PID: ${PID}"
echo ""
exec "${PYTHON}" "${REPO_ROOT}/bootstrap.py" --no-browser "$@"

View File

@@ -2,13 +2,136 @@ async function cancelStream(){
const streamId = S.activeStreamId;
if(!streamId) return;
try{
await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'});
const btn=$('btnCancel');if(btn)btn.style.display='none';
setStatus('Cancelling…');
}catch(e){setStatus('Cancel failed: '+e.message);}
await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{credentials:'include'});
}catch(e){/* cancel request failed — cleanup below still runs */}
// Clear status unconditionally after the cancel request completes.
// The SSE cancel event may also fire, but if the connection is already
// closed it won't arrive — so we handle cleanup here as the guaranteed path.
S.activeStreamId=null;
setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
else setStatus('');
}
// ── Mobile navigation ──────────────────────────────────────────────────────
let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview'
function _isCompactWorkspaceViewport(){
return window.matchMedia('(max-width: 900px)').matches;
}
function _workspacePanelEls(){
return {
layout: document.querySelector('.layout'),
panel: document.querySelector('.rightpanel'),
toggleBtn: $('btnWorkspacePanelToggle'),
collapseBtn: $('btnCollapseWorkspacePanel'),
};
}
function _hasWorkspacePreviewVisible(){
const preview=$('previewArea');
return !!(preview&&preview.classList.contains('visible'));
}
function _setWorkspacePanelMode(mode){
const {layout,panel}= _workspacePanelEls();
if(!layout||!panel)return;
_workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
const open=_workspacePanelMode!=='closed';
document.documentElement.dataset.workspacePanel=open?'open':'closed';
// Persist open/closed across refreshes (browse/preview → open; closed → closed)
// Do NOT overwrite the user's "keep open" preference — only track runtime state
// so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting.
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
layout.classList.toggle('workspace-panel-collapsed',!open);
if(_isCompactWorkspaceViewport()){
panel.classList.toggle('mobile-open',open);
}else{
panel.classList.remove('mobile-open');
}
syncWorkspacePanelUI();
}
function syncWorkspacePanelState(){
const hasPreview=_hasWorkspacePreviewVisible();
if(hasPreview){
if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
else syncWorkspacePanelUI();
return;
}
if(!S.session){
// No active session — if the panel was explicitly opened (browse mode), keep it
// open so the workspace pane doesn't vanish on a fresh-page or empty-session boot.
// The file tree will show the "no workspace" placeholder naturally via renderFileTree().
// Only force-close if the mode is 'preview' (file preview without a session is invalid).
if(_workspacePanelMode==='preview') _setWorkspacePanelMode('closed');
else syncWorkspacePanelUI();
return;
}
_setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode);
}
function openWorkspacePanel(mode='browse'){
if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible()&&!S._profileDefaultWorkspace)return;
if(mode==='preview'&&_workspacePanelMode==='browse'){
syncWorkspacePanelUI();
return;
}
_setWorkspacePanelMode(mode);
}
function closeWorkspacePanel(){
_setWorkspacePanelMode('closed');
}
function ensureWorkspacePreviewVisible(){
if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
else syncWorkspacePanelUI();
}
function handleWorkspaceClose(){
if(_hasWorkspacePreviewVisible()){
clearPreview();
return;
}
closeWorkspacePanel();
}
function syncWorkspacePanelUI(){
const {layout,panel,toggleBtn,collapseBtn}= _workspacePanelEls();
if(!layout||!panel)return;
const desktopOpen=_workspacePanelMode!=='closed';
const mobileOpen=panel.classList.contains('mobile-open');
const isCompact=_isCompactWorkspaceViewport();
const isOpen=isCompact?mobileOpen:desktopOpen;
const canBrowse=!!S.session||_hasWorkspacePreviewVisible()||!!(S._profileDefaultWorkspace);
const hasPreview=_hasWorkspacePreviewVisible();
if(toggleBtn){
toggleBtn.classList.toggle('active',isOpen);
toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false');
toggleBtn.title=isOpen?'Hide workspace panel':'Show workspace panel';
toggleBtn.disabled=!canBrowse;
}
if(collapseBtn){
collapseBtn.title=isCompact?'Close workspace panel':'Hide workspace panel';
}
const hasSession=!!S.session;
['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{
const el=$(id);
if(el)el.disabled=!hasSession;
});
const clearBtn=$('btnClearPreview');
if(clearBtn){
clearBtn.disabled=!isOpen;
clearBtn.title=hasPreview?'Close preview':'Hide workspace panel';
// On desktop, only show the X button when a file preview is open.
// In browse mode the chevron (btnCollapseWorkspacePanel) already serves
// as the close control, so showing both produces a duplicate X.
if(!isCompact) clearBtn.style.display=hasPreview?'':'none';
}
}
function toggleMobileSidebar(){
const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay');
@@ -24,16 +147,22 @@ function closeMobileSidebar(){
if(overlay)overlay.classList.remove('visible');
}
function toggleMobileFiles(){
const panel=document.querySelector('.rightpanel');
toggleWorkspacePanel();
}
function toggleWorkspacePanel(force){
const {panel}= _workspacePanelEls();
if(!panel)return;
panel.classList.toggle('mobile-open');
const currentlyOpen=_workspacePanelMode!=='closed';
const nextOpen=typeof force==='boolean'?force:!currentlyOpen;
if(!nextOpen){
closeWorkspacePanel();
return;
}
const nextMode=_hasWorkspacePreviewVisible()?'preview':'browse';
openWorkspacePanel(nextMode);
}
function mobileSwitchPanel(name){
// Switch the panel content view
switchPanel(name);
// For non-chat panels (tasks, skills, memory, spaces), open the sidebar
// so the panel is visible. For 'chat', the content is in the main area —
// just close the sidebar so the chat view is unobstructed.
if(name==='chat'){
closeMobileSidebar();
} else {
@@ -44,98 +173,226 @@ function mobileSwitchPanel(name){
if(overlay)overlay.classList.add('visible');
}
}
// Update bottom nav active state
document.querySelectorAll('.mobile-nav-btn').forEach(btn=>{
btn.classList.toggle('active',btn.dataset.panel===name);
});
}
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
$('btnSend').onclick=()=>{
if(typeof handleComposerPrimaryAction==='function') return handleComposerPrimaryAction();
if(window._micActive){
window._micPendingSend=true;
_stopMic();
return;
}
send();
};
$('btnAttach').onclick=()=>$('fileInput').click();
// ── Voice input (Web Speech API) ─────────────────────────────────────────
// ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
(function(){
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden
const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
// Persist SR failure across reloads (e.g. Tailscale/network error)
const _micForceMediaRecorderKey='mic_force_mediarecorder';
let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1';
const btn=$('btnMic');
const status=$('micStatus');
const ta=$('msg');
btn.style.display=''; // Show button — browser supports speech
const recognition=new SpeechRecognition();
recognition.continuous=false;
recognition.interimResults=true;
recognition.lang='en-US';
const statusText=status?status.querySelector('.status-text'):null;
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null;
let mediaRecorder=null;
let mediaStream=null;
let audioChunks=[];
let _finalText='';
let _prefix='';
let _isRecording=false;
function _setRecording(on){
window._micActive=on;
btn.classList.toggle('recording',on);
status.style.display=on?'':'none';
if(statusText) statusText.textContent=on?'Listening':'Listening';
if(!on){ _finalText=''; _prefix=''; }
}
recognition.onstart=()=>{ _finalText=''; };
recognition.onresult=(event)=>{
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
// Append to whatever was already in the textarea before mic started
ta.value=_prefix+(final||interim);
autoResize();
};
recognition.onend=()=>{
// Commit: prefix + final transcription; trim trailing space if prefix was non-empty
const committed=_finalText
function _commitTranscript(text){
const clean=(text||'').trim();
const committed=clean
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
? _prefix+' '+_finalText.trimStart()
: _prefix+_finalText)
: ta.value; // no speech detected — leave whatever is there
_setRecording(false);
? _prefix+' '+clean.trimStart()
: _prefix+clean)
: ta.value;
ta.value=committed;
autoResize();
};
if(window._micPendingSend){
window._micPendingSend=false;
send();
}
}
recognition.onerror=(event)=>{
_setRecording(false);
const msgs={
'not-allowed':'Microphone access denied. Check browser permissions.',
'no-speech':'No speech detected. Try again.',
'network':'Speech recognition unavailable.',
};
showToast(msgs[event.error]||'Voice input error: '+event.error);
};
async function _transcribeBlob(blob){
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
const form=new FormData();
form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
setComposerStatus('Transcribing…');
try{
const res=await fetch('api/transcribe',{method:'POST',body:form});
const data=await res.json().catch(()=>({}));
if(!res.ok) throw new Error(data.error||'Transcription failed');
_commitTranscript(data.transcript||'');
}catch(err){
window._micPendingSend=false;
showToast(err.message||t('mic_network'));
}finally{
setComposerStatus('');
}
}
function _stopTracks(){
if(mediaStream){
mediaStream.getTracks().forEach(track=>track.stop());
mediaStream=null;
}
}
function _stopMic(){
if(window._micActive){ recognition.stop(); }
if(!window._micActive) return;
if(recognition){
recognition.stop();
return;
}
if(mediaRecorder&&mediaRecorder.state!=='inactive'){
mediaRecorder.stop();
return;
}
_setRecording(false);
_stopTracks();
}
window._stopMic=_stopMic; // expose for send-guard above
btn.onclick=()=>{
if(recognition && !_forceMediaRecorder){
recognition.continuous=false;
recognition.interimResults=true;
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
recognition.onstart=()=>{ _finalText=''; };
recognition.onresult=(event)=>{
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
ta.value=_prefix+(final||interim);
autoResize();
};
recognition.onend=()=>{
const committed=_finalText
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
? _prefix+' '+_finalText.trimStart()
: _prefix+_finalText)
: ta.value;
_setRecording(false);
ta.value=committed;
autoResize();
if(window._micPendingSend){
window._micPendingSend=false;
send();
}
};
recognition.onerror=(event)=>{
_setRecording(false);
window._micPendingSend=false;
_isRecording=false;
if(event.error==='network'||event.error==='not-allowed'){
// Persist SR failure: next reload will skip SpeechRecognition
localStorage.setItem(_micForceMediaRecorderKey,'1');
_forceMediaRecorder=true;
recognition=null;
}
const msgs={
'not-allowed':t('mic_denied'),
'no-speech':t('mic_no_speech'),
'network':t('mic_network'),
};
showToast(msgs[event.error]||t('mic_error')+event.error);
};
}
btn.onclick=async()=>{
// Race-condition guard: ignore rapid double-clicks
if(_isRecording){
_stopMic();
_isRecording=false;
return;
}
if(window._micActive){
recognition.stop();
// _setRecording(false) will be called by onend
} else {
_finalText='';
// Snapshot existing textarea content so we append rather than replace
_prefix=ta.value;
_stopMic();
return;
}
_isRecording=true;
_finalText='';
_prefix=ta.value;
if(recognition && !_forceMediaRecorder){
recognition.start();
_setRecording(true);
return;
}
if(!_canRecordAudio){
_isRecording=false;
showToast(t('mic_network'));
return;
}
try{
mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
audioChunks=[];
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
mediaRecorder.onerror=()=>{
_isRecording=false;
_setRecording(false);
window._micPendingSend=false;
_stopTracks();
showToast(t('mic_network'));
};
mediaRecorder.onstop=async()=>{
_isRecording=false;
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
_setRecording(false);
_stopTracks();
if(blob.size){ await _transcribeBlob(blob); }
else if(window._micPendingSend){
window._micPendingSend=false;
}
};
mediaRecorder.start();
_setRecording(true);
}catch(err){
_isRecording=false;
window._micPendingSend=false;
_stopTracks();
showToast(t('mic_denied'));
}
};
})();
window._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
$('btnNewChat').onclick=async()=>{
// If the current session has no messages, just focus the composer rather than
// creating another empty session that will clutter the sidebar list (#1171).
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();closeMobileSidebar();return;}
await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
};
$('btnDownload').onclick=()=>{
if(!S.session)return;
const blob=new Blob([transcript()],{type:'text/markdown'});
@@ -160,14 +417,16 @@ $('importFileInput').onchange=async(e)=>{
if(res.ok&&res.session){
await loadSession(res.session.session_id);
await renderSessionList();
showToast('Session imported');
if(_currentPanel==='settings') switchPanel('chat');
showToast(t('session_imported'));
}
}catch(err){
showToast('Import failed: '+(err.message||'Invalid JSON'));
showToast(t('import_failed')+(err.message||t('import_invalid_json')));
}
};
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
function clearPreview(){
const closePanelAfter=_workspacePanelMode==='preview';
const pa=$('previewArea');if(pa)pa.classList.remove('visible');
const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';}
const pm=$('previewMd');if(pm)pm.innerHTML='';
@@ -175,24 +434,48 @@ function clearPreview(){
const pp=$('previewPathText');if(pp)pp.textContent='';
const ft=$('fileTree');if(ft)ft.style.display='';
_previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
// Restore directory breadcrumb after closing file preview
if(typeof renderBreadcrumb==='function') renderBreadcrumb();
if(closePanelAfter)closeWorkspacePanel();
else syncWorkspacePanelUI();
}
$('btnClearPreview').onclick=clearPreview;
$('btnClearPreview').onclick=handleWorkspaceClose;
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
$('modelSelect').onchange=async()=>{
if(!S.session)return;
const selectedModel=$('modelSelect').value;
if(typeof closeModelDropdown==='function') closeModelDropdown();
localStorage.setItem('hermes-webui-model', selectedModel);
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
S.session.model=selectedModel;syncTopbar();
S.session.model=selectedModel;
if(typeof syncModelChip==='function') syncModelChip();
syncTopbar();
// Warn if selected model belongs to a different provider than what Hermes is configured for
if(typeof _checkProviderMismatch==='function'){
const warn=_checkProviderMismatch(selectedModel);
if(warn&&typeof showToast==='function') showToast(warn,4000);
}
// Clarify scope: composer model changes are session-local, not the global default.
if(typeof showToast==='function'){
showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
}
};
$('msg').addEventListener('input',()=>{
autoResize();
updateSendBtn();
const text=$('msg').value;
if(text.startsWith('/')&&text.indexOf('\n')===-1){
const prefix=text.slice(1);
const matches=getMatchingCommands(prefix);
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
if(typeof getSlashAutocompleteMatches==='function'){
getSlashAutocompleteMatches(text).then(matches=>{
if(($('msg').value||'')!==text) return;
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
});
}else{
const prefix=text.slice(1);
const matches=getMatchingCommands(prefix);
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
}
if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
} else {
hideCmdDropdown();
}
@@ -206,11 +489,22 @@ $('msg').addEventListener('keydown',e=>{
if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;}
if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;}
if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;}
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();selectCmdDropdownItem();return;}
if(e.key==='Enter'&&!e.shiftKey){
if(e.isComposing){return;}
e.preventDefault();
selectCmdDropdownItem();
return;
}
}
// Send key: respect user preference
// Send key: respect user preference.
// On touch-primary devices (software keyboard), default to Enter = newline
// since there's no physical Shift key. Users send via the Send button.
// The 'ctrl+enter' setting also uses this behavior (Enter = newline).
// Users can override in Settings by explicitly choosing 'enter' mode.
if(e.key==='Enter'){
if(window._sendKey==='ctrl+enter'){
if(e.isComposing){return;}
const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter';
if(window._sendKey==='ctrl+enter'||_mobileDefault){
if(e.ctrlKey||e.metaKey){e.preventDefault();send();}
} else {
if(!e.shiftKey){e.preventDefault();send();}
@@ -219,14 +513,33 @@ $('msg').addEventListener('keydown',e=>{
});
// B14: Cmd/Ctrl+K creates a new chat from anywhere
document.addEventListener('keydown',async e=>{
// Enter on approval card = Allow once (when a button inside the card is focused or
// card is visible and focus is not on an input/textarea/select)
if(e.key==='Enter'&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey){
const card=$('approvalCard');
const tag=(document.activeElement||{}).tagName||'';
if(card&&card.classList.contains('visible')&&tag!=='TEXTAREA'&&tag!=='INPUT'&&tag!=='SELECT'){
e.preventDefault();
if(typeof respondApproval==='function') respondApproval('once');
return;
}
}
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
e.preventDefault();
if(!S.busy){await newSession();await renderSessionList();$('msg').focus();}
// If the current session has no messages, just focus the composer rather than
// creating another empty session that will clutter the sidebar list (#1171).
if(S.session&&(S.session.message_count||0)===0){$('msg').focus();return;}
if(!S.busy){await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();}
}
if(e.key==='Escape'){
// Close settings overlay if open
const settingsOverlay=$('settingsOverlay');
if(settingsOverlay&&settingsOverlay.style.display!=='none'){toggleSettings();return;}
// Close onboarding overlay if open (skip/dismiss the wizard)
const onboardingOverlay=$('onboardingOverlay');
if(onboardingOverlay&&onboardingOverlay.style.display!=='none'){
if(typeof skipOnboarding==='function') skipOnboarding();
return;
}
// Close settings panel if active
if(_currentPanel==='settings'){_closeSettingsPanel();return;}
// Close workspace dropdown
closeWsDropdown();
// Clear session search
@@ -251,17 +564,21 @@ $('msg').addEventListener('paste',e=>{
return new File([blob],`screenshot-${Date.now()}.${ext}`,{type:i.type});
});
addFiles(files);
setStatus(`Image pasted: ${files.map(f=>f.name).join(', ')}`);
setStatus(t('image_pasted')+files.map(f=>f.name).join(', '));
});
document.querySelectorAll('.suggestion').forEach(btn=>{
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
});
window.addEventListener('resize',()=>{
syncWorkspacePanelState();
});
// Boot: restore last session or start fresh
// ── Resizable panels ──────────────────────────────────────────────────────
(function(){
const SIDEBAR_MIN=180, SIDEBAR_MAX=420;
const PANEL_MIN=180, PANEL_MAX=500;
const PANEL_MIN=180, PANEL_MAX=1200;
function initResize(handleId, targetEl, edge, minW, maxW, storageKey){
const handle = $(handleId);
@@ -306,33 +623,351 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
};
})();
// ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
const _SKINS=[
{name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']},
{name:'Ares', colors:['#FF4444','#CC3333','#992222']},
{name:'Mono', colors:['#CCCCCC','#999999','#666666']},
{name:'Slate', colors:['#334155','#475569','#64748b']},
{name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
];
const _VALID_THEMES=new Set(['system','dark','light']);
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
const _LEGACY_THEME_MAP={
slate:{theme:'dark',skin:'slate'},
solarized:{theme:'dark',skin:'poseidon'},
monokai:{theme:'dark',skin:'sisyphus'},
nord:{theme:'dark',skin:'slate'},
oled:{theme:'dark',skin:'default'},
};
let _systemThemeMq=null;
let _onSystemThemeChange=null;
function _normalizeAppearance(theme,skin){
const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():'';
const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():'';
const legacy=_LEGACY_THEME_MAP[rawTheme];
const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark');
const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default');
return {theme:nextTheme,skin:nextSkin};
}
function _setResolvedTheme(isDark){
document.documentElement.classList.toggle('dark',!!isDark);
const link=document.getElementById('prism-theme');
if(!link) return;
const want=isDark
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
// No SRI integrity on theme CSS — jsdelivr edge nodes serve different
// digests for the same pinned version, causing intermittent blocking (#1100).
if(link.href!==want){ link.integrity=''; link.href=want; }
}
function _applyTheme(name){
const normalized=_normalizeAppearance(name,'default');
if(_systemThemeMq&&_onSystemThemeChange){
_systemThemeMq.removeEventListener('change',_onSystemThemeChange);
_systemThemeMq=null;
_onSystemThemeChange=null;
}
if(normalized.theme==='system'){
_systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)');
_onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches);
_setResolvedTheme(_systemThemeMq.matches);
_systemThemeMq.addEventListener('change',_onSystemThemeChange);
return;
}
_setResolvedTheme(normalized.theme==='dark');
}
function _applySkin(name){
const key=(name||'default').toLowerCase();
if(key==='default') delete document.documentElement.dataset.skin;
else document.documentElement.dataset.skin=key;
}
function _pickTheme(name){
const currentSkin=localStorage.getItem('hermes-skin');
const appearance=_normalizeAppearance(name,currentSkin);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
_syncThemePicker(appearance.theme);
_syncSkinPicker(appearance.skin);
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
const hidden=$('settingsTheme');
if(hidden) hidden.value=appearance.theme;
const skinHidden=$('settingsSkin');
if(skinHidden) skinHidden.value=appearance.skin;
}
function _pickSkin(name){
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
_syncThemePicker(appearance.theme);
_syncSkinPicker(appearance.skin);
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
const hidden=$('settingsSkin');
if(hidden) hidden.value=appearance.skin;
const themeHidden=$('settingsTheme');
if(themeHidden) themeHidden.value=appearance.theme;
}
function _syncThemePicker(active){
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
btn.classList.toggle('active',btn.dataset.themeVal===active);
btn.style.borderColor='';
btn.style.boxShadow='';
});
}
function _syncSkinPicker(active){
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
btn.classList.toggle('active',btn.dataset.skinVal===active);
btn.style.borderColor='';
btn.style.boxShadow='';
});
}
function _applyFontSize(size){
if(size&&size!=='default'){
document.documentElement.dataset.fontSize=size;
} else {
delete document.documentElement.dataset.fontSize;
}
}
function _pickFontSize(size){
localStorage.setItem('hermes-font-size',size);
_applyFontSize(size);
_syncFontSizePicker(size);
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
const hidden=$('settingsFontSize');
if(hidden) hidden.value=size;
}
function _syncFontSizePicker(active){
document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
btn.classList.toggle('active',btn.dataset.fontSizeVal===(active||'default'));
btn.style.borderColor='';
btn.style.boxShadow='';
});
}
function _buildSkinPicker(activeSkin){
const grid=$('skinPickerGrid');
if(!grid) return;
grid.innerHTML='';
for(const skin of _SKINS){
const key=skin.name.toLowerCase();
const btn=document.createElement('button');
btn.type='button';
btn.className='skin-pick-btn';
btn.dataset.skinVal=key;
btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
btn.onclick=()=>_pickSkin(skin.name);
const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
grid.appendChild(btn);
}
_syncSkinPicker((activeSkin||'default').toLowerCase());
}
function applyBotName(){
// Prefer profile name over global bot_name for personalised placeholder.
// If activeProfile is set and not 'default', use it (capitalised).
// Falls back to window._botName (global bot_name setting) or 'Hermes'.
let name;
if(S.activeProfile && S.activeProfile!=='default'){
name=S.activeProfile.charAt(0).toUpperCase()+S.activeProfile.slice(1);
}else{
name=window._botName||'Hermes';
}
document.title=name;
const sidebarH1=document.querySelector('.sidebar-header h1');
if(sidebarH1) sidebarH1.textContent=name;
const logo=document.querySelector('.sidebar-header .logo');
if(logo) logo.textContent=name.charAt(0).toUpperCase();
const topbarTitle=$('topbarTitle');
if(topbarTitle && (!S.session)) topbarTitle.textContent=name;
const msg=$('msg');
if(msg) msg.placeholder='Message '+name+'\u2026';
}
(async()=>{
// Load send key preference
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
let _bootSettings={};
try{
const s=await api('/api/settings');
_bootSettings=s;
window._sendKey=s.send_key||'enter';
window._showTokenUsage=!!s.show_token_usage;
window._showCliSessions=!!s.show_cli_sessions;
window._soundEnabled=!!s.sound_enabled;
window._notificationsEnabled=!!s.notifications_enabled;
window._showThinking=s.show_thinking!==false;
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
window._busyInputMode=(s.busy_input_mode||'queue');
window._botName=s.bot_name||'Hermes';
if(s.default_model) window._defaultModel=s.default_model;
// Persist default workspace so the blank new-chat page can show it
// and workspace actions (New file/folder) work before the first session (#804).
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
const appearance=_normalizeAppearance(s.theme,s.skin);
localStorage.setItem('hermes-theme',appearance.theme);
_applyTheme(appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applySkin(appearance.skin);
if(typeof setLocale==='function'){
const _lang=typeof resolvePreferredLocale==='function'
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
: (s.language || localStorage.getItem('hermes-lang') || 'en');
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
applyBotName();
}catch(e){
window._sendKey='enter';
window._showTokenUsage=false;
window._showCliSessions=false;
window._soundEnabled=false;
window._notificationsEnabled=false;
window._showThinking=true;
window._sidebarDensity='compact';
window._busyInputMode='queue';
window._botName='Hermes';
_bootSettings={check_for_updates:false};
if(typeof setLocale==='function'){
const _lang=typeof resolvePreferredLocale==='function'
? resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))
: (localStorage.getItem('hermes-lang') || 'en');
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
applyBotName();
}
// Non-blocking update check (fire-and-forget, once per tab session)
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':'');
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
}
// Fetch active profile
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
// Update profile chip label immediately
const profileLabel=$('profileChipLabel');
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
// Fetch available models from server and populate dropdown dynamically
await populateModelDropdown();
// Restore last-used model preference
const savedModel=localStorage.getItem('hermes-webui-model');
if(savedModel && $('modelSelect')){
$('modelSelect').value=savedModel;
// If the value didn't take (model not in list), clear the bad pref
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
}
// Fetch available models without blocking session restore. The static HTML
// options are enough for first paint; the dynamic provider list can settle
// after the saved session is visible.
const _modelDropdownReady=populateModelDropdown().then(()=>{
const savedModel=localStorage.getItem('hermes-webui-model');
if(savedModel && $('modelSelect')){
$('modelSelect').value=savedModel;
// If the value didn't take (model not in list), clear the bad pref
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
else if(typeof syncModelChip==='function') syncModelChip();
}
if(S.session) syncTopbar();
}).catch(()=>{});
window._modelDropdownReady=_modelDropdownReady;
// Pre-load workspace list so sidebar name is correct from first render
await loadWorkspaceList();
await loadOnboardingWizard();
_initResizePanels();
// Workspace panel restore happens AFTER loadSession so we know if
// the session has a workspace — prevents the snap-open-then-closed flash (#576).
// Fix #822: clear any browser-restored value before first render. This
// covers fresh page loads and reloads. The bfcache restore case is handled
// separately below by a `pageshow` listener — the async IIFE here does NOT
// re-run when the browser restores the page from bfcache.
const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
// Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load)
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
const saved=localStorage.getItem('hermes-webui-session');
if(saved){
try{await loadSession(saved);await renderSessionList();await checkInflightOnBoot(saved);return;}
try{
await loadSession(saved);
// If the restored session has no messages it is an ephemeral scratch pad —
// treat the page as a fresh start rather than resuming a blank conversation.
// loadSession() already ran, so loadDir() has populated the workspace file tree.
// Do NOT remove the session ID from localStorage — keeping it means every
// subsequent refresh will also run loadSession() → loadDir() → files stay visible.
// Removing it here caused the file tree to go blank on the second refresh
// because the "no saved session" path never calls loadDir (#workspace-files).
if(S.session && (S.session.message_count||0) === 0){
S.session=null; S.messages=[];
S._bootReady=true;
// Restore panel pref before syncing so the workspace panel stays visible
// even though there is no active session (#workspace-persist).
const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
if(_ephPanelPref) _workspacePanelMode='browse';
syncTopbar();syncWorkspacePanelState();
$('emptyState').style.display='';
await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
return;
}
// Restore the panel from localStorage when the session has a workspace.
// Preference key takes priority over runtime state so that closing
// the panel via toolbar X doesn't suppress the "keep open" setting.
const panelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
if(S.session&&S.session.workspace&&panelPref){
_workspacePanelMode='browse';
}
S._bootReady=true;
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
catch(e){localStorage.removeItem('hermes-webui-session');}
}
// no saved session - show empty state, wait for user to hit +
S._bootReady=true;
syncTopbar();
// Restore panel pref so the workspace panel stays visible on a fresh load if the
// user had it open during their last session (#workspace-persist).
const _freshPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
if(_freshPanelPref) _workspacePanelMode='browse';
syncWorkspacePanelState();
$('emptyState').style.display='';
await renderSessionList();
// Start real-time gateway session sync if setting is enabled
if(typeof startGatewaySSE==='function') startGatewaySSE();
})();
// Fix #822 (bfcache path): when the browser restores the page from the
// back-forward cache, the async boot IIFE above does NOT re-run, but the
// DOM — including any stale value in #sessionSearch — IS restored. A
// prior search string would silently hide all sessions via the filter in
// renderSessionListFromCache(). Clear the field and re-run the full layout
// sync whenever the page is restored from cache (`event.persisted === true`).
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
// chrome aren't left in the stale bfcache snapshot.
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return; // fresh loads are handled by the IIFE above
const _srch = document.getElementById('sessionSearch');
if (_srch) _srch.value = '';
// Close any dropdowns/popovers that were open when the user navigated away.
// bfcache freezes DOM state, so a dropdown left open remains open on restore.
if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {}
if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
// Re-synchronise layout chrome that the boot IIFE sets up but bfcache
// doesn't re-run. Each call is guarded so missing helpers degrade silently.
if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}
if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {}
if (typeof renderSessionListFromCache === 'function') {
try { renderSessionListFromCache(); } catch (_) {}
}
// Restart the gateway SSE watcher — the persisted connection is dead after bfcache
if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {}
});

View File

@@ -3,15 +3,40 @@
// (no round-trip to the agent) and shows feedback via toast or local message.
const COMMANDS=[
{name:'help', desc:'List available commands', fn:cmdHelp},
{name:'clear', desc:'Clear conversation messages', fn:cmdClear},
{name:'compact', desc:'Compress conversation context', fn:cmdCompact},
{name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'},
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
{name:'new', desc:'Start a new chat session', fn:cmdNew},
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
// noEcho:true = action-only commands that don't produce a chat response.
// Commands without noEcho get a user message echoed to the chat (#840).
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
{name:'clear', desc:t('cmd_clear'), fn:cmdClear, noEcho:true},
{name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]', noEcho:true},
{name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact, noEcho:true},
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models', noEcho:true},
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name', noEcho:true},
{name:'terminal', desc:t('cmd_terminal'), fn:cmdTerminal, noEcho:true},
{name:'new', desc:t('cmd_new'), fn:cmdNew, noEcho:true},
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage, noEcho:true},
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true},
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'},
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
{name:'stop', desc:t('cmd_stop'), fn:cmdStop, noEcho:true},
{name:'queue', desc:t('cmd_queue'), fn:cmdQueue, arg:'message', noEcho:true},
{name:'interrupt', desc:t('cmd_interrupt'), fn:cmdInterrupt, arg:'message', noEcho:true},
{name:'steer', desc:t('cmd_steer'), fn:cmdSteer, arg:'message', noEcho:true},
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
{name:'btw', desc:t('cmd_btw'), fn:cmdBtw, arg:'question', noEcho:true},
{name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true},
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
{name:'yolo', desc:t('cmd_yolo'), fn:cmdYolo, noEcho:true},
];
const SLASH_SUBARG_SOURCES={
model:{desc:t('cmd_model'), subArgs:'models'},
personality:{desc:t('cmd_personality'), subArgs:'personalities'},
};
function parseCommand(text){
if(!text.startsWith('/'))return null;
const parts=text.slice(1).split(/\s+/);
@@ -22,42 +47,192 @@ function parseCommand(text){
function executeCommand(text){
const parsed=parseCommand(text);
if(!parsed)return false;
if(!parsed)return null;
const cmd=COMMANDS.find(c=>c.name===parsed.name);
if(!cmd)return false;
cmd.fn(parsed.args);
return true;
if(!cmd)return null;
// A handler may return `false` to opt out of interception — e.g. /reasoning
// with an effort level falls through so the agent's own handler sees it,
// preserving the pre-existing pass-through behaviour for that subcommand.
if(cmd.fn(parsed.args)===false)return null;
// Return noEcho flag so send() knows whether to echo the command as a user message (#840).
return {noEcho:!!cmd.noEcho};
}
function getMatchingCommands(prefix){
const q=prefix.toLowerCase();
return COMMANDS.filter(c=>c.name.startsWith(q));
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
const seen=new Set(matches.map(c=>c.name));
for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){
if(!name.startsWith(q)||seen.has(name))continue;
matches.push({
name,
desc:spec.desc,
arg:'name',
source:'subarg-command',
});
seen.add(name);
}
for(const skill of _skillCommandCache){
if(!skill.name.startsWith(q)||seen.has(skill.name))continue;
matches.push(skill);
seen.add(skill.name);
}
return matches;
}
let _slashModelCache=null;
let _slashModelCachePromise=null;
let _slashPersonalityCache=null;
let _slashPersonalityCachePromise=null;
function _normalizeSlashSubArg(value){
return String(value||'').trim();
}
function _getSlashModelSubArgsFromDom(){
const sel=$('modelSelect');
if(!sel) return [];
const values=[];
for(const opt of Array.from(sel.options||[])){
const value=_normalizeSlashSubArg(opt.value||opt.textContent||'');
if(value) values.push(value);
}
return Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
}
async function _loadSlashModelSubArgs(force=false){
const domValues=_getSlashModelSubArgsFromDom();
if(domValues.length&&!force){
_slashModelCache=domValues;
return domValues;
}
if(_slashModelCache&&!force) return _slashModelCache;
if(_slashModelCachePromise&&!force) return _slashModelCachePromise;
_slashModelCachePromise=(async()=>{
try{
const data=await api('/api/models');
const values=[];
for(const group of (data&&data.groups)||[]){
for(const model of (group&&group.models)||[]){
const id=_normalizeSlashSubArg(model&&model.id);
if(id) values.push(id);
}
}
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
_slashModelCache=deduped;
return deduped;
}catch(_){
_slashModelCache=domValues;
return domValues;
}finally{
_slashModelCachePromise=null;
}
})();
return _slashModelCachePromise;
}
async function _loadSlashPersonalitySubArgs(force=false){
if(_slashPersonalityCache&&!force) return _slashPersonalityCache;
if(_slashPersonalityCachePromise&&!force) return _slashPersonalityCachePromise;
_slashPersonalityCachePromise=(async()=>{
try{
const data=await api('/api/personalities');
const values=['none'];
for(const p of (data&&data.personalities)||[]){
const name=_normalizeSlashSubArg(p&&p.name);
if(name) values.push(name);
}
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
_slashPersonalityCache=deduped;
return deduped;
}catch(_){
_slashPersonalityCache=['none'];
return _slashPersonalityCache;
}finally{
_slashPersonalityCachePromise=null;
}
})();
return _slashPersonalityCachePromise;
}
function _getSlashSubArgOptions(spec){
if(Array.isArray(spec)) return Promise.resolve(spec.slice());
if(spec==='models') return _loadSlashModelSubArgs();
if(spec==='personalities') return _loadSlashPersonalitySubArgs();
return Promise.resolve([]);
}
function _parseSlashAutocomplete(text){
if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null;
const raw=text.slice(1);
const hasSpace=/\s/.test(raw);
const parts=raw.split(/\s+/);
const cmdName=(parts[0]||'').toLowerCase();
const command=COMMANDS.find(c=>c.name===cmdName);
const subArgSource=(command&&command.subArgs)?command:SLASH_SUBARG_SOURCES[cmdName];
if(!hasSpace||!subArgSource){
return {kind:'commands', query:raw};
}
const argText=raw.slice(cmdName.length).replace(/^\s+/,'');
return {kind:'subargs', command:{name:cmdName, desc:subArgSource.desc, subArgs:subArgSource.subArgs}, query:argText.toLowerCase(), rawQuery:argText};
}
async function getSlashAutocompleteMatches(text){
const parsed=_parseSlashAutocomplete(text);
if(!parsed) return [];
if(parsed.kind==='commands') return getMatchingCommands(parsed.query);
const options=await _getSlashSubArgOptions(parsed.command.subArgs);
return options
.filter(opt=>String(opt).toLowerCase().startsWith(parsed.query))
.map(opt=>({
name:parsed.command.name,
value:String(opt),
desc:parsed.command.desc,
source:'subarg',
parent:parsed.command.name,
}));
}
function _compressionAnchorMessageKey(m){
if(!m||!m.role||m.role==='tool') return null;
let content='';
try{
content=typeof msgContent==='function' ? String(msgContent(m)||'') : String(m.content||'');
}catch(_){
content=String(m.content||'');
}
const norm=content.replace(/\s+/g,' ').trim().slice(0,160);
const ts=m._ts||m.timestamp||null;
const attachments=Array.isArray(m.attachments)?m.attachments.length:0;
if(!norm && !attachments && !ts) return null;
return {role:String(m.role||''), ts, text:norm, attachments};
}
// ── Command handlers ────────────────────────────────────────────────────────
function cmdHelp(){
const lines=COMMANDS.map(c=>{
const usage=c.arg?` <${c.arg}>`:'';
const usage=c.arg ? (String(c.arg).startsWith('[') ? ` ${c.arg}` : ` <${c.arg}>`) : '';
return ` /${c.name}${usage}${c.desc}`;
});
const msg={role:'assistant',content:'**Available commands:**\n'+lines.join('\n')};
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
S.messages.push(msg);
renderMessages();
showToast('Type / to see commands');
showToast(t('type_slash'));
}
function cmdClear(){
if(!S.session)return;
S.messages=[];S.toolCalls=[];
clearLiveToolCards();
if(typeof clearCompressionUi==='function') clearCompressionUi();
renderMessages();
$('emptyState').style.display='';
showToast('Conversation cleared');
showToast(t('conversation_cleared'));
}
async function cmdModel(args){
if(!args){showToast('Usage: /model <name>');return;}
if(!args){showToast(t('model_usage'));return;}
const sel=$('modelSelect');
if(!sel)return;
const q=args.toLowerCase();
@@ -68,45 +243,179 @@ async function cmdModel(args){
match=opt.value;break;
}
}
if(!match){showToast(`No model matching "${args}"`);return;}
if(!match){showToast(t('no_model_match')+`"${args}"`);return;}
sel.value=match;
await sel.onchange();
showToast(`Switched to ${match}`);
showToast(t('switched_to')+match);
}
async function cmdWorkspace(args){
if(!args){showToast('Usage: /workspace <name>');return;}
if(!args){showToast(t('workspace_usage'));return;}
try{
const data=await api('/api/workspaces');
const q=args.toLowerCase();
const ws=(data.workspaces||[]).find(w=>
(w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q)
);
if(!ws){showToast(`No workspace matching "${args}"`);return;}
if(!S.session)return;
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id,workspace:ws.path,model:S.session.model
})});
S.session.workspace=ws.path;
syncTopbar();await loadDir('.');
showToast(`Switched to workspace: ${ws.name||ws.path}`);
}catch(e){showToast('Workspace switch failed: '+e.message);}
if(!ws){showToast(t('no_workspace_match')+`"${args}"`);return;}
if(typeof switchToWorkspace==='function') await switchToWorkspace(ws.path, ws.name||ws.path);
else showToast(t('switched_workspace')+(ws.name||ws.path));
}catch(e){showToast(t('workspace_switch_failed')+e.message);}
}
async function cmdTerminal(){
if(!S.session&&typeof newSession==='function'){
if(!S._profileSwitchWorkspace&&!S._profileDefaultWorkspace){
try{
const data=await api('/api/workspaces');
const first=(data.workspaces||[])[0];
S._profileSwitchWorkspace=data.last||(first&&first.path)||null;
}catch(_){}
}
await newSession();
if(typeof renderSessionList==='function') await renderSessionList();
}
if(!S.session||!S.session.workspace){
showToast(t('terminal_no_workspace_title'),2600,'warning');
if(typeof syncTerminalButton==='function') syncTerminalButton();
return;
}
if(typeof toggleComposerTerminal==='function') await toggleComposerTerminal(true);
}
async function cmdNew(){
if(typeof clearCompressionUi==='function') clearCompressionUi();
await newSession();
await renderSessionList();
$('msg').focus();
showToast('New session created');
showToast(t('new_session'));
}
function cmdCompact(){
// Send as a regular message to the agent -- the agent's run_conversation
// preflight will detect the high token count and trigger _compress_context.
// We send a user message so it appears in the conversation.
$('msg').value='Please compress and summarize the conversation context to free up space.';
send();
showToast('Requesting context compression...');
async function _runManualCompression(focusTopic){
if(!S.session){showToast(t('no_active_session'));return;}
let visibleCount=0;
try{
const sid=S.session.session_id;
// Preflight: verify the viewed session still exists before compressing.
// This avoids a confusing "not found" toast when the UI is stale.
try{
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
if(!live||!live.session||live.session.session_id!==sid){
throw new Error('session no longer available');
}
S.session=live.session;
S.messages=live.session.messages||[];
S.toolCalls=live.session.tool_calls||[];
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=false;
}catch(preflightErr){
if(typeof clearCompressionUi==='function') clearCompressionUi();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(typeof setBusy==='function') setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
showToast('Compression failed: '+(preflightErr.message||'session no longer available'));
return;
}
if(typeof setBusy==='function') setBusy(true);
const body={session_id:sid};
if(focusTopic) body.focus_topic=focusTopic;
const visibleMessages=(S.messages||[]).filter(m=>{
if(!m||!m.role||m.role==='tool') return false;
if(m.role==='assistant'){
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true;
}
return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length;
});
visibleCount=visibleMessages.length;
const anchorVisibleIdx=Math.max(0, visibleCount - 1);
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
const commandText=focusTopic?`/compress ${focusTopic}`:'/compress';
if(typeof setCompressionUi==='function'){
setCompressionUi({
sessionId:S.session.session_id,
phase:'running',
focusTopic:focusTopic||'',
commandText,
beforeCount:visibleCount,
anchorVisibleIdx,
anchorMessageKey,
});
}
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
renderMessages();
const data=await api('/api/session/compress',{method:'POST',body:JSON.stringify(body)});
if(data&&data.session){
const currentSid=S.session&&S.session.session_id;
if(data.session.session_id&&data.session.session_id!==currentSid){
await loadSession(data.session.session_id);
}else{
S.session=data.session;
S.messages=data.session.messages||[];
S.toolCalls=data.session.tool_calls||[];
clearLiveToolCards();
localStorage.setItem('hermes-webui-session',S.session.session_id);
syncTopbar();
renderMessages();
await renderSessionList();
updateQueueBadge(S.session.session_id);
}
}
const summary=data&&data.summary;
if(typeof setCompressionUi==='function'&&S.session){
const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m));
const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';
const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : '';
// Prefer the persisted compaction handoff when it already exists in session state.
// The short summary fallback is only for environments where that message is unavailable.
const referenceText=messageRef || summaryRef;
const effectiveFocus=(data&&data.focus_topic)||focusTopic||'';
setCompressionUi({
sessionId:S.session.session_id,
phase:'done',
focusTopic:effectiveFocus,
commandText:effectiveFocus?`/compress ${effectiveFocus}`:'/compress',
beforeCount:visibleCount,
summary:summary||null,
referenceText,
anchorVisibleIdx: data?.session?.compression_anchor_visible_idx,
anchorMessageKey: data?.session?.compression_anchor_message_key||null,
});
}
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
}catch(e){
if(typeof setCompressionUi==='function'){
const currentSid=S.session&&S.session.session_id;
setCompressionUi({
sessionId:currentSid||'',
phase:'error',
focusTopic:(focusTopic||'').trim(),
commandText:focusTopic?`/compress ${focusTopic}`:'/compress',
beforeCount:(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length,
errorText:`Compression failed: ${e.message}`,
anchorVisibleIdx: Math.max(0, visibleCount - 1),
anchorMessageKey:null,
});
}
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(typeof setBusy==='function') setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
showToast('Compression failed: '+e.message);
return;
}
if(typeof setBusy==='function') setBusy(false);
}
async function cmdCompress(args){
await _runManualCompression((args||'').trim());
}
async function cmdCompact(args){
await _runManualCompression((args||'').trim());
}
async function cmdUsage(){
@@ -119,7 +428,482 @@ async function cmdUsage(){
const cb=$('settingsShowTokenUsage');
if(cb) cb.checked=next;
renderMessages();
showToast('Token usage '+(next?'on':'off'));
showToast(next?t('token_usage_on'):t('token_usage_off'));
}
async function cmdTheme(args){
const themes=['system','dark','light'];
const skins=(_SKINS||[]).map(s=>s.name.toLowerCase());
const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});
const val=(args||'').toLowerCase().trim();
// Check if it's a theme
if(themes.includes(val)||legacyThemes.includes(val)){
const appearance=_normalizeAppearance(
val,
legacyThemes.includes(val)?null:localStorage.getItem('hermes-skin')
);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsTheme');
if(sel)sel.value=appearance.theme;
const skinSel=$('settingsSkin');
if(skinSel)skinSel.value=appearance.skin;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.theme+(legacyThemes.includes(val)?` + ${appearance.skin}`:''));
return;
}
// Check if it's a skin
if(skins.includes(val)){
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),val);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsSkin');
if(sel)sel.value=appearance.skin;
const themeSel=$('settingsTheme');
if(themeSel)themeSel.value=appearance.theme;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.skin);
return;
}
showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|'));
}
async function cmdSkills(args){
try{
const data = await api('/api/skills');
let skills = data.skills || [];
if(args){
const q = args.toLowerCase();
skills = skills.filter(s =>
(s.name||'').toLowerCase().includes(q) ||
(s.description||'').toLowerCase().includes(q) ||
(s.category||'').toLowerCase().includes(q)
);
}
if(!skills.length){
const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'};
S.messages.push(msg); renderMessages(); return;
}
// Group by category
const byCategory = {};
skills.forEach(s => {
const cat = s.category || 'General';
if(!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(s);
});
const lines = [];
for(const [cat, items] of Object.entries(byCategory).sort()){
lines.push(`**${cat}**`);
items.forEach(s => {
const desc = s.description ? `${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
lines.push(` \`${s.name}\`${desc}`);
});
lines.push('');
}
const header = args
? `Skills matching "${args}" (${skills.length}):\n\n`
: `Available skills (${skills.length}):\n\n`;
S.messages.push({role:'assistant', content: header + lines.join('\n')});
renderMessages();
showToast(t('type_slash'));
}catch(e){
showToast('Failed to load skills: '+e.message);
}
}
async function cmdPersonality(args){
if(!S.session){showToast(t('no_active_session'));return;}
if(!args){
// List available personalities
try{
const data=await api('/api/personalities');
if(!data.personalities||!data.personalities.length){
showToast(t('no_personalities'));
return;
}
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+t('personality_switch_hint')});
renderMessages();
}catch(e){showToast(t('personalities_load_failed'));}
return;
}
const name=args.trim();
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
try{
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
showToast(t('personality_cleared'));
}catch(e){showToast(t('failed_colon')+e.message);}
return;
}
try{
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
S.messages.push({role:'assistant',content:t('personality_set')+`**${name}**`});
renderMessages();
showToast(t('personality_set')+name);
}catch(e){showToast(t('failed_colon')+e.message);}
}
async function cmdStop(){
if(!S.session){showToast(t('no_active_session'));return;}
if(!S.activeStreamId){showToast(t('no_active_task'));return;}
if(typeof cancelStream==='function'){await cancelStream();showToast(t('stream_stopped'));}
else showToast(t('cancel_unavailable'));
}
// ── Busy-input mode commands ──────────────────────────────────────────────
// These commands let users override the default busy_input_mode setting for a
// specific message. They are only meaningful while the agent is running.
/**
* /queue <message> — Explicitly queue a message for the next turn.
* Works regardless of the busy_input_mode setting.
*/
async function cmdQueue(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_queue_no_msg'));return;}
// If nothing is running, /queue <msg> just sends like a normal message
if(!S.busy){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
if(!S.session){showToast(t('no_active_session'));return;}
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
showToast(t('cmd_queue_confirm'),2000);
}
/**
* /interrupt <message> — Cancel the current turn and send a new message.
* Calls cancelStream() then queues the message so the drain picks it up.
*/
async function cmdInterrupt(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_interrupt_no_msg'));return;}
// If nothing is running, /interrupt <msg> just sends like a normal message
if(!S.busy||!S.activeStreamId){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
if(!S.session){showToast(t('no_active_session'));return;}
// Queue the message first (before cancel sets busy=false and drains)
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
// Cancel the active stream; setBusy(false) will drain the queue
if(typeof cancelStream==='function'){await cancelStream();}
showToast(t('cmd_interrupt_confirm'),2000);
}
/**
* /steer <message> — Inject a steering hint mid-task without interrupting.
*
* Calls POST /api/chat/steer which looks up the cached AIAgent for this
* session and calls agent.steer(text). The agent's run loop appends the
* steer text to the next tool-result message so the model sees it on its
* next iteration — same pathway as the CLI's /steer command.
*
* Falls back to interrupt mode when the agent isn't running, isn't cached,
* or doesn't support steer (older hermes-agent versions).
*/
async function cmdSteer(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_steer_no_msg'));return;}
// If nothing is running, /steer <msg> just sends like a normal message
if(!S.busy||!S.activeStreamId){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
if(!S.session){showToast(t('no_active_session'));return;}
await _trySteer(msg, /*explicitSteer=*/true);
}
/**
* Shared implementation for /steer and the busy_input_mode='steer' path.
*
* Tries the real steer endpoint first. On any non-accept response (no cached
* agent, agent lacks steer, stream dead, etc.) falls back to interrupt+queue:
* queues the message and cancels the stream so the drain re-sends it.
*
* @param {string} msg - The steer text.
* @param {boolean} explicitSteer - True if the user explicitly invoked /steer
* (vs the busy-mode auto-fallback). Affects toast wording only.
*/
async function _trySteer(msg, explicitSteer){
let result=null;
try{
result=await api('/api/chat/steer',{
method:'POST',
body:JSON.stringify({session_id:S.session.session_id,text:msg}),
});
}catch(e){
// Network or server error — fall back to interrupt
result={accepted:false, fallback:'network_error'};
}
if(result&&result.accepted){
showToast(t('cmd_steer_delivered'),2500);
return;
}
// Fall back to interrupt: queue the message + cancel the stream so the
// drain in setBusy(false) re-sends it as a fresh turn.
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
if(typeof cancelStream==='function'){await cancelStream();}
// Toast wording differs based on why we're falling back so the user
// understands what just happened.
const reason=(result&&result.fallback)||'unknown';
if(explicitSteer){
showToast(t('cmd_steer_fallback'),2500);
} else if(reason==='no_cached_agent'||reason==='not_running'||reason==='stream_dead'){
// Busy mode hit the steer path before the agent was ready —
// interrupt is the natural fallback, no need to call out steer.
showToast(t('busy_interrupt_confirm'),2000);
} else {
showToast(t('busy_steer_fallback'),2500);
}
}
async function cmdTitle(args){
if(!S.session){showToast(t('no_active_session'));return;}
const name=(args||'').trim();
if(!name){
S.messages.push({role:'assistant',content:`${t('title_current')}: **${S.session.title||t('untitled')}**\n\n${t('title_change_hint')}`});
renderMessages();return;
}
try{
const r=await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,title:name})});
if(r&&r.error){showToast(r.error);return;}
S.session.title=(r&&r.session&&r.session.title)||name;
if(typeof syncTopbar==='function')syncTopbar();
if(typeof renderSessionList==='function')renderSessionList();
showToast(`${t('title_set')} "${S.session.title}"`);
S.messages.push({role:'assistant',content:`${t('title_set')} **${S.session.title}**`});
renderMessages();
}catch(e){showToast(t('failed_colon')+e.message);}
}
async function cmdRetry(){
if(!S.session){showToast(t('no_active_session'));return;}
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();await send();
}catch(e){showToast(t('retry_failed')+e.message);}
}
async function cmdUndo(){
if(!S.session){showToast(t('no_active_session'));return;}
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
showToast(`${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
}catch(e){showToast(t('undo_failed')+e.message);}
}
async function undoLastExchange(){await cmdUndo();}
async function cmdBtw(args){
if(!S.session){showToast(t('no_active_session'));return;}
const question=(args||'').trim();
if(!question){showToast(t('cmd_btw_usage'));return;}
showToast(t('btw_asking'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/btw',{method:'POST',body:JSON.stringify({session_id:activeSid,question})});
if(r&&r.error){showToast(r.error);return;}
// Connect to the ephemeral SSE stream
const streamId=r.stream_id;
const parentSid=r.parent_session_id;
if(typeof attachBtwStream==='function') attachBtwStream(parentSid,streamId,question);
}catch(e){showToast(t('btw_failed')+e.message);}
}
async function cmdBackground(args){
if(!S.session){showToast(t('no_active_session'));return;}
const prompt=(args||'').trim();
if(!prompt){showToast(t('cmd_background_usage'));return;}
showToast(t('bg_running'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/background',{method:'POST',body:JSON.stringify({session_id:activeSid,prompt})});
if(r&&r.error){showToast(r.error);return;}
// Show background badge and start polling
if(typeof showBackgroundBadge==='function') showBackgroundBadge(r.task_id);
if(typeof startBackgroundPolling==='function') startBackgroundPolling(activeSid,r.task_id,prompt);
}catch(e){showToast(t('bg_failed')+e.message);}
}
async function cmdStatus(){
if(!S.session){showToast(t('no_active_session'));return;}
try{
const r=await api('/api/session/status?session_id='+encodeURIComponent(S.session.session_id));
if(r&&r.error){showToast(r.error);return;}
// Build status card lines matching CLI /status output
const provider=window._activeProvider||'';
const profile=S.activeProfile||'default';
const started=r.created_at?new Date(r.created_at).toLocaleString():t('status_unknown');
const fmtNum=n=>typeof n==='number'?n.toLocaleString():'0';
const tokens=r.total_tokens?`${fmtNum(r.input_tokens)} in / ${fmtNum(r.output_tokens)} out`:t('status_no_tokens');
const cost=r.estimated_cost?` (~$${Number(r.estimated_cost).toFixed(4)})`:'';
const lines=[
`**${t('status_heading')}**`,'',
`\`${r.session_id}\``,'',
`**${t('status_title')}:** ${r.title||t('untitled')}`,
`**${t('status_model')}:** ${r.model||t('usage_default_model')}${provider?' ('+provider+')':''}`,
`**${t('status_profile')}:** ${profile}`,
`**${t('status_workspace')}:** ${r.workspace}`,
`**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`,
`**${t('status_started')}:** ${started}`,
`**${t('status_tokens')}:** ${tokens}${cost}`,
`**${t('status_messages')}:** ${r.message_count}`,
`**${t('status_agent_running')}:** ${r.agent_running?t('status_yes'):t('status_no')}`,
];
S.messages.push({role:'assistant',content:lines.join('\n')});
renderMessages();
}catch(e){showToast(t('status_load_failed')+e.message);}
}
function cmdReasoning(args){
const arg=(args||'').trim().toLowerCase();
const BRAIN='\uD83E\uDDE0';
// Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity).
const EFFORTS=['none','minimal','low','medium','high','xhigh'];
// Shared status renderer used by the no-args branch and as a fallback.
function _fmtStatus(st){
const vis=(st && st.show_reasoning===false)?'off':'on';
const eff=(st && st.reasoning_effort)||'default';
return BRAIN+' Reasoning effort: '+eff+' \u00B7 display: '+vis
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh';
}
if(!arg){
// Status — read from the same config.yaml keys the CLI uses.
api('/api/reasoning').then(function(st){showToast(_fmtStatus(st));})
.catch(function(){showToast(BRAIN+' /reasoning — status unavailable');});
return true;
}
if(arg==='show'||arg==='on'||arg==='hide'||arg==='off'){
const on=(arg==='show'||arg==='on');
// Update the UI render gate immediately for responsiveness.
window._showThinking=on;
if(typeof renderMessages==='function') renderMessages();
// Persist via /api/reasoning → config.yaml display.show_reasoning
// (CLI reads the same key). Also mirror into WebUI settings.json
// show_thinking so boot.js picks it up on reload without hitting
// /api/reasoning on every page load.
api('/api/reasoning',{method:'POST',body:JSON.stringify({display:arg})}).catch(function(){});
api('/api/settings',{method:'POST',body:JSON.stringify({show_thinking:on})}).catch(function(){});
showToast(BRAIN+' Thinking blocks: '+(on?'on':'off')+' (saved)');
return true;
}
if(EFFORTS.includes(arg)){
// Persist via /api/reasoning → config.yaml agent.reasoning_effort.
// Takes effect on the NEXT session/turn (agent re-reads config at
// construction time), matching CLI semantics where `/reasoning high`
// also forces an agent re-init.
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
.then(function(st){
const eff=(st && st.reasoning_effort)||arg;
showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)');
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff);
})
.catch(function(e){
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
});
return true;
}
showToast('Unknown argument: '+arg+' \u2014 use show|hide|'+EFFORTS.join('|'));
return true;
}
function cmdVoice(){
const mic=document.getElementById('btnMic');
if(mic&&mic.style.display!=='none'&&!mic.disabled){try{mic.click();return;}catch(_){}}
showToast(t('cmd_voice_use_mic'));
}
// ── YOLO mode toggle ──
// Session-scoped: skips all approval prompts for the current session.
// Toggles on/off; state is not persisted across page reloads.
async function cmdYolo(){
const sid=S.session&&S.session.session_id;
if(!sid){showToast(t('yolo_no_session'));return;}
try{
// Check current state first to toggle
const status=await api('/api/session/yolo?session_id='+encodeURIComponent(sid));
const enable=!status.yolo_enabled;
await api('/api/session/yolo',{
method:'POST',
body:JSON.stringify({session_id:sid,enabled:enable}),
});
_yoloEnabled=enable;
_updateYoloPill();
showToast(enable?t('yolo_enabled'):t('yolo_disabled'));
if(enable){
// Dismiss any visible approval card
hideApprovalCard(true);
}
}catch(e){showToast('YOLO: '+e.message);}
}
let _skillCommandCache=[];
let _skillCommandLoadPromise=null;
let _skillCommandCacheReady=false;
function _skillCommandSlug(name){
const raw=String(name||'').trim().toLowerCase();
if(!raw)return'';
return raw.replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-{2,}/g,'-').replace(/^-+|-+$/g,'');
}
function _buildSkillCommandEntry(skill){
const skillName=String(skill&&skill.name||'').trim();
const slug=_skillCommandSlug(skillName);
if(!slug)return null;
if(COMMANDS.some(c=>c.name===slug)) return null;
return{name:slug,desc:String(skill&&skill.description||'').trim()||t('slash_skill_desc'),source:'skill',skillName};
}
async function loadSkillCommands(force=false){
if(_skillCommandCacheReady&&!force)return _skillCommandCache;
if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise;
_skillCommandLoadPromise=(async()=>{
try{
const data=await api('/api/skills');
const deduped=new Map();
for(const skill of (data&&data.skills)||[]){const entry=_buildSkillCommandEntry(skill);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);}
_skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name));
}catch(_){_skillCommandCache=[];}
finally{_skillCommandCacheReady=true;_skillCommandLoadPromise=null;}
return _skillCommandCache;
})();
return _skillCommandLoadPromise;
}
function refreshSlashCommandDropdown(){
const ta=$('msg');if(!ta)return;
const text=ta.value||'';
if(!text.startsWith('/')||text.indexOf('\n')!==-1){hideCmdDropdown();return;}
getSlashAutocompleteMatches(text).then(matches=>{
if(($('msg').value||'')!==text) return;
if(matches.length)showCmdDropdown(matches);else hideCmdDropdown();
});
}
function ensureSkillCommandsLoadedForAutocomplete(){
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
}
// ── Autocomplete dropdown ───────────────────────────────────────────────────
@@ -130,19 +914,36 @@ function showCmdDropdown(matches){
const dd=$('cmdDropdown');
if(!dd)return;
dd.innerHTML='';
_cmdSelectedIdx=-1;
_cmdSelectedIdx=matches.length?0:-1;
for(let i=0;i<matches.length;i++){
const c=matches[i];
const el=document.createElement('div');
el.className='cmd-item';
if(i===_cmdSelectedIdx) el.classList.add('selected');
el.dataset.idx=i;
const usage=c.arg?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
const isSubArg=c.source==='subarg';
const usage=(!isSubArg&&c.arg)?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
const badge=c.source==='skill'?`<span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`:'';
if(c.source==='skill') el.classList.add('cmd-item-skill');
const nameHtml=isSubArg
? `<div class="cmd-item-name"><span class="cmd-item-parent">/${esc(c.parent)}</span> <span class="cmd-item-subarg">${esc(c.value)}</span></div>`
: `<div class="cmd-item-name">/${esc(c.name)}${usage}${badge}</div>`;
const descHtml=`<div class="cmd-item-desc">${esc(c.desc)}</div>`;
el.innerHTML=`${nameHtml}${descHtml}`;
el.onmousedown=(e)=>{
e.preventDefault();
$('msg').value='/'+c.name+(c.arg?' ':'');
hideCmdDropdown();
const nextValue=isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':''));
$('msg').value=nextValue;
$('msg').focus();
if(!isSubArg&&c.source!=='skill'&&nextValue.endsWith(' ')&&typeof getSlashAutocompleteMatches==='function'){
getSlashAutocompleteMatches(nextValue).then(matches=>{
if(($('msg').value||'')!==nextValue) return;
if(matches.length) showCmdDropdown(matches);
else hideCmdDropdown();
});
}else{
hideCmdDropdown();
}
};
dd.appendChild(el);
}
@@ -165,6 +966,9 @@ function navigateCmdDropdown(dir){
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
items[_cmdSelectedIdx].classList.add('selected');
// Scroll the newly highlighted item into view so it stays visible when the
// dropdown overflows and the user navigates with keyboard (#838).
items[_cmdSelectedIdx].scrollIntoView({block:'nearest'});
}
function selectCmdDropdownItem(){
@@ -178,3 +982,9 @@ function selectCmdDropdownItem(){
}
hideCmdDropdown();
}
// ── Handler aliases (for test-discoverable command registration) ──────────────
// The COMMANDS array above is the authoritative dispatch table. These aliases
// allow tooling and tests to discover command handlers by name independently.
const HANDLERS = {};
HANDLERS.skills = cmdSkills;

BIN
static/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

20
static/favicon.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#1a1a1a"/>
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
</linearGradient>
</defs>
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#g)"/>
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42"
fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42"
fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

5435
static/i18n.js Normal file

File diff suppressed because it is too large Load Diff

79
static/icons.js Normal file
View File

@@ -0,0 +1,79 @@
// ── Lucide icon library (self-hosted SVG paths, no CDN dependency) ──────────
// All icons are 24×24 viewBox, stroke-based, currentColor.
// Usage: li('folder') → returns a ready-to-embed SVG string
// The returned SVG uses display:inline-block + vertical-align so it sits
// neatly beside text in both HTML templates and innerHTML assignments.
const LI_PATHS = {
// Navigation tabs
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'layers': '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
// Editing / actions
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'undo': '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>',
'check': '<polyline points="20 6 9 17 4 12"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'square': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
'arrow-right': '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
'loader': '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
'pause': '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
// Tool icons
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'file-pen': '<path d="M12 22h6a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v10"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10.4 19.4 14 16l-4-1 .4 4.4z"/><path d="m14 16 1.5-1.5a2.12 2.12 0 0 1 3 3L17 19"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
'brain': '<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/>',
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
'grip-vertical': '<circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'bot': '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'shuffle': '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>',
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.48-8.48"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'rotate-ccw': '<path d="M3 2v6h6"/><path d="M3 8a9 9 0 1 0 2.64-4.36L3 8"/>',
'user': '<path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/>',
// File-type icons
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'file-code': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
// Suggestion buttons
'clipboard-list': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/>',
'map': '<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>',
};
/**
* Returns a Lucide SVG string for the given icon name.
* @param {string} name key in LI_PATHS (e.g. 'folder', 'trash-2')
* @param {number} size width/height in px (default 16)
* @returns {string} SVG element string ready for innerHTML
*/
function li(name, size = 16) {
const p = LI_PATHS[name];
if (!p) { console.warn('li(): unknown icon', name); return ''; }
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" `
+ `stroke="currentColor" stroke-width="2" stroke-linecap="round" `
+ `stroke-linejoin="round" aria-hidden="true" `
+ `style="display:inline-block;vertical-align:-0.15em;flex-shrink:0">${p}</svg>`;
}

File diff suppressed because it is too large Load Diff

69
static/login.js Normal file
View File

@@ -0,0 +1,69 @@
/* Login page — external script, no inline handlers.
* Loaded by the /login route. Reads data attributes from the form for
* i18n strings so the server does not need to inject JS literals.
*/
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('login-form');
var input = document.getElementById('pw');
if (!form || !input) return;
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
function showErr(msg) {
var err = document.getElementById('err');
if (err) { err.textContent = msg; err.style.display = 'block'; }
}
function hideErr() {
var err = document.getElementById('err');
if (err) { err.style.display = 'none'; }
}
// Return the ?next= redirect path if present and safe, otherwise './'
// Guards against open-redirect: rejects protocol-relative (//evil.com),
// absolute URLs, backslash variants, and control characters.
function _safeNextPath() {
try {
var raw = new URL(window.location.href).searchParams.get('next');
if (!raw) return './';
if (raw.charAt(0) !== '/') return './'; // must be path-absolute
if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\
if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace
return raw;
} catch (_) { return './'; }
}
async function doLogin(e) {
e.preventDefault();
var pw = input.value;
hideErr();
try {
var res = await fetch('api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
credentials: 'include',
});
var data = {};
try { data = await res.json(); } catch (_) {}
if (res.ok && data.ok) {
window.location.href = _safeNextPath();
} else {
showErr(data.error || invalidPw);
}
} catch (ex) {
showErr(connFailed);
}
}
form.addEventListener('submit', doLogin);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
doLogin(e);
}
});
});

23
static/manifest.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "Hermes",
"short_name": "Hermes",
"description": "Hermes AI Agent Web UI",
"start_url": "./",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"orientation": "portrait-primary",
"icons": [
{
"src": "static/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "static/favicon-32.png",
"sizes": "32x32",
"type": "image/png"
}
]
}

File diff suppressed because it is too large Load Diff

411
static/onboarding.js Normal file
View File

@@ -0,0 +1,411 @@
const ONBOARDING={status:null,step:0,steps:['system','setup','workspace','password','finish'],form:{provider:'openrouter',workspace:'',model:'',password:'',apiKey:'',baseUrl:''},active:false};
function _getOnboardingSetupProviders(){
return (((ONBOARDING.status||{}).setup||{}).providers)||[];
}
function _getOnboardingSetupProvider(id){
return _getOnboardingSetupProviders().find(p=>p.id===id)||null;
}
function _getOnboardingSetupCategories(){
return (((ONBOARDING.status||{}).setup||{}).categories)||[];
}
/** Render the provider <select> with <optgroup> per category. */
function _renderProviderSelectOptions(selectedId){
const providers=_getOnboardingSetupProviders();
const categories=_getOnboardingSetupCategories();
const provMap={};
providers.forEach(p=>{provMap[p.id]=p;});
if(!categories.length){
// Fallback: flat list when no categories are available.
return providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
}
return categories.map(cat=>{
const opts=cat.providers.map(pid=>{
const p=provMap[pid];
if(!p)return '';
return `<option value="${esc(p.id)}"${p.id===selectedId?' selected':''}>${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`;
}).join('');
return `<optgroup label="${esc(t('provider_category_'+cat.id)||cat.label)}">${opts}</optgroup>`;
}).join('');
}
function _getOnboardingCurrentSetup(){
return (((ONBOARDING.status||{}).setup||{}).current)||{};
}
function _onboardingStepMeta(key){
return ({
system:{title:t('onboarding_step_system_title'),desc:t('onboarding_step_system_desc')},
setup:{title:t('onboarding_step_setup_title'),desc:t('onboarding_step_setup_desc')},
workspace:{title:t('onboarding_step_workspace_title'),desc:t('onboarding_step_workspace_desc')},
password:{title:t('onboarding_step_password_title'),desc:t('onboarding_step_password_desc')},
finish:{title:t('onboarding_step_finish_title'),desc:t('onboarding_step_finish_desc')}
})[key];
}
function _renderOnboardingSteps(){
const wrap=$('onboardingSteps');
if(!wrap)return;
wrap.innerHTML='';
ONBOARDING.steps.forEach((key,idx)=>{
const meta=_onboardingStepMeta(key);
const item=document.createElement('div');
item.className='onboarding-step'+(idx===ONBOARDING.step?' active':idx<ONBOARDING.step?' done':'');
item.innerHTML=`<div class="onboarding-step-index">${idx+1}</div><div><div class="onboarding-step-title">${meta.title}</div><div class="onboarding-step-desc">${meta.desc}</div></div>`;
wrap.appendChild(item);
});
}
function _setOnboardingNotice(msg,kind='info'){
const el=$('onboardingNotice');
if(!el)return;
if(!msg){el.style.display='none';el.textContent='';el.className='onboarding-status';return;}
el.style.display='block';
el.className='onboarding-status '+kind;
el.textContent=msg;
}
function _getOnboardingWorkspaceChoices(){
const items=((ONBOARDING.status||{}).workspaces||{}).items||[];
return items.length?items:[{name:'Home',path:ONBOARDING.form.workspace||''}];
}
function _getOnboardingProviderModelChoices(){
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
return provider?(provider.models||[]):[];
}
function _getOnboardingSelectedModel(){
return ONBOARDING.form.model||'';
}
function _renderOnboardingModelField(){
const choices=_getOnboardingProviderModelChoices();
if(ONBOARDING.form.provider==='custom'){
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><input id="onboardingModelInput" value="${esc(_getOnboardingSelectedModel())}" placeholder="${t('onboarding_custom_model_placeholder')}" oninput="ONBOARDING.form.model=this.value"></label><p class="onboarding-copy">${t('onboarding_custom_model_help')}</p>`;
}
const options=choices.map(m=>`<option value="${esc(m.id)}">${esc(m.label)}</option>`).join('');
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><select id="onboardingModelSelect" onchange="ONBOARDING.form.model=this.value">${options}</select></label><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
}
function _providerStatusLabel(system){
if(system.chat_ready) return t('onboarding_check_provider_ready');
if(system.provider_configured) return t('onboarding_check_provider_partial');
return t('onboarding_check_provider_pending');
}
function _renderOnboardingBody(){
const body=$('onboardingBody');
if(!body||!ONBOARDING.status)return;
const key=ONBOARDING.steps[ONBOARDING.step];
const system=ONBOARDING.status.system||{};
const settings=ONBOARDING.status.settings||{};
const setup=ONBOARDING.status.setup||{};
const nextBtn=$('onboardingNextBtn');
const backBtn=$('onboardingBackBtn');
if(backBtn) backBtn.style.display=ONBOARDING.step>0?'':'none';
if(nextBtn) nextBtn.textContent=key==='finish'?t('onboarding_open'):t('onboarding_continue');
if(key==='system'){
const hermesOk=system.hermes_found&&system.imports_ok;
const setupOk=!!system.chat_ready;
_setOnboardingNotice(system.provider_note|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn'));
body.innerHTML=`
<div class="onboarding-panel-grid">
<div class="onboarding-check ${hermesOk?'ok':'warn'}"><strong>${t('onboarding_check_agent')}</strong><span>${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}</span></div>
<div class="onboarding-check ${(setupOk?'ok':system.provider_configured?'warn':'muted')}"><strong>${t('onboarding_check_provider')}</strong><span>${_providerStatusLabel(system)}</span></div>
<div class="onboarding-check ${(settings.password_enabled?'ok':'muted')}"><strong>${t('onboarding_check_password')}</strong><span>${settings.password_enabled?t('onboarding_check_password_enabled'):t('onboarding_check_password_disabled')}</span></div>
</div>
<div class="onboarding-copy">
<p><strong>${t('onboarding_config_file')}</strong> ${esc(system.config_path||t('onboarding_unknown'))}</p>
<p><strong>${t('onboarding_env_file')}</strong> ${esc(system.env_path||t('onboarding_unknown'))}</p>
<p>${esc(system.provider_note||'')}</p>
${system.current_provider?`<p><strong>${t('onboarding_current_provider')}</strong> ${esc(system.current_provider)}${system.current_model?`${esc(system.current_model)}`:''}</p>`:''}
${system.current_base_url?`<p><strong>${t('onboarding_base_url_label')}</strong> ${esc(system.current_base_url)}</p>`:''}
${system.missing_modules&&system.missing_modules.length?`<p><strong>${t('onboarding_missing_imports')}</strong> ${esc(system.missing_modules.join(', '))}</p>`:''}
</div>`;
return;
}
if(key==='setup'){
const selectedId=ONBOARDING.form.provider;
const groupedOptions=_renderProviderSelectOptions(selectedId);
const provider=_getOnboardingSetupProvider(selectedId)||_getOnboardingSetupProviders()[0]||null;
const showBaseUrl=provider&&provider.requires_base_url;
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
// OAuth provider path: configured via CLI, no API key input needed.
const currentIsOauth=!!(ONBOARDING.status.setup||{}).current_is_oauth;
const currentProviderName=((ONBOARDING.status.setup||{}).current||{}).provider||'';
if(currentIsOauth){
const isReady=!!(ONBOARDING.status.system||{}).chat_ready;
const providerLabel=esc(currentProviderName);
if(isReady){
_setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success');
body.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-ready">
<div class="onboarding-oauth-icon">✓</div>
<div>
<strong>${t('onboarding_oauth_provider_ready_title')}</strong>
<p>${t('onboarding_oauth_provider_ready_body').replace('{provider}',providerLabel)}</p>
</div>
</div>
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
</label>
<label class="onboarding-field" id="onboardingApiKeyField">
<span>${t('onboarding_api_key_label')}</span>
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
</label>
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>`;
} else {
_setOnboardingNotice(t('onboarding_notice_setup_required'),'warn');
body.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-pending">
<div class="onboarding-oauth-icon">⚠</div>
<div>
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
</div>
</div>
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
</label>
<label class="onboarding-field" id="onboardingApiKeyField">
<span>${t('onboarding_api_key_label')}</span>
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
</label>
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>`;
}
return;
}
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
body.innerHTML=`
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
</label>
<label class="onboarding-field">
<span>${t('onboarding_api_key_label')}</span>
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
</label>
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
return;
}
if(key==='workspace'){
const workspaceOptions=_getOnboardingWorkspaceChoices().map(ws=>`<option value="${esc(ws.path)}">${esc(ws.name||ws.path)}${esc(ws.path)}</option>`).join('');
_setOnboardingNotice(t('onboarding_notice_workspace'), 'info');
body.innerHTML=`
<label class="onboarding-field">
<span>${t('onboarding_workspace_label')}</span>
<select id="onboardingWorkspaceSelect" onchange="syncOnboardingWorkspaceSelect(this.value)">${workspaceOptions}</select>
</label>
<label class="onboarding-field">
<span>${t('onboarding_workspace_or_path')}</span>
<input id="onboardingWorkspaceInput" value="${esc(ONBOARDING.form.workspace||'')}" placeholder="${t('onboarding_workspace_placeholder')}" oninput="ONBOARDING.form.workspace=this.value">
</label>
${_renderOnboardingModelField()}`;
const wsSel=$('onboardingWorkspaceSelect');
if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace;
const modelSel=$('onboardingModelSelect');
if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model;
return;
}
if(key==='password'){
_setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info');
body.innerHTML=`
<label class="onboarding-field">
<span>${t('onboarding_password_label')}</span>
<input id="onboardingPasswordInput" type="password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
</label>
<p class="onboarding-copy">${t('onboarding_password_help')}</p>`;
return;
}
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
_setOnboardingNotice(t('onboarding_notice_finish'), 'success');
body.innerHTML=`
<div class="onboarding-summary">
<div><strong>${t('onboarding_provider_label')}</strong><span>${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_model_label')}</strong><span>${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_workspace_label')}</strong><span>${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_check_password')}</strong><span>${t(_getOnboardingPasswordSummaryKey(settings))}</span></div>
</div>
${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''}
<p class="onboarding-copy">${t('onboarding_finish_help')}</p>`;
}
function _getOnboardingPasswordSummaryKey(settings){
const hasExistingPassword=!!(settings&&settings.password_enabled);
const hasNewPassword=!!((ONBOARDING.form.password||'').trim());
if(hasNewPassword) return hasExistingPassword?'onboarding_password_will_replace':'onboarding_password_will_enable';
return hasExistingPassword?'onboarding_password_keep_existing':'onboarding_password_remains_disabled';
}
function syncOnboardingWorkspaceSelect(value){
ONBOARDING.form.workspace=value;
const input=$('onboardingWorkspaceInput');
if(input) input.value=value;
}
function syncOnboardingProvider(value){
const provider=_getOnboardingSetupProvider(value);
ONBOARDING.form.provider=value;
if(provider){
if(!ONBOARDING.form.model || !_getOnboardingProviderModelChoices().some(m=>m.id===ONBOARDING.form.model) || value==='custom'){
ONBOARDING.form.model=provider.default_model||'';
}
if(provider.requires_base_url){
ONBOARDING.form.baseUrl=ONBOARDING.form.baseUrl||provider.default_base_url||'';
}else{
ONBOARDING.form.baseUrl=provider.default_base_url||'';
}
}
_renderOnboardingBody();
}
async function loadOnboardingWizard(){
try{
const status=await api('/api/onboarding/status');
ONBOARDING.status=status;
const current=((status.setup||{}).current)||{};
ONBOARDING.form.provider=current.provider||'openrouter';
ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||'';
ONBOARDING.form.model=status.settings.default_model||current.model||'';
ONBOARDING.form.password='';
ONBOARDING.form.apiKey='';
ONBOARDING.form.baseUrl=current.base_url||'';
ONBOARDING.active=!status.completed;
if(!ONBOARDING.active) return false;
$('onboardingOverlay').style.display='flex';
_renderOnboardingSteps();
_renderOnboardingBody();
return true;
}catch(e){
console.warn('onboarding status failed',e);
return false;
}
}
function prevOnboardingStep(){
if(ONBOARDING.step===0)return;
ONBOARDING.step--;
_renderOnboardingSteps();
_renderOnboardingBody();
}
async function _saveOnboardingProviderSetup(){
const provider=(ONBOARDING.form.provider||'').trim();
const model=(ONBOARDING.form.model||'').trim();
const apiKey=(ONBOARDING.form.apiKey||'').trim();
const baseUrl=(ONBOARDING.form.baseUrl||'').trim();
const current=_getOnboardingCurrentSetup();
const isUnchanged=current.provider===provider&&((current.model||'')===model)&&((current.base_url||'')===baseUrl);
// Skip the POST when nothing changed. We also skip when the provider is
// unsupported/OAuth-based and already working — chat_ready may be false for
// providers not in the quick-setup list (e.g. minimax-cn) even though they are
// fully configured. Posting in that case would either be a no-op (the server
// just marks complete for unsupported providers) or could silently overwrite
// config.yaml if the user accidentally changed the provider dropdown.
const currentIsOauth=!!(ONBOARDING.status&&ONBOARDING.status.setup&&ONBOARDING.status.setup.current_is_oauth);
if(isUnchanged && !apiKey && ((ONBOARDING.status.system||{}).chat_ready || currentIsOauth)) return;
const body={provider,model};
if(apiKey) body.api_key=apiKey;
if(baseUrl) body.base_url=baseUrl;
const status=await api('/api/onboarding/setup',{method:'POST',body:JSON.stringify(body)});
ONBOARDING.status=status;
}
async function _saveOnboardingDefaults(){
const workspace=(ONBOARDING.form.workspace||'').trim();
const model=(ONBOARDING.form.model||'').trim();
const password=(ONBOARDING.form.password||'').trim();
if(!workspace) throw new Error(t('onboarding_error_choose_workspace'));
if(!model) throw new Error(t('onboarding_error_choose_model'));
const known=_getOnboardingWorkspaceChoices().some(ws=>ws.path===workspace);
if(!known){
await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:workspace})});
}
// Model persisted by /api/onboarding/setup — no /api/default-model call needed here
const body={default_workspace:workspace};
if(password) body._set_password=password;
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
if(ONBOARDING.status){
ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled};
}
localStorage.setItem('hermes-webui-model',model);
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
}
async function _finishOnboarding(){
await _saveOnboardingProviderSetup();
await _saveOnboardingDefaults();
const done=await api('/api/onboarding/complete',{method:'POST',body:'{}'});
ONBOARDING.status=done;
ONBOARDING.active=false;
$('onboardingOverlay').style.display='none';
showToast(t('onboarding_complete'));
await loadWorkspaceList();
if(typeof renderSessionList==='function') await renderSessionList();
if(!S.session && typeof newSession==='function'){
await newSession(true);
await renderSessionList();
}
}
async function skipOnboarding(){
try{
// Mark onboarding completed server-side without changing any config
await api('/api/onboarding/complete',{method:'POST',body:'{}'});
ONBOARDING.active=false;
$('onboardingOverlay').style.display='none';
showToast(t('onboarding_skipped')||'Setup skipped');
}catch(e){
_setOnboardingNotice((e.message||String(e)),'warn');
}
}
async function nextOnboardingStep(){
try{
if(ONBOARDING.steps[ONBOARDING.step]==='setup'){
ONBOARDING.form.provider=(($('onboardingProviderSelect')||{}).value||ONBOARDING.form.provider||'').trim();
ONBOARDING.form.apiKey=(($('onboardingApiKeyInput')||{}).value||'').trim();
ONBOARDING.form.baseUrl=(($('onboardingBaseUrlInput')||{}).value||ONBOARDING.form.baseUrl||'').trim();
if(!ONBOARDING.form.provider) throw new Error(t('onboarding_error_provider_required'));
if(ONBOARDING.form.provider==='custom' && !ONBOARDING.form.baseUrl) throw new Error(t('onboarding_error_base_url_required'));
}
if(ONBOARDING.steps[ONBOARDING.step]==='workspace'){
ONBOARDING.form.workspace=(($('onboardingWorkspaceInput')||{}).value||ONBOARDING.form.workspace||'').trim();
ONBOARDING.form.model=(($('onboardingModelInput')||{}).value||($('onboardingModelSelect')||{}).value||ONBOARDING.form.model||'').trim();
if(!ONBOARDING.form.workspace) throw new Error(t('onboarding_error_workspace_required'));
if(!ONBOARDING.form.model) throw new Error(t('onboarding_error_model_required'));
}
if(ONBOARDING.steps[ONBOARDING.step]==='password'){
ONBOARDING.form.password=(($('onboardingPasswordInput')||{}).value||'').trim();
}
if(ONBOARDING.step===ONBOARDING.steps.length-1){
await _finishOnboarding();
return;
}
ONBOARDING.step++;
_renderOnboardingSteps();
_renderOnboardingBody();
}catch(e){
_setOnboardingNotice(e.message||String(e),'warn');
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

107
static/sw.js Normal file
View File

@@ -0,0 +1,107 @@
/**
* Hermes WebUI Service Worker
* Minimal PWA service worker — enables "Add to Home Screen".
* No offline caching of API responses (the UI requires a live backend).
* Caches only static shell assets so the app shell loads fast on repeat visits.
*/
// Cache version is injected by the server at request time (routes.py /sw.js handler).
// Bumps automatically whenever the git commit changes — no manual edits needed.
const CACHE_NAME = 'hermes-shell-__CACHE_VERSION__';
// Static assets that form the app shell
const SHELL_ASSETS = [
'./',
'./static/style.css',
'./static/boot.js',
'./static/ui.js',
'./static/messages.js',
'./static/sessions.js',
'./static/panels.js',
'./static/commands.js',
'./static/icons.js',
'./static/i18n.js',
'./static/workspace.js',
'./static/terminal.js',
'./static/onboarding.js',
'./static/favicon.svg',
'./static/favicon-32.png',
'./manifest.json',
];
// Install: pre-cache the app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(SHELL_ASSETS).catch((err) => {
// Non-fatal: if any asset fails, still activate
console.warn('[sw] Shell pre-cache partial failure:', err);
});
})
);
self.skipWaiting();
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
// Fetch strategy:
// - API calls (/api/*, /stream) → always network (never cache)
// - Shell assets → cache-first with network fallback
// - Everything else → network-first, fall back to offline page
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Never intercept cross-origin requests
if (url.origin !== self.location.origin) return;
// API and streaming endpoints — always go to network
if (
url.pathname.startsWith('/api/') ||
url.pathname.includes('/stream') ||
url.pathname.startsWith('/health')
) {
return; // let browser handle normally
}
// Shell assets: cache-first
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
// Cache successful GET responses for shell assets
if (
event.request.method === 'GET' &&
response.status === 200
) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
}).catch(() => {
// Offline fallback for navigation requests.
// Note: caches.match() returns a Promise (always truthy in a `||` check),
// so we must await/then to unwrap it — otherwise the `new Response(...)`
// branch is dead code and the browser falls back to its default offline page.
if (event.request.mode === 'navigate') {
return caches.match('./').then((cached) => cached || new Response(
'<html><body style="font-family:sans-serif;padding:2rem;background:#1a1a1a;color:#ccc">' +
'<h2>You are offline</h2>' +
'<p>Hermes requires a server connection. Please check your network and try again.</p>' +
'</body></html>',
{ headers: { 'Content-Type': 'text/html' } }
));
}
});
})
);
});

632
static/terminal.js Normal file
View File

@@ -0,0 +1,632 @@
const TERMINAL_UI={
open:false,
collapsed:false,
sessionId:null,
workspace:null,
source:null,
term:null,
fitAddon:null,
resizeObserver:null,
resizeTimer:null,
closeTimer:null,
typedLine:'',
height:null,
resizeHandleReady:false,
resizing:false,
resizeStartY:0,
resizeStartHeight:0,
};
const TERMINAL_HEIGHT_DEFAULT=260;
const TERMINAL_HEIGHT_MIN=180;
const TERMINAL_HEIGHT_MAX=520;
const TERMINAL_MOBILE_HEIGHT_DEFAULT=190;
const TERMINAL_MOBILE_HEIGHT_MIN=140;
const TERMINAL_MOBILE_HEIGHT_MAX=300;
function _terminalEls(){
return {
panel:$('composerTerminalPanel'),
inner:$('composerTerminalPanel')&&$('composerTerminalPanel').querySelector('.composer-terminal-inner'),
dock:$('composerTerminalDock'),
viewport:$('terminalViewport'),
surface:$('terminalSurface'),
toggle:$('btnTerminalToggle'),
workspace:$('terminalWorkspaceLabel'),
dockWorkspace:$('terminalDockWorkspaceLabel'),
handle:$('terminalResizeHandle'),
};
}
function _terminalSessionId(){
return S.session&&S.session.session_id;
}
function _terminalWorkspaceName(){
const ws=S.session&&S.session.workspace;
if(!ws)return '';
const parts=String(ws).split(/[\\/]+/).filter(Boolean);
return parts[parts.length-1]||ws;
}
function _isTerminalCloseCommand(value){
return ['exit','quit','logout','close'].includes(String(value||'').trim().toLowerCase());
}
function _trackTerminalInput(data){
if(data==='\r'||data==='\n'){
const command=TERMINAL_UI.typedLine;
TERMINAL_UI.typedLine='';
return command;
}
if(data==='\u0003'){
TERMINAL_UI.typedLine='';
return null;
}
if(data==='\u007f'||data==='\b'){
TERMINAL_UI.typedLine=TERMINAL_UI.typedLine.slice(0,-1);
return null;
}
if(data.length===1&&data>=' '){
TERMINAL_UI.typedLine+=data;
}else if(data.length>1&&/^[\x20-\x7e]+$/.test(data)){
TERMINAL_UI.typedLine+=data;
}
return null;
}
function _terminalCssVar(name,fallback){
const value=getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value||fallback;
}
function _terminalTheme(){
const isDark=document.documentElement.classList.contains('dark');
const background=_terminalCssVar('--code-bg',isDark?'#1A1A2E':'#F5F0E5');
const foreground=_terminalCssVar('--pre-text',_terminalCssVar('--text',isDark?'#E2E8F0':'#1A1610'));
const muted=_terminalCssVar('--muted',isDark?'#C0C0C0':'#5C5344');
const accent=_terminalCssVar('--accent-text',_terminalCssVar('--accent',isDark?'#FFD700':'#8B6508'));
const error=_terminalCssVar('--error',isDark?'#EF5350':'#C62828');
const success=_terminalCssVar('--success',isDark?'#4CAF50':'#3D8B40');
const warning=_terminalCssVar('--warning',isDark?'#FFA726':'#E68A00');
const info=_terminalCssVar('--info',isDark?'#4DD0E1':'#0288A8');
return {
background,
foreground,
cursor:accent,
selectionBackground:_terminalCssVar('--accent-bg-strong',isDark?'rgba(255,215,0,.18)':'rgba(184,134,11,.18)'),
black:isDark?'#0D0D1A':'#1A1610',
red:error,
green:success,
yellow:warning,
blue:info,
magenta:accent,
cyan:info,
white:foreground,
brightBlack:muted,
brightRed:error,
brightGreen:success,
brightYellow:accent,
brightBlue:info,
brightMagenta:accent,
brightCyan:info,
brightWhite:isDark?'#FFFFFF':'#0F0D08',
};
}
function syncComposerTerminalTheme(){
if(TERMINAL_UI.term)TERMINAL_UI.term.options.theme=_terminalTheme();
}
function _xtermReady(){
return typeof window.Terminal==='function';
}
function _ensureXterm(){
const {surface}= _terminalEls();
if(!surface)return null;
if(TERMINAL_UI.term)return TERMINAL_UI.term;
if(!_xtermReady()){
surface.textContent='Terminal library failed to load. Check network access to cdn.jsdelivr.net.';
return null;
}
const term=new window.Terminal({
cursorBlink:true,
fontSize:13,
fontFamily:'Menlo, Monaco, Consolas, "Liberation Mono", monospace',
scrollback:1000,
convertEol:false,
theme:_terminalTheme(),
});
let fitAddon=null;
if(window.FitAddon&&typeof window.FitAddon.FitAddon==='function'){
fitAddon=new window.FitAddon.FitAddon();
term.loadAddon(fitAddon);
}
if(window.WebLinksAddon&&typeof window.WebLinksAddon.WebLinksAddon==='function'){
term.loadAddon(new window.WebLinksAddon.WebLinksAddon());
}
term.open(surface);
term.onData(data=>{
const completedCommand=_trackTerminalInput(data);
if(completedCommand!==null&&_isTerminalCloseCommand(completedCommand)){
closeComposerTerminal();
return;
}
const sid=TERMINAL_UI.sessionId||_terminalSessionId();
if(!sid)return;
api('/api/terminal/input',{method:'POST',body:JSON.stringify({
session_id:sid,
data,
})}).catch(e=>showToast(t('terminal_input_failed')+e.message,2600,'error'));
});
TERMINAL_UI.term=term;
TERMINAL_UI.fitAddon=fitAddon;
_fitTerminal();
return term;
}
function _terminalDimensions(){
const term=TERMINAL_UI.term;
if(term&&term.cols&&term.rows)return {rows:term.rows,cols:term.cols};
return {rows:18,cols:80};
}
function _terminalHeightBounds(){
const mobile=window.matchMedia&&window.matchMedia('(max-width: 700px)').matches;
const min=mobile?TERMINAL_MOBILE_HEIGHT_MIN:TERMINAL_HEIGHT_MIN;
const maxByViewport=Math.floor(window.innerHeight*(mobile?0.44:0.5));
const hardMax=mobile?TERMINAL_MOBILE_HEIGHT_MAX:TERMINAL_HEIGHT_MAX;
return {
min,
max:Math.max(min,Math.min(hardMax,maxByViewport)),
defaultHeight:mobile?TERMINAL_MOBILE_HEIGHT_DEFAULT:TERMINAL_HEIGHT_DEFAULT,
};
}
function _clampTerminalHeight(height){
const bounds=_terminalHeightBounds();
const n=Number(height);
const fallback=TERMINAL_UI.height||bounds.defaultHeight;
return Math.max(bounds.min,Math.min(bounds.max,Number.isFinite(n)?n:fallback));
}
function _applyTerminalHeight(height){
const {inner,handle}= _terminalEls();
const next=_clampTerminalHeight(height);
TERMINAL_UI.height=next;
if(inner)inner.style.setProperty('--composer-terminal-height',next+'px');
if(handle){
const bounds=_terminalHeightBounds();
handle.setAttribute('aria-valuemin',String(bounds.min));
handle.setAttribute('aria-valuemax',String(bounds.max));
handle.setAttribute('aria-valuenow',String(next));
}
if(TERMINAL_UI.open&&!TERMINAL_UI.collapsed){
_fitTerminal();
_syncTerminalTranscriptSpace(true);
}
return next;
}
function _resetTerminalHeightForViewport(){
const bounds=_terminalHeightBounds();
_applyTerminalHeight(TERMINAL_UI.height||bounds.defaultHeight);
}
function _startTerminalHeightResize(ev){
if(ev.pointerType==='touch')return;
const {inner,handle}= _terminalEls();
if(!inner||!handle)return;
ev.preventDefault();
TERMINAL_UI.resizing=true;
TERMINAL_UI.resizeStartY=ev.clientY;
TERMINAL_UI.resizeStartHeight=TERMINAL_UI.height||inner.getBoundingClientRect().height||_terminalHeightBounds().defaultHeight;
inner.classList.add('is-resizing');
try{handle.setPointerCapture(ev.pointerId);}catch(_){}
}
function _moveTerminalHeightResize(ev){
if(!TERMINAL_UI.resizing)return;
ev.preventDefault();
_applyTerminalHeight(TERMINAL_UI.resizeStartHeight+(TERMINAL_UI.resizeStartY-ev.clientY));
}
function _endTerminalHeightResize(ev){
if(!TERMINAL_UI.resizing)return;
TERMINAL_UI.resizing=false;
const {inner,handle}= _terminalEls();
if(inner)inner.classList.remove('is-resizing');
if(handle&&ev&&ev.pointerId!==undefined)try{handle.releasePointerCapture(ev.pointerId);}catch(_){}
_fitTerminal();
}
function _handleTerminalResizeKey(ev){
let delta=0;
if(ev.key==='ArrowUp')delta=16;
else if(ev.key==='ArrowDown')delta=-16;
else if(ev.key==='PageUp')delta=64;
else if(ev.key==='PageDown')delta=-64;
else if(ev.key==='Home'){
ev.preventDefault();
return _applyTerminalHeight(_terminalHeightBounds().min);
}
else if(ev.key==='End'){
ev.preventDefault();
return _applyTerminalHeight(_terminalHeightBounds().max);
}
else return;
ev.preventDefault();
_applyTerminalHeight((TERMINAL_UI.height||_terminalHeightBounds().defaultHeight)+delta);
}
function _initTerminalResizeHandle(){
if(TERMINAL_UI.resizeHandleReady)return;
const {handle}= _terminalEls();
if(!handle)return;
TERMINAL_UI.resizeHandleReady=true;
handle.addEventListener('pointerdown',_startTerminalHeightResize);
handle.addEventListener('pointermove',_moveTerminalHeightResize);
handle.addEventListener('pointerup',_endTerminalHeightResize);
handle.addEventListener('pointercancel',_endTerminalHeightResize);
handle.addEventListener('keydown',_handleTerminalResizeKey);
}
function _terminalMessagesEl(){
return document.getElementById('messages');
}
function _terminalIsMessagesNearBottom(el){
if(!el)return false;
return el.scrollHeight-el.scrollTop-el.clientHeight<150;
}
function _syncTerminalTranscriptSpace(open,opts){
opts=opts||{};
const messages=_terminalMessagesEl();
if(!messages)return;
const wasNearBottom=_terminalIsMessagesNearBottom(messages);
if(!open){
messages.classList.remove('terminal-open');
messages.classList.remove('terminal-collapsed');
messages.classList.remove('terminal-expanding-from-dock');
messages.style.removeProperty('--terminal-card-height');
messages.style.removeProperty('--terminal-dock-height');
if(wasNearBottom&&typeof scrollToBottom==='function')requestAnimationFrame(scrollToBottom);
return;
}
if(open==='collapsed'){
messages.classList.remove('terminal-open');
messages.classList.add('terminal-collapsed');
}else{
messages.classList.add('terminal-open');
messages.classList.remove('terminal-collapsed');
}
const measure=()=>{
if(!TERMINAL_UI.open)return;
const {panel,inner,dock}= _terminalEls();
const target=open==='collapsed'?(dock||panel):(inner||panel);
const h=target&&target.getBoundingClientRect().height;
if(h>0){
if(open==='collapsed')messages.style.setProperty('--terminal-dock-height',Math.ceil(h+24)+'px');
else messages.style.setProperty('--terminal-card-height',Math.ceil(h+24)+'px');
}
if(wasNearBottom&&typeof scrollToBottom==='function')scrollToBottom();
};
if(opts.immediate)measure();
requestAnimationFrame(measure);
setTimeout(measure,420);
}
function _fitTerminal(){
const term=TERMINAL_UI.term;
if(!term)return;
if(TERMINAL_UI.collapsed)return;
try{
if(TERMINAL_UI.fitAddon)TERMINAL_UI.fitAddon.fit();
}catch(_){}
_syncTerminalTranscriptSpace(true);
_scheduleTerminalResize();
}
function _setTerminalChromeState(state){
const {panel,inner,dock,workspace,dockWorkspace}= _terminalEls();
const composerWrap=$('composerWrap');
if(!panel)return;
const collapsed=state==='collapsed';
const expanded=state==='expanded';
if(composerWrap)composerWrap.classList.toggle('terminal-dock-visible',collapsed);
panel.hidden=!(collapsed||expanded);
panel.classList.toggle('is-open',expanded);
panel.classList.toggle('is-collapsed',collapsed);
if(inner)inner.setAttribute('aria-hidden',collapsed?'true':'false');
if(dock)dock.hidden=!collapsed;
const label=_terminalWorkspaceName();
if(workspace)workspace.textContent=label;
if(dockWorkspace)dockWorkspace.textContent=label;
}
function syncTerminalButton(){
const {toggle}= _terminalEls();
const currentSid=_terminalSessionId();
const currentWorkspace=S.session&&S.session.workspace;
if(TERMINAL_UI.open&&TERMINAL_UI.sessionId&&(currentSid!==TERMINAL_UI.sessionId||currentWorkspace!==TERMINAL_UI.workspace)){
closeComposerTerminal(TERMINAL_UI.sessionId);
}
if(!toggle)return;
const hasWorkspace=!!(S.session&&S.session.workspace);
toggle.disabled=!hasWorkspace;
toggle.classList.toggle('active',TERMINAL_UI.open);
toggle.setAttribute('aria-pressed',TERMINAL_UI.open?'true':'false');
toggle.title=hasWorkspace?(TERMINAL_UI.collapsed?t('terminal_expand'):t('terminal_open_title')):t('terminal_no_workspace_title');
toggle.setAttribute('aria-label',toggle.title);
}
function focusComposerTerminalInput(){
if(TERMINAL_UI.term)TERMINAL_UI.term.focus();
}
function _connectTerminalOutput(){
const sid=_terminalSessionId();
if(!sid)return;
if(TERMINAL_UI.source){
try{TERMINAL_UI.source.close();}catch(_){}
TERMINAL_UI.source=null;
}
const url=new URL('api/terminal/output',location.href);
url.searchParams.set('session_id',sid);
const source=new EventSource(url.href,{withCredentials:true});
TERMINAL_UI.source=source;
source.addEventListener('output',ev=>{
if(TERMINAL_UI.source!==source)return;
let text='';
try{text=(JSON.parse(ev.data)||{}).text||'';}
catch(_){text=ev.data||'';}
if(TERMINAL_UI.term&&text)TERMINAL_UI.term.write(text);
});
source.addEventListener('terminal_closed',()=>{
if(TERMINAL_UI.source!==source)return;
if(TERMINAL_UI.term)TERMINAL_UI.term.writeln('\r\n[terminal closed]\r\n');
try{source.close();}catch(_){}
TERMINAL_UI.source=null;
setTimeout(()=>closeComposerTerminal(null,{skipApi:true}),260);
});
source.addEventListener('terminal_error',ev=>{
if(TERMINAL_UI.source!==source)return;
let msg=t('terminal_error');
try{msg=(JSON.parse(ev.data)||{}).error||msg;}catch(_){}
if(TERMINAL_UI.term)TERMINAL_UI.term.writeln('\r\n[terminal error] '+msg+'\r\n');
try{source.close();}catch(_){}
TERMINAL_UI.source=null;
});
}
async function _startComposerTerminal(restart=false){
const sid=_terminalSessionId();
if(!sid||!(S.session&&S.session.workspace)){
showToast(t('terminal_no_workspace_title'),2600,'warning');
syncTerminalButton();
return;
}
const term=_ensureXterm();
if(!term)return;
_fitTerminal();
const dims=_terminalDimensions();
await api('/api/terminal/start',{method:'POST',body:JSON.stringify({
session_id:sid,
rows:dims.rows,
cols:dims.cols,
restart:!!restart,
})});
TERMINAL_UI.sessionId=sid;
TERMINAL_UI.workspace=S.session&&S.session.workspace||null;
TERMINAL_UI.typedLine='';
_connectTerminalOutput();
_resizeComposerTerminal();
}
async function toggleComposerTerminal(force){
const next=typeof force==='boolean'?force:!TERMINAL_UI.open;
if(next){
if(TERMINAL_UI.open){
if(TERMINAL_UI.collapsed)expandComposerTerminal();
else focusComposerTerminalInput();
return;
}
const {panel,inner}= _terminalEls();
const messages=_terminalMessagesEl();
if(!panel)return;
clearTimeout(TERMINAL_UI.closeTimer);
_initTerminalResizeHandle();
_resetTerminalHeightForViewport();
if(messages)messages.classList.add('terminal-expanding-from-dock');
_setTerminalChromeState('expanded');
TERMINAL_UI.open=true;
TERMINAL_UI.collapsed=false;
_syncTerminalTranscriptSpace(true,{immediate:true});
if(messages)void messages.offsetHeight;
requestAnimationFrame(()=>{
panel.classList.add('is-open');
window.setTimeout(_fitTerminal,80);
setTimeout(()=>{
if(messages)messages.classList.remove('terminal-expanding-from-dock');
},120);
});
syncTerminalButton();
if(!TERMINAL_UI.resizeObserver&&window.ResizeObserver){
TERMINAL_UI.resizeObserver=new ResizeObserver(()=>_fitTerminal());
TERMINAL_UI.resizeObserver.observe(inner||panel);
}
try{
await _startComposerTerminal(false);
focusComposerTerminalInput();
}catch(e){
showToast(t('terminal_start_failed')+e.message,3200,'error');
}
}else{
await closeComposerTerminal();
}
}
function collapseComposerTerminal(){
if(!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
TERMINAL_UI.collapsed=true;
_setTerminalChromeState('collapsed');
_syncTerminalTranscriptSpace('collapsed');
syncTerminalButton();
}
function expandComposerTerminal(){
if(!TERMINAL_UI.open)return;
const {panel}= _terminalEls();
const messages=_terminalMessagesEl();
TERMINAL_UI.collapsed=false;
clearTimeout(TERMINAL_UI.closeTimer);
if(panel)panel.classList.add('is-expanding-from-dock');
if(messages)messages.classList.add('terminal-expanding-from-dock');
_syncTerminalTranscriptSpace(true,{immediate:true});
if(messages)void messages.offsetHeight;
_setTerminalChromeState('expanded');
_resetTerminalHeightForViewport();
requestAnimationFrame(()=>{
_fitTerminal();
focusComposerTerminalInput();
setTimeout(()=>{
if(panel)panel.classList.remove('is-expanding-from-dock');
if(messages)messages.classList.remove('terminal-expanding-from-dock');
},120);
});
syncTerminalButton();
}
function _disposeXterm(){
if(TERMINAL_UI.term){
try{TERMINAL_UI.term.dispose();}catch(_){}
}
TERMINAL_UI.term=null;
TERMINAL_UI.fitAddon=null;
TERMINAL_UI.typedLine='';
const {surface}= _terminalEls();
if(surface)surface.textContent='';
}
async function closeComposerTerminal(sessionId,opts){
opts=opts||{};
const sid=sessionId||TERMINAL_UI.sessionId||_terminalSessionId();
if(TERMINAL_UI.source){
try{TERMINAL_UI.source.close();}catch(_){}
TERMINAL_UI.source=null;
}
if(sid&&!opts.skipApi){
api('/api/terminal/close',{method:'POST',body:JSON.stringify({session_id:sid})}).catch(()=>{});
}
const {panel}= _terminalEls();
if(panel){
panel.classList.remove('is-open','is-collapsed','is-expanding-from-dock');
_syncTerminalTranscriptSpace(false);
clearTimeout(TERMINAL_UI.closeTimer);
TERMINAL_UI.closeTimer=setTimeout(()=>{
if(!TERMINAL_UI.open)panel.hidden=true;
_disposeXterm();
},280);
}else{
_syncTerminalTranscriptSpace(false);
_disposeXterm();
}
TERMINAL_UI.open=false;
TERMINAL_UI.collapsed=false;
const composerWrap=$('composerWrap');
if(composerWrap)composerWrap.classList.remove('terminal-dock-visible');
TERMINAL_UI.sessionId=null;
TERMINAL_UI.workspace=null;
syncTerminalButton();
}
async function restartComposerTerminal(){
if(!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
if(TERMINAL_UI.source){
try{TERMINAL_UI.source.close();}catch(_){}
TERMINAL_UI.source=null;
}
if(TERMINAL_UI.term)TERMINAL_UI.term.reset();
try{await _startComposerTerminal(true);}
catch(e){showToast(t('terminal_start_failed')+e.message,3200,'error');}
}
function clearComposerTerminal(){
if(TERMINAL_UI.term)TERMINAL_UI.term.clear();
}
function _terminalBufferText(){
const term=TERMINAL_UI.term;
if(!term||!term.buffer)return '';
const buffer=term.buffer.active;
const lines=[];
for(let i=0;i<buffer.length;i++){
const line=buffer.getLine(i);
if(line)lines.push(line.translateToString(true));
}
return lines.join('\n').trim();
}
async function copyComposerTerminalOutput(){
try{
const selection=TERMINAL_UI.term&&TERMINAL_UI.term.getSelection?TERMINAL_UI.term.getSelection():'';
await navigator.clipboard.writeText(selection||_terminalBufferText());
showToast(t('copied'));
}catch(e){
showToast(t('terminal_copy_failed')+e.message,2600,'error');
}
}
async function submitComposerTerminalInput(ev){
if(ev)ev.preventDefault();
}
function _scheduleTerminalResize(){
clearTimeout(TERMINAL_UI.resizeTimer);
TERMINAL_UI.resizeTimer=setTimeout(_resizeComposerTerminal,120);
}
async function _resizeComposerTerminal(){
if(!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
const sid=TERMINAL_UI.sessionId||_terminalSessionId();
if(!sid)return;
const dims=_terminalDimensions();
try{
await api('/api/terminal/resize',{method:'POST',body:JSON.stringify({
session_id:sid,
rows:dims.rows,
cols:dims.cols,
})});
}catch(_){}
}
window.addEventListener('beforeunload',()=>{
if(TERMINAL_UI.source)try{TERMINAL_UI.source.close();}catch(_){}
if(TERMINAL_UI.sessionId){
const url=new URL('api/terminal/close',location.href).href;
const body=JSON.stringify({session_id:TERMINAL_UI.sessionId});
try{
navigator.sendBeacon(url,new Blob([body],{type:'application/json'}));
}catch(_){
try{fetch(url,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(__){}
}
}
});
window.addEventListener('resize',()=>{
if(!TERMINAL_UI.open)return;
if(TERMINAL_UI.collapsed){
_syncTerminalTranscriptSpace('collapsed');
return;
}
_resetTerminalHeightForViewport();
});
if(window.MutationObserver){
new MutationObserver(syncComposerTerminalTheme).observe(document.documentElement,{
attributes:true,
attributeFilter:['class','data-skin'],
});
}

File diff suppressed because it is too large Load Diff

29
static/vendor/smd.min.js vendored Normal file
View File

@@ -0,0 +1,29 @@
var D=2,C=3,h=4,b=5,B=6,U=7,G=8,S=9,x=10,m=11,H=12,K=13,M=14,Q=15,w=16,q=17,W=18,P=19,Y=20,y=21,F=22,$=23,v=24,X=25,j=26,z=27,J=28,V=29,Z=30,p=31;var I=1,k=2,L=4,T=8,f=16;function ee(e){switch(e){case I:return"href";case k:return"src";case L:return"class";case T:return"checked";case f:return"start"}}var ne=e=>{switch(e){case 1:return 3;case 2:return 4;case 3:return 5;case 4:return 6;case 5:return 7;default:return 8}},te=ne;var O=24;function ae(e){let c=new Uint32Array(O);return c[0]=1,{renderer:e,text:"",pending:"",tokens:c,len:0,token:1,fence_end:0,blockquote_idx:0,hr_char:"",hr_chars:0,fence_start:0,spaces:new Uint8Array(O),indent:"",indent_len:0,table_state:0}}function ce(e){e.pending.length>0&&o(e,`
`)}function a(e){e.text.length!==0&&(e.renderer.add_text(e.renderer.data,e.text),e.text="")}function _(e){e.len-=1,e.token=e.tokens[e.len],e.renderer.end_token(e.renderer.data)}function i(e,c){(e.tokens[e.len]===24||e.tokens[e.len]===23)&&c!==25&&_(e),e.len+=1,e.tokens[e.len]=c,e.token=c,e.renderer.add_token(e.renderer.data,c)}function re(e,c,n){for(;n<=e.len;){if(e.tokens[n]===c)return n;n+=1}return-1}function l(e,c){for(e.fence_start=0;e.len>c;)_(e)}function u(e,c){let n=0;for(let t=0;t<=e.len&&(c-=e.spaces[t],!(c<0));t+=1)switch(e.tokens[t]){case 9:case 10:case 20:case 25:n=t;break}for(;e.len>n;)_(e);return c}function A(e,c){let n=-1,t=-1;for(let s=e.blockquote_idx+1;s<=e.len;s+=1)if(e.tokens[s]===25){if(e.indent_len<e.spaces[s]){t=-1;break}t=s}else e.tokens[s]===c&&(n=s);return t===-1?n===-1?(l(e,e.blockquote_idx),i(e,c),!0):(l(e,n),!1):(l(e,t),i(e,c),!0)}function g(e,c){i(e,25),e.spaces[e.len]=e.indent_len+c,E(e),e.token=103}function E(e){e.indent="",e.indent_len=0,e.pending=""}function N(e){switch(e){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:return!0;default:return!1}}function ie(e){switch(e){case 32:case 58:case 59:case 41:case 44:case 33:case 46:case 63:case 93:case 10:return!0;default:return!1}}function se(e){return N(e)||ie(e)}function o(e,c){for(let n of c){if(e.token===101){switch(n){case" ":e.indent_len+=1;continue;case" ":e.indent_len+=4;continue}let s=u(e,e.indent_len);e.indent_len=0,e.token=e.tokens[e.len],s>0&&o(e," ".repeat(s))}let t=e.pending+n;switch(e.token){case 21:case 1:case 20:case 24:case 23:switch(e.pending[0]){case void 0:e.pending=n;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=1;continue;case" ":e.pending=n,e.indent+=" ",e.indent_len+=4;continue;case`
`:if(e.tokens[e.len]===25&&e.token===21){_(e),E(e),e.pending=n;continue}l(e,e.blockquote_idx),E(e),e.blockquote_idx=0,e.fence_start=0,e.pending=n;continue;case"#":switch(n){case"#":if(e.pending.length<6){e.pending=t;continue}break;case" ":u(e,e.indent_len),i(e,te(e.pending.length)),E(e);continue}break;case">":{let r=re(e,20,e.blockquote_idx+1);r===-1?(l(e,e.blockquote_idx),e.blockquote_idx+=1,e.fence_start=0,i(e,20)):e.blockquote_idx=r,E(e),e.pending=n;continue}case"-":case"*":case"_":if(e.hr_chars===0&&(e.hr_chars=1,e.hr_char=e.pending),e.hr_chars>0){switch(n){case e.hr_char:e.hr_chars+=1,e.pending=t;continue;case" ":e.pending=t;continue;case`
`:if(e.hr_chars<3)break;u(e,e.indent_len),e.renderer.add_token(e.renderer.data,22),e.renderer.end_token(e.renderer.data),E(e),e.hr_chars=0;continue}e.hr_chars=0}if(e.pending[0]!=="_"&&e.pending[1]===" "){A(e,23),g(e,2),o(e,t.slice(2));continue}break;case"`":if(e.pending.length<3){if(n==="`"){e.pending=t,e.fence_start=t.length;continue}e.fence_start=0;break}switch(n){case"`":e.pending.length===e.fence_start?(e.pending=t,e.fence_start=t.length):(i(e,2),E(e),e.fence_start=0,o(e,t));continue;case`
`:{u(e,e.indent_len),i(e,10),e.pending.length>e.fence_start&&e.renderer.set_attr(e.renderer.data,L,e.pending.slice(e.fence_start)),E(e),e.token=101;continue}default:e.pending=t;continue}case"+":if(n!==" ")break;A(e,23),g(e,2);continue;case"0":case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":if(e.pending[e.pending.length-1]==="."){if(n!==" ")break;A(e,24)&&e.pending!=="1."&&e.renderer.set_attr(e.renderer.data,f,e.pending.slice(0,-1)),g(e,e.pending.length+1);continue}else{let r=n.charCodeAt(0);if(r===46||N(r)){e.pending=t;continue}}break;case"|":l(e,e.blockquote_idx),i(e,27),i(e,28),e.pending="",o(e,n);continue}let s=t;if(e.token===21)e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data);else if(e.indent_len>=4){let r=0;for(;r<4;r+=1)if(e.indent[r]===" "){r=r+1;break}s=e.indent.slice(r)+t,i(e,9)}else i(e,2);E(e),o(e,s);continue;case 27:if(e.table_state===1)switch(n){case"-":case" ":case"|":case":":e.pending=t;continue;case`
`:e.table_state=2,e.pending="";continue;default:_(e),e.table_state=0;break}else switch(e.pending){case"|":i(e,28),e.pending="",o(e,n);continue;case`
`:_(e),e.pending="",e.table_state=0,o(e,n);continue}break;case 28:switch(e.pending){case"":break;case"|":i(e,29),_(e),e.pending="",o(e,n);continue;case`
`:_(e),e.table_state=Math.min(e.table_state+1,2),e.pending="",o(e,n);continue;default:i(e,29),o(e,n);continue}break;case 29:if(e.pending==="|"){a(e),_(e),e.pending="",o(e,n);continue}break;case 9:switch(t){case`
`:case`
`:case`
`:case`
`:case`
`:e.text+=`
`,e.pending="";continue;case`
`:case`
`:case`
`:case`
`:e.pending=t;continue;default:e.pending.length!==0?(a(e),_(e),e.pending=n):e.text+=n;continue}case 10:switch(n){case"`":e.pending=t;continue;case`
`:if(t.length===e.fence_start+e.fence_end+1){a(e),_(e),e.pending="",e.fence_start=0,e.fence_end=0,e.token=101;continue}e.token=101;break;case" ":if(e.pending[0]===`
`){e.pending=t,e.fence_end+=1;continue}break}e.text+=e.pending,e.pending=n,e.fence_end=1;continue;case 11:switch(n){case"`":t.length===e.fence_start+ +(e.pending[0]===" ")?(a(e),_(e),e.pending="",e.fence_start=0):e.pending=t;continue;case`
`:e.text+=e.pending,e.pending="",e.token=21,e.blockquote_idx=0,a(e);continue;case" ":e.text+=e.pending,e.pending=n;continue;default:e.text+=t,e.pending="";continue}case 103:switch(e.pending.length){case 0:if(n!=="[")break;e.pending=t;continue;case 1:if(n!==" "&&n!=="x")break;e.pending=t;continue;case 2:if(n!=="]")break;e.pending=t;continue;case 3:if(n!==" ")break;e.renderer.add_token(e.renderer.data,26),e.pending[1]==="x"&&e.renderer.set_attr(e.renderer.data,T,""),e.renderer.end_token(e.renderer.data),e.pending=" ";continue}e.token=e.tokens[e.len],e.pending="",o(e,t);continue;case 14:case 15:{let r="*",d=12;if(e.token===15&&(r="_",d=13),r===e.pending){if(a(e),r===n){_(e),e.pending="";continue}i(e,d),e.pending=n;continue}break}case 12:case 13:{let r="*",d=14;switch(e.token===13&&(r="_",d=15),e.pending){case r:r===n?e.tokens[e.len-1]===d?e.pending=t:(a(e),i(e,d),e.pending=""):(a(e),_(e),e.pending=n);continue;case r+r:let R=e.token;a(e),_(e),_(e),r!==n?(i(e,R),e.pending=n):e.pending="";continue}break}case 16:if(t==="~~"){a(e),_(e),e.pending="";continue}break;case 105:n===`
`?(a(e),i(e,30),e.pending=""):(e.token=e.tokens[e.len],e.pending[0]==="\\"?e.text+="[":e.text+="$$",e.pending="",o(e,n));continue;case 30:if(t==="\\]"||t==="$$"){a(e),_(e),e.pending="";continue}break;case 31:if(t==="\\)"||e.pending[0]==="$"){a(e),_(e),n===")"?e.pending="":e.pending=n;continue}break;case 102:t==="http://"||t==="https://"?(a(e),i(e,18),e.pending=t,e.text=t):"http:/"[e.pending.length]===n||"https:/"[e.pending.length]===n?e.pending=t:(e.token=e.tokens[e.len],o(e,n));continue;case 17:case 19:if(e.pending==="]"){a(e),n==="("?e.pending=t:(_(e),e.pending=n);continue}if(e.pending[0]==="]"&&e.pending[1]==="("){if(n===")"){let r=e.token===17?I:k,d=e.pending.slice(2);e.renderer.set_attr(e.renderer.data,r,d),_(e),e.pending=""}else e.pending+=n;continue}break;case 18:n===" "||n===`
`||n==="\\"?(e.renderer.set_attr(e.renderer.data,I,e.pending),a(e),_(e),e.pending=n):(e.text+=n,e.pending=t);continue;case 104:if(t.startsWith("<br")){if(t.length===3||n===" "||n==="/"&&(t.length===4||e.pending[e.pending.length-1]===" ")){e.pending=t;continue}if(n===">"){a(e),e.token=e.tokens[e.len],e.renderer.add_token(e.renderer.data,21),e.renderer.end_token(e.renderer.data),e.pending="";continue}}e.token=e.tokens[e.len],e.text+="<",e.pending=e.pending.slice(1),o(e,n);continue}switch(e.pending[0]){case"\\":if(e.token===19||e.token===30||e.token===31)break;switch(n){case"(":a(e),i(e,31),e.pending="";continue;case"[":e.token=105,e.pending=t;continue;case`
`:e.pending=n;continue;default:let s=n.charCodeAt(0);e.pending="",e.text+=N(s)||s>=65&&s<=90||s>=97&&s<=122?t:n;continue}case`
`:switch(e.token){case 19:case 30:case 31:break;case 3:case 4:case 5:case 6:case 7:case 8:a(e),l(e,e.blockquote_idx),e.blockquote_idx=0,e.pending=n;continue;default:a(e),e.pending=n,e.token=21,e.blockquote_idx=0;continue}break;case"<":if(e.token!==19&&e.token!==30&&e.token!==31){a(e),e.pending=t,e.token=104;continue}break;case"`":if(e.token===19)break;n==="`"?(e.fence_start+=1,e.pending=t):(e.fence_start+=1,a(e),i(e,11),e.text=n===" "||n===`
`?"":n,e.pending="");continue;case"_":case"*":{if(e.token===19||e.token===30||e.token===31||e.token===14)break;let s=12,r=14,d=e.pending[0];if(d==="_"&&(s=13,r=15),e.pending.length===1){if(d===n){e.pending=t;continue}if(n!==" "&&n!==`
`){a(e),i(e,s),e.pending=n;continue}}else{if(d===n){a(e),i(e,r),i(e,s),e.pending="";continue}if(n!==" "&&n!==`
`){a(e),i(e,r),e.pending=n;continue}}break}case"~":if(e.token!==19&&e.token!==16){if(e.pending==="~"){if(n==="~"){e.pending=t;continue}}else if(n!==" "&&n!==`
`){a(e),i(e,16),e.pending=n;continue}}break;case"$":if(e.token!==19&&e.token!==16&&e.pending==="$")if(n==="$"){e.token=105,e.pending=t;continue}else{if(se(n.charCodeAt(0)))break;a(e),i(e,31),e.pending=n;continue}break;case"[":if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n!=="]"){a(e),i(e,17),e.pending=n;continue}break;case"!":if(e.token!==19&&n==="["){a(e),i(e,19),e.pending="";continue}break;case" ":if(e.pending.length===1&&n===" ")continue;break}if(e.token!==19&&e.token!==17&&e.token!==30&&e.token!==31&&n==="h"&&(e.pending===" "||e.pending==="")){e.text+=e.pending,e.pending=n,e.token=102;continue}e.text+=e.pending,e.pending=n}a(e)}function _e(e){return{add_token:oe,end_token:de,add_text:Ee,set_attr:le,data:{nodes:[e,,,,,],index:0}}}function oe(e,c){let n=e.nodes[e.index],t;switch(c){case 1:return;case 20:t=document.createElement("blockquote");break;case 2:t=document.createElement("p");break;case 21:t=document.createElement("br");break;case 22:t=document.createElement("hr");break;case 3:t=document.createElement("h1");break;case 4:t=document.createElement("h2");break;case 5:t=document.createElement("h3");break;case 6:t=document.createElement("h4");break;case 7:t=document.createElement("h5");break;case 8:t=document.createElement("h6");break;case 12:case 13:t=document.createElement("em");break;case 14:case 15:t=document.createElement("strong");break;case 16:t=document.createElement("s");break;case 11:t=document.createElement("code");break;case 18:case 17:t=document.createElement("a");break;case 19:t=document.createElement("img");break;case 23:t=document.createElement("ul");break;case 24:t=document.createElement("ol");break;case 25:t=document.createElement("li");break;case 26:let s=t=document.createElement("input");s.type="checkbox",s.disabled=!0;break;case 9:case 10:n=n.appendChild(document.createElement("pre")),t=document.createElement("code");break;case 27:t=document.createElement("table");break;case 28:switch(n.children.length){case 0:n=n.appendChild(document.createElement("thead"));break;case 1:n=n.appendChild(document.createElement("tbody"));break;default:n=n.children[1]}t=document.createElement("tr");break;case 29:t=document.createElement(n.parentElement?.tagName==="THEAD"?"th":"td");break;case 30:t=document.createElement("equation-block");break;case 31:t=document.createElement("equation-inline");break}e.nodes[++e.index]=n.appendChild(t)}function de(e){e.index-=1}function Ee(e,c){e.nodes[e.index].appendChild(document.createTextNode(c))}function le(e,c,n){e.nodes[e.index].setAttribute(ee(c),n)}export{Y as BLOCKQUOTE,j as CHECKBOX,T as CHECKED,S as CODE_BLOCK,x as CODE_FENCE,m as CODE_INLINE,Z as EQUATION_BLOCK,p as EQUATION_INLINE,C as HEADING_1,h as HEADING_2,b as HEADING_3,B as HEADING_4,U as HEADING_5,G as HEADING_6,I as HREF,P as IMAGE,H as ITALIC_AST,K as ITALIC_UND,L as LANG,y as LINE_BREAK,q as LINK,X as LIST_ITEM,v as LIST_ORDERED,$ as LIST_UNORDERED,D as PARAGRAPH,W as RAW_URL,F as RULE,k as SRC,f as START,w as STRIKE,M as STRONG_AST,Q as STRONG_UND,z as TABLE,V as TABLE_CELL,J as TABLE_ROW,_e as default_renderer,ae as parser,ce as parser_end,o as parser_write};

View File

@@ -1,15 +1,36 @@
async function api(path,opts={}){
const url=new URL(path,location.origin);
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
if(!res.ok){
const text=await res.text();
// Parse JSON error body and surface the human-readable message,
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);}
catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;}
// Strip leading slash so URL resolves relative to location.href (supports subpath mounts)
const rel = path.startsWith('/') ? path.slice(1) : path;
const url=new URL(rel,location.href);
// Retry up to 2 times on network errors (e.g. stale keep-alive after long idle).
// Server errors (4xx/5xx) are NOT retried — only connection failures.
let lastErr;
for(let attempt=0;attempt<3;attempt++){
try{
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
if(!res.ok){
// 401 means the auth session expired. Redirect to /login so the user can
// re-authenticate. This is especially important for iOS PWA (standalone mode)
// where a server-side 302 → /login opens in Safari instead of within the PWA.
if(res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;}
const text=await res.text();
// Parse JSON error body and surface the human-readable message,
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);}
catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;}
}
const ct=res.headers.get('content-type')||'';
return ct.includes('application/json')?res.json():res.text();
}catch(e){
lastErr=e;
// Only retry on network errors (TypeError from fetch), not on HTTP errors
// that were already thrown above. Re-throw 401 redirects immediately.
if(e.message&&/401/.test(e.message)) throw e;
if(attempt<2 && e instanceof TypeError) continue;
throw e;
}
}
const ct=res.headers.get('content-type')||'';
return ct.includes('application/json')?res.json():res.text();
throw lastErr;
}
// Persist/restore expanded directory state per workspace in localStorage
@@ -41,20 +62,23 @@ async function loadDir(path){
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
// Pre-fetch contents of restored expanded dirs so they render without a second click
// (parallelized — avoids serial waterfall when multiple dirs are expanded)
if(!path||path==='.'){
for(const dirPath of (S._expandedDirs||[])){
if(!S._dirCache[dirPath]){
try{
const dc=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`);
S._dirCache[dirPath]=dc.entries||[];
}catch(e2){S._dirCache[dirPath]=[];}
}
const expanded=S._expandedDirs||new Set();
const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]);
if(pending.length){
const results=await Promise.all(pending.map(dirPath=>
api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`)
.then(dc=>({dirPath,entries:dc.entries||[]}))
.catch(()=>({dirPath,entries:[]}))
));
for(const {dirPath,entries} of results) S._dirCache[dirPath]=entries;
}
if(S._expandedDirs&&S._expandedDirs.size>0)renderFileTree();
if(expanded.size>0)renderFileTree();
}
if(typeof clearPreview==='function'){
if(typeof _previewDirty!=='undefined'&&_previewDirty){
if(confirm('You have unsaved changes in the preview. Discard and navigate?'))clearPreview();
showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview();});
}else{
clearPreview();
}
@@ -95,6 +119,7 @@ function navigateUp(){
// File extension sets for preview routing (must match server-side sets)
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
const HTML_EXTS = new Set(['.html','.htm']);
// Binary formats that should download rather than preview
const DOWNLOAD_EXTS = new Set([
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
@@ -108,21 +133,25 @@ const DOWNLOAD_EXTS = new Set([
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
let _previewCurrentPath = ''; // relative path of currently previewed file
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html'
let _previewDirty = false; // true when edits are unsaved
function showPreview(mode){
// mode: 'code' | 'image' | 'md'
// mode: 'code' | 'image' | 'md' | 'html'
$('previewCode').style.display = mode==='code' ? '' : 'none';
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
$('previewMd').style.display = mode==='md' ? '' : 'none';
$('previewHtmlWrap').style.display = mode==='html' ? '' : 'none';
$('previewEditArea').style.display = 'none'; // start in read-only
const badge=$('previewBadge');
badge.className='preview-badge '+mode;
badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text';
badge.textContent = mode==='image'?'image':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text';
_previewCurrentMode = mode;
_previewDirty = false;
updateEditBtn();
// Show "Open in browser" button only for HTML mode
const openBtn=$('btnOpenInBrowser');
if(openBtn) openBtn.style.display = mode==='html'?'inline-flex':'none';
}
function updateEditBtn(){
@@ -131,8 +160,8 @@ function updateEditBtn(){
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md';
btn.style.display = editable?'':'none';
const editing = $('previewEditArea').style.display!=='none';
btn.innerHTML = editing ? '&#128190; Save' : '&#9998; Edit';
btn.title = editing ? 'Save changes' : 'Edit this file';
btn.innerHTML = editing ? `&#128190; ${t('save')}` : `&#9998; ${t('edit')}`;
btn.title = editing ? t('save_title') : t('edit_title');
btn.style.color = editing ? 'var(--blue)' : '';
if(_previewDirty) btn.innerHTML = '&#128190; Save*';
}
@@ -150,12 +179,12 @@ async function toggleEditMode(){
_previewDirty=false;
// Update read-only views
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
else $('previewMd').innerHTML=renderMd(content);
else { $('previewMd').innerHTML=renderMd(content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); }
$('previewEditArea').style.display='none';
if(_previewCurrentMode==='code') $('previewCode').style.display='';
else $('previewMd').style.display='';
showToast('Saved');
}catch(e){setStatus('Save failed: '+e.message);}
showToast(t('saved'));
}catch(e){setStatus(t('save_failed')+e.message);}
}else{
// Enter edit mode: populate textarea with current content
const currentText = _previewCurrentMode==='code'
@@ -200,13 +229,14 @@ async function openFile(path){
$('fileTree').style.display='none';
_previewCurrentPath = path;
renderFileBreadcrumb(path);
if(IMAGE_EXTS.has(ext)){
// Image: load via raw endpoint, show as <img>
showPreview('image');
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
$('previewImg').alt=path;
$('previewImg').src=url;
$('previewImg').onerror=()=>setStatus('Could not load image');
$('previewImg').onerror=()=>setStatus(t('image_load_failed'));
} else if(MD_EXTS.has(ext)){
// Markdown: fetch text, render with renderMd, display as formatted HTML
try{
@@ -214,7 +244,24 @@ async function openFile(path){
showPreview('md');
_previewRawContent = data.content;
$('previewMd').innerHTML=renderMd(data.content);
}catch(e){setStatus('Could not open file');}
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
}catch(e){setStatus(t('file_open_failed'));}
} else if(HTML_EXTS.has(ext)){
// HTML: render in sandboxed iframe via raw endpoint.
// SECURITY TRADEOFF: We use sandbox="allow-scripts" which lets inline JS run
// but prevents access to the parent frame (origin isolation). This is a
// deliberate choice — the user is previewing their own workspace files, so
// blocking scripts entirely would break most HTML documents. The sandbox
// still prevents the preview from navigating the parent, accessing cookies,
// or reading other origin data. If a stricter mode is needed, remove
// allow-scripts (or add sandbox="") to disable all JS execution.
showPreview('html');
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`;
const iframe=$('previewHtmlIframe');
if(iframe){
iframe.src=''; // clear first to avoid stale content
iframe.src=url;
}
} else {
// Plain code / text -- but fall back to download if server signals binary
try{
@@ -236,12 +283,56 @@ async function openFile(path){
function downloadFile(path){
if(!S.session)return;
// Trigger browser download via the raw file endpoint with content-disposition attachment
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
const filename=path.split('/').pop();
const a=document.createElement('a');
a.href=url;a.download=filename;
document.body.appendChild(a);a.click();
setTimeout(()=>document.body.removeChild(a),100);
showToast(`Downloading ${filename}\u2026`,2000);
showToast(t('downloading',filename),2000);
}
// ── Render breadcrumb for file preview mode ──────────────────────────────────
function renderFileBreadcrumb(filePath) {
const bar = $('breadcrumbBar');
if (!bar) return;
bar.style.display = 'flex';
const upBtn = $('btnUpDir');
if (upBtn) upBtn.style.display = '';
bar.innerHTML = '';
// Root
const root = document.createElement('span');
root.className = 'breadcrumb-seg breadcrumb-link';
root.textContent = '~';
root.onclick = () => { clearPreview(); loadDir('.'); };
bar.appendChild(root);
const parts = filePath.split('/');
let accumulated = '';
for (let i = 0; i < parts.length; i++) {
const sep = document.createElement('span');
sep.className = 'breadcrumb-sep';
sep.textContent = '/';
bar.appendChild(sep);
accumulated += (accumulated ? '/' : '') + parts[i];
const seg = document.createElement('span');
seg.textContent = parts[i];
if (i < parts.length - 1) {
seg.className = 'breadcrumb-seg breadcrumb-link';
const target = accumulated;
seg.onclick = () => { clearPreview(); loadDir(target); };
} else {
seg.className = 'breadcrumb-seg breadcrumb-current';
}
bar.appendChild(seg);
}
}
function openInBrowser(){
if(!_previewCurrentPath||!S.session) return;
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(_previewCurrentPath)}`;
window.open(url,'_blank');
}

46
tests/_pytest_port.py Normal file
View File

@@ -0,0 +1,46 @@
"""
Shared test server constants for use in individual test files.
Instead of hardcoding ``BASE = "http://127.0.0.1:8788"`` in every test file,
import from here so the port and state dir are always consistent with
what conftest.py computed for this worktree.
Usage::
from tests._pytest_port import BASE
conftest.py publishes ``HERMES_WEBUI_TEST_PORT`` and
``HERMES_WEBUI_TEST_STATE_DIR`` to ``os.environ`` at module level
(before any test file is imported), so this module always reads the
correct values. The auto-derivation fallback matches conftest's logic
exactly, so standalone imports also work correctly.
"""
import hashlib
import os
import pathlib
def _auto_test_port(repo_root: pathlib.Path) -> int:
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
return 20000 + (h % 10000)
def _auto_state_dir_name(repo_root: pathlib.Path) -> str:
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
return f"webui-test-{h}"
_TESTS_DIR = pathlib.Path(__file__).parent.resolve()
_REPO_ROOT = _TESTS_DIR.parent.resolve()
_HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME',
str(pathlib.Path.home() / '.hermes')))
TEST_PORT = int(os.environ.get('HERMES_WEBUI_TEST_PORT',
str(_auto_test_port(_REPO_ROOT))))
BASE = f"http://127.0.0.1:{TEST_PORT}"
TEST_STATE_DIR = pathlib.Path(os.environ.get(
'HERMES_WEBUI_TEST_STATE_DIR',
str(_HERMES_HOME / _auto_state_dir_name(_REPO_ROOT))
))
# Default model injected by conftest — tests that mutate the default model
# must restore to this value so later tests see a consistent baseline.
TEST_DEFAULT_MODEL = os.environ.get('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')

View File

@@ -31,14 +31,45 @@ HOME = pathlib.Path.home()
HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME', str(HOME / '.hermes')))
# ── Test server config ────────────────────────────────────────────────────
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT', '8788'))
# Port and state dir auto-derive from the repo path when no env var is set,
# giving every worktree its own isolated port (8800-8899) and state directory.
# Override with HERMES_WEBUI_TEST_PORT / HERMES_WEBUI_TEST_STATE_DIR to pin.
def _auto_test_port(repo_root) -> int:
"""Map repo path to a unique port in 20000-29999 (10k range = near-zero collisions).
Far from system port ranges and Linux ephemeral ports (32768+).
Override with HERMES_WEBUI_TEST_PORT to use a specific port."""
import hashlib
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
return 20000 + (h % 10000)
def _auto_state_dir_name(repo_root) -> str:
import hashlib
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
return f"webui-test-{h}"
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT',
str(_auto_test_port(REPO_ROOT))))
TEST_BASE = f"http://127.0.0.1:{TEST_PORT}"
TEST_STATE_DIR = pathlib.Path(os.getenv(
'HERMES_WEBUI_TEST_STATE_DIR',
str(HERMES_HOME / 'webui-mvp-test')
str(HERMES_HOME / _auto_state_dir_name(REPO_ROOT))
))
TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace'
# Publish at module level so api.config, _pytest_port.py, and any test module
# importing stateful API code during collection see the isolated test paths.
#
# Direct assignment is intentional for production-risk paths: tests that import
# api.config/api.models in the pytest process must never inherit the real
# ~/.hermes state tree before the server subprocess fixture starts.
os.environ['HERMES_WEBUI_TEST_PORT'] = str(TEST_PORT)
os.environ['HERMES_WEBUI_TEST_STATE_DIR'] = str(TEST_STATE_DIR)
os.environ['HERMES_WEBUI_STATE_DIR'] = str(TEST_STATE_DIR)
os.environ['HERMES_WEBUI_DEFAULT_WORKSPACE'] = str(TEST_WORKSPACE)
os.environ['HERMES_HOME'] = str(TEST_STATE_DIR)
os.environ['HERMES_BASE_HOME'] = str(TEST_STATE_DIR)
# ── Server script: always relative to repo root ───────────────────────────
SERVER_SCRIPT = REPO_ROOT / 'server.py'
if not SERVER_SCRIPT.exists():
@@ -153,6 +184,8 @@ def pytest_collection_modifyitems(config, items):
# Agent backend (need running AIAgent)
'test_chat_stream_opens_successfully',
'test_approval_submit_and_respond',
# Security redaction (flaky — session state varies across test ordering)
'test_api_sessions_list_redacts_titles',
# Workspace path (macOS /tmp -> /private/tmp symlink)
'test_new_session_inherits_workspace',
'test_workspace_add_valid',
@@ -170,7 +203,7 @@ def pytest_collection_modifyitems(config, items):
skipped += 1
if skipped:
print(f"\n⚠️ hermes-agent not found {skipped} agent-dependent tests will be skipped\n")
print(f"\nWARNING: hermes-agent not found; {skipped} agent-dependent tests will be skipped\n")
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -210,6 +243,18 @@ def test_server():
Start an isolated test server on TEST_PORT with a clean state directory.
Paths are discovered dynamically -- no hardcoded absolute path assumptions.
"""
# Kill any leftover process on the test port before starting.
# Stale servers from QA harness runs or prior test sessions cause
# conftest to think the server is already up, producing false failures.
try:
import subprocess as _sp
_sp.run(['fuser', '-k', f'{TEST_PORT}/tcp'],
capture_output=True, timeout=5)
except Exception:
pass
import time as _time
_time.sleep(0.5) # brief pause to let the port release
# Clean slate
if TEST_STATE_DIR.exists():
shutil.rmtree(TEST_STATE_DIR)
@@ -226,7 +271,25 @@ def test_server():
# Isolated cron state
(TEST_STATE_DIR / 'cron').mkdir(parents=True, exist_ok=True)
# Expose TEST_STATE_DIR to the test process itself so that tests which write
# directly to state.db (e.g. test_gateway_sync.py) always use the same path
# as the server. Other test files (test_auth_sessions.py) may override
# HERMES_WEBUI_STATE_DIR for their own purposes, but HERMES_WEBUI_TEST_STATE_DIR
# is reserved for this mapping and is never overridden by individual test files.
# Export both port and state-dir as env vars so individual test files
# can read them without importing conftest (avoids circular imports).
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
# os.environ already set at module level above; no-op here.
env = os.environ.copy()
# Strip real provider keys so test subprocess never inherits production credentials.
# The test server uses a mock/isolated config — no real API calls are made.
for _k in list(env):
if any(_k.startswith(p) for p in (
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
'GOOGLE_API_KEY', 'DEEPSEEK_API_KEY',
)):
del env[_k]
env.update({
"HERMES_WEBUI_PORT": str(TEST_PORT),
"HERMES_WEBUI_HOST": "127.0.0.1",
@@ -234,6 +297,14 @@ def test_server():
"HERMES_WEBUI_DEFAULT_WORKSPACE": str(TEST_WORKSPACE),
"HERMES_WEBUI_DEFAULT_MODEL": "openai/gpt-5.4-mini",
"HERMES_HOME": str(TEST_STATE_DIR),
# Belt-and-suspenders: HERMES_BASE_HOME hard-locks _DEFAULT_HERMES_HOME
# in api/profiles.py to the test state dir regardless of profile switching
# or any os.environ mutation that happens inside the server process.
# Without this, a profile switch or active_profile file in the real
# ~/.hermes can redirect _get_active_hermes_home() out of the sandbox,
# causing onboarding writes (config.yaml, .env) to land in the production
# ~/.hermes/profiles/webui/ and overwrite real API keys.
"HERMES_BASE_HOME": str(TEST_STATE_DIR),
})
# Pass agent dir if discovered so server.py doesn't have to re-discover
@@ -279,6 +350,33 @@ def base_url():
return TEST_BASE
# ── Per-test model cache invalidation ────────────────────────────────────────
# The TTL cache for get_available_models() persists across tests within the
# same process. Tests that modify cfg in-memory won't trigger the mtime path,
# so the cache must be explicitly invalidated after each test that exercises
# provider/model detection.
@pytest.fixture(autouse=True)
def _invalidate_models_cache_after_test():
"""Force the TTL cache to be cleared before and after every test.
This prevents state bleed where a test that calls get_available_models()
populates the cache with a particular config, and the next test sees stale
results even though it has mutated _cfg_cache in-memory.
"""
try:
from api.config import invalidate_models_cache
invalidate_models_cache()
except Exception:
pass
yield
try:
from api.config import invalidate_models_cache
invalidate_models_cache()
except Exception:
pass
# ── Per-test session cleanup ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)

View File

@@ -0,0 +1,106 @@
"""
Tests for issue #1038 — iOS PWA auth-expiry redirect.
When a 401 is returned by any API endpoint, the client-side JS should redirect
to /login rather than showing a raw error toast. On iOS PWA standalone mode a
server-side 302→/login breaks out of the PWA shell into Safari, so the fix is
client-side: workspace.js api() intercepts 401 before throwing and calls
window.location.href = '/login'.
These are static regression tests that verify the JS source contains the
correct guard patterns.
"""
import re
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _workspace_js() -> str:
return (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
def _ui_js() -> str:
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
class TestPWAAuthRedirect:
def test_workspace_js_has_401_redirect(self):
"""api() in workspace.js must redirect to /login on 401."""
src = _workspace_js()
# Guard must appear inside the !res.ok block, before throwing
assert "res.status===401" in src, \
"workspace.js api() must check res.status===401"
assert "window.location.href='/login" in src or 'window.location.href="/login' in src, \
"workspace.js api() must redirect to /login on 401"
def test_workspace_js_401_before_throw(self):
"""The 401 redirect must come before the generic error throw."""
src = _workspace_js()
idx_401 = src.find("res.status===401")
idx_throw = src.find("throw new Error")
assert idx_401 != -1, "401 guard not found in workspace.js"
assert idx_throw != -1, "throw not found in workspace.js"
assert idx_401 < idx_throw, \
"401 redirect must appear before the generic throw in workspace.js"
def test_ui_js_has_redirect_helper(self):
"""ui.js must define _redirectIfUnauth helper."""
src = _ui_js()
assert "_redirectIfUnauth" in src, \
"ui.js must define _redirectIfUnauth helper function"
def test_ui_js_models_fetch_uses_redirect(self):
"""populateModelDropdown() must call _redirectIfUnauth on the api/models response."""
src = _ui_js()
# The helper must be called after the api/models fetch
assert "_redirectIfUnauth(_modelsRes)" in src, \
"populateModelDropdown() must check 401 on api/models fetch"
def test_ui_js_live_models_fetch_uses_redirect(self):
"""loadLiveModels() must call _redirectIfUnauth on the api/models/live response."""
src = _ui_js()
assert "_redirectIfUnauth(_liveRes)" in src, \
"loadLiveModels() must check 401 on api/models/live fetch"
def test_ui_js_upload_fetch_uses_redirect(self):
"""File upload must call _redirectIfUnauth on the api/upload response."""
src = _ui_js()
assert "_redirectIfUnauth(res)" in src, \
"upload fetch must call _redirectIfUnauth"
class TestLoginJsSafeNextPath:
"""login.js _safeNextPath() must honor ?next= but reject open-redirect payloads."""
@staticmethod
def _login_js():
return (Path(__file__).parent.parent / "static" / "login.js").read_text(encoding="utf-8")
def test_safe_next_path_function_exists(self):
"""login.js must define _safeNextPath() to honor the ?next= redirect."""
assert "_safeNextPath" in self._login_js(), (
"login.js must define _safeNextPath() to use the ?next= redirect after login"
)
def test_login_uses_safe_next_path(self):
"""doLogin success handler must redirect to _safeNextPath(), not hardcoded './'."""
src = self._login_js()
assert "_safeNextPath()" in src, (
"doLogin must call _safeNextPath() instead of hardcoding './'"
)
def test_safe_next_path_rejects_protocol_relative(self):
"""_safeNextPath guard must reject '//' prefix (protocol-relative open-redirect)."""
src = self._login_js()
assert "charAt(1) === '/'" in src or "startsWith('//')" in src, (
"_safeNextPath must reject protocol-relative paths like //evil.com"
)
def test_safe_next_path_rejects_non_path_absolute(self):
"""_safeNextPath guard must require path starts with '/'."""
src = self._login_js()
assert "charAt(0) !== '/'" in src or "startsWith('/')" in src, (
"_safeNextPath must reject non-path-absolute inputs (e.g. 'http://...')"
)

View File

@@ -0,0 +1,51 @@
"""
Tests for issue #1044 — Mermaid CSP font violation.
Mermaid's built-in themes inject an @import for Google Fonts (Manrope) at
render time, which is blocked by the CSP's style-src directive. Fix: pass
fontFamily:'inherit' in themeVariables so Mermaid never requests an external
font URL.
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _ui_js() -> str:
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
class TestMermaidCSPFont:
def test_mermaid_init_has_font_family_inherit(self):
"""themeVariables in mermaid.initialize() must set fontFamily to 'inherit'."""
src = _ui_js()
assert "fontFamily:'inherit'" in src, (
"mermaid.initialize() themeVariables must set fontFamily:'inherit' "
"to suppress the Google Fonts (Manrope) import that violates CSP"
)
def test_mermaid_init_no_google_fonts_url(self):
"""ui.js must not contain a hardcoded fonts.googleapis.com URL."""
src = _ui_js()
assert "fonts.googleapis.com" not in src, (
"ui.js must not reference fonts.googleapis.com — use fontFamily:'inherit'"
)
def test_mermaid_font_family_inside_theme_variables_block(self):
"""fontFamily:'inherit' must be inside the themeVariables block of mermaid.initialize()."""
src = _ui_js()
init_idx = src.find("mermaid.initialize(")
assert init_idx != -1, "mermaid.initialize() call not found in ui.js"
# Find the themeVariables block after the initialize call
tv_idx = src.find("themeVariables", init_idx)
assert tv_idx != -1, "themeVariables not found inside mermaid.initialize()"
font_idx = src.find("fontFamily:'inherit'", tv_idx)
assert font_idx != -1, (
"fontFamily:'inherit' must appear inside themeVariables in mermaid.initialize()"
)
# The closing brace of themeVariables should come after fontFamily
close_brace = src.find("})", tv_idx)
assert font_idx < close_brace, (
"fontFamily:'inherit' must be inside the themeVariables block (before })"
)

View File

@@ -0,0 +1,116 @@
"""
Tests for issue #1045 — bfcache layout broken on tab restore.
When the browser restores a page from bfcache (event.persisted === true),
the async boot IIFE does not re-run. The existing pageshow handler (added for
#822) only cleared the session search field and re-rendered the session list.
This left the rail, topbar, workspace panel, and resize handles in the stale
bfcache DOM state, producing a broken layout.
Fix: extend the pageshow handler to also call syncTopbar, syncWorkspacePanelState,
_initResizePanels, and startGatewaySSE — all guarded so missing helpers degrade.
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _boot_js() -> str:
return (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
class TestBfcacheLayoutRestore:
def test_pageshow_calls_sync_topbar(self):
"""pageshow handler must call syncTopbar() on bfcache restore."""
src = _boot_js()
# Find the pageshow listener block
ps_idx = src.find("window.addEventListener('pageshow'")
assert ps_idx != -1, "pageshow listener not found in boot.js"
handler_body = src[ps_idx:ps_idx + 1600]
assert "syncTopbar" in handler_body, (
"pageshow handler must call syncTopbar() to restore topbar state after bfcache"
)
def test_pageshow_calls_sync_workspace_panel_state(self):
"""pageshow handler must call syncWorkspacePanelState()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "syncWorkspacePanelState" in handler_body, (
"pageshow handler must call syncWorkspacePanelState() on bfcache restore"
)
def test_pageshow_calls_start_gateway_sse(self):
"""pageshow handler must call startGatewaySSE() to reconnect the dead SSE connection."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "startGatewaySSE" in handler_body, (
"pageshow handler must restart gateway SSE (bfcache-persisted connections are dead)"
)
def test_pageshow_still_clears_session_search(self):
"""pageshow handler must still clear #sessionSearch (original #822 fix preserved)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "sessionSearch" in handler_body, (
"pageshow handler must still clear #sessionSearch (regression: #822 fix must be preserved)"
)
def test_pageshow_still_calls_render_session_list_from_cache(self):
"""pageshow handler must still call renderSessionListFromCache()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "renderSessionListFromCache" in handler_body, (
"pageshow handler must still call renderSessionListFromCache() (regression: #822 fix)"
)
def test_pageshow_does_not_call_init_resize_panels(self):
"""pageshow handler must NOT call _initResizePanels() — bfcache
preserves event listeners so re-attaching them stacks duplicates."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
assert "_initResizePanels" not in handler_body, (
"pageshow handler must not call _initResizePanels() — it stacks "
"duplicate mousedown listeners on every bfcache restore"
)
def test_new_calls_are_guarded_with_typeof(self):
"""New calls in the pageshow handler must be typeof-guarded for safe degradation."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
# Each of the new calls must be guarded
for fn in ("syncTopbar", "syncWorkspacePanelState", "startGatewaySSE",
"closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
assert f"typeof {fn} === 'function'" in handler_body, (
f"{fn}() call in pageshow handler must be guarded with typeof === 'function'"
)
def test_pageshow_closes_all_dropdowns(self):
"""pageshow handler must close all known dropdowns to reset frozen bfcache popover state."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
for fn in ("closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
assert fn in handler_body, (
f"pageshow handler must call {fn}() to dismiss any dropdown left open by bfcache"
)
def test_dropdowns_closed_before_layout_sync(self):
"""Dropdown closes must come before layout sync calls (clean state first)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
close_idx = handler_body.find("closeModelDropdown")
sync_idx = handler_body.find("syncTopbar")
assert close_idx != -1 and sync_idx != -1, "Both close and sync calls must be present"
assert close_idx < sync_idx, (
"Dropdown close calls must appear before layout sync calls in the pageshow handler"
)

View File

@@ -0,0 +1,382 @@
"""Tests for adaptive session title refresh helpers (PR #1058).
Covers all five new functions added to api/streaming.py:
- _count_exchanges
- _latest_exchange_snippets
- _get_title_refresh_interval
- _run_background_title_refresh
- _maybe_schedule_title_refresh
"""
import sys
import os
import threading
import types
import unittest
from unittest.mock import MagicMock, patch
import pytest
# Ensure the project root is on sys.path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from api.streaming import (
_count_exchanges,
_latest_exchange_snippets,
_get_title_refresh_interval,
_run_background_title_refresh,
_maybe_schedule_title_refresh,
)
@pytest.fixture(autouse=True)
def _restore_auth_sessions():
"""Snapshot and restore api.auth._sessions around each test.
Importing api.streaming can trigger api.config.load_settings() which may
call into api.auth and create a real session token. Without this fixture,
that stale token leaks into test_auth_session_persistence.py tests (which
assume _sessions starts empty) when our file runs first alphabetically.
"""
import api.auth as _auth
snapshot = dict(_auth._sessions)
yield
_auth._sessions.clear()
_auth._sessions.update(snapshot)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _user_msg(text):
return {'role': 'user', 'content': text}
def _asst_msg(text, tool_calls=None):
msg = {'role': 'assistant', 'content': text}
if tool_calls is not None:
msg['tool_calls'] = tool_calls
return msg
def _tool_only_asst():
"""Assistant message that only has tool_calls, no real text."""
return {'role': 'assistant', 'content': '', 'tool_calls': [{'id': 't1', 'type': 'function'}]}
def _make_session(title='My Title', llm_title_generated=True, messages=None, session_id='sid1'):
s = MagicMock()
s.title = title
s.llm_title_generated = llm_title_generated
s.messages = messages or []
s.session_id = session_id
s.save = MagicMock()
return s
# ---------------------------------------------------------------------------
# _count_exchanges
# ---------------------------------------------------------------------------
class TestCountExchanges:
def test_empty_messages_returns_zero(self):
assert _count_exchanges([]) == 0
def test_none_messages_returns_zero(self):
assert _count_exchanges(None) == 0
def test_counts_only_user_messages(self):
msgs = [_user_msg('hello'), _asst_msg('hi'), _user_msg('world')]
assert _count_exchanges(msgs) == 2
def test_skips_empty_user_messages(self):
msgs = [_user_msg(''), _user_msg(' '), _user_msg('real question')]
assert _count_exchanges(msgs) == 1
def test_counts_list_content_user_messages(self):
msgs = [
{'role': 'user', 'content': [{'type': 'text', 'text': 'list question'}]},
]
assert _count_exchanges(msgs) == 1
def test_skips_empty_list_content(self):
msgs = [
{'role': 'user', 'content': [{'type': 'text', 'text': ' '}]},
]
assert _count_exchanges(msgs) == 0
def test_ignores_non_user_roles(self):
msgs = [_asst_msg('response'), {'role': 'system', 'content': 'system prompt'}]
assert _count_exchanges(msgs) == 0
def test_ignores_non_dict_entries(self):
msgs = ['not a dict', _user_msg('real'), None]
assert _count_exchanges(msgs) == 1
def test_five_exchanges(self):
msgs = []
for i in range(5):
msgs.append(_user_msg(f'question {i}'))
msgs.append(_asst_msg(f'answer {i}'))
assert _count_exchanges(msgs) == 5
# ---------------------------------------------------------------------------
# _latest_exchange_snippets
# ---------------------------------------------------------------------------
class TestLatestExchangeSnippets:
def test_empty_returns_empty_strings(self):
u, a = _latest_exchange_snippets([])
assert u == '' and a == ''
def test_none_returns_empty_strings(self):
u, a = _latest_exchange_snippets(None)
assert u == '' and a == ''
def test_basic_pair(self):
msgs = [_user_msg('first q'), _asst_msg('first a'),
_user_msg('second q'), _asst_msg('second a')]
u, a = _latest_exchange_snippets(msgs)
assert u == 'second q'
assert a == 'second a'
def test_returns_latest_not_first(self):
msgs = [_user_msg('old q'), _asst_msg('old a'),
_user_msg('new q'), _asst_msg('new a')]
u, a = _latest_exchange_snippets(msgs)
assert u == 'new q'
def test_skips_tool_call_only_assistant(self):
"""An assistant msg with tool_calls and no real text should be skipped."""
msgs = [_user_msg('q'), _asst_msg('real answer'),
_user_msg('q2'), _tool_only_asst()]
u, a = _latest_exchange_snippets(msgs)
# _tool_only_asst should be skipped; fall back to previous real assistant
assert a == 'real answer'
assert u == 'q2'
def test_truncates_long_content(self):
long_text = 'x' * 600
msgs = [_user_msg(long_text), _asst_msg(long_text)]
u, a = _latest_exchange_snippets(msgs)
assert len(u) == 500
assert len(a) == 500
def test_no_assistant_message(self):
msgs = [_user_msg('q')]
u, a = _latest_exchange_snippets(msgs)
assert u == 'q'
assert a == ''
def test_no_user_message(self):
msgs = [_asst_msg('a')]
u, a = _latest_exchange_snippets(msgs)
assert u == ''
assert a == 'a'
def test_ignores_non_dict_entries(self):
msgs = ['noise', _user_msg('q'), None, _asst_msg('a')]
u, a = _latest_exchange_snippets(msgs)
assert u == 'q'
assert a == 'a'
# ---------------------------------------------------------------------------
# _get_title_refresh_interval
# ---------------------------------------------------------------------------
class TestGetTitleRefreshInterval:
def test_returns_int_for_valid_setting(self):
# _get_title_refresh_interval does a local import: `from api.config import load_settings`
# so patch the source module, not api.streaming
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '5'}):
assert _get_title_refresh_interval() == 5
def test_returns_zero_for_off_setting(self):
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '0'}):
assert _get_title_refresh_interval() == 0
def test_returns_zero_when_key_absent(self):
with patch('api.config.load_settings', return_value={}):
assert _get_title_refresh_interval() == 0
def test_returns_zero_on_exception(self):
with patch('api.config.load_settings', side_effect=Exception('boom')):
assert _get_title_refresh_interval() == 0
def test_valid_values_10_and_20(self):
for val in ('10', '20'):
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': val}):
assert _get_title_refresh_interval() == int(val)
# ---------------------------------------------------------------------------
# _run_background_title_refresh
# ---------------------------------------------------------------------------
class TestRunBackgroundTitleRefresh:
def _make_put_event(self):
events = []
def put(name, data):
events.append((name, data))
return put, events
def _make_session_obj(self, title='Old Title'):
s = MagicMock()
s.title = title
s.llm_title_generated = True
s.save = MagicMock()
return s
def test_skips_when_title_changed_before_call(self):
"""If the title has changed (manual rename) since the refresh was scheduled, skip."""
put, events = self._make_put_event()
with patch('api.streaming.get_session') as mock_get, \
patch('api.streaming.SESSIONS', {}), \
patch('api.streaming.LOCK', threading.Lock()):
s = self._make_session_obj(title='Different Title')
mock_get.return_value = s
_run_background_title_refresh(
'sid', 'user', 'asst', 'Old Title', put, agent=None
)
# No 'title' event should have been emitted
assert not any(name == 'title' for name, _ in events)
def test_skips_if_session_not_found(self):
put, events = self._make_put_event()
with patch('api.streaming.get_session', side_effect=KeyError('not found')):
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
assert events == []
def test_skips_when_title_is_untitled(self):
put, events = self._make_put_event()
with patch('api.streaming.get_session') as mock_get:
s = self._make_session_obj(title='Untitled')
mock_get.return_value = s
_run_background_title_refresh('sid', 'u', 'a', 'Untitled', put)
assert not any(name == 'title' for name, _ in events)
def test_skips_same_title(self):
"""If the LLM generates a title identical to the current one, no event is emitted."""
put, events = self._make_put_event()
with patch('api.streaming.get_session') as mock_get, \
patch('api.streaming._aux_title_configured', return_value=True), \
patch('api.streaming._generate_llm_session_title_via_aux',
return_value=('Old Title', 'llm_ok', 'raw')), \
patch('api.streaming.SESSIONS', {}), \
patch('api.streaming.LOCK', threading.Lock()):
s = self._make_session_obj(title='Old Title')
mock_get.return_value = s
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
assert not any(name == 'title' for name, _ in events)
def test_emits_title_event_on_new_title(self):
put, events = self._make_put_event()
s = self._make_session_obj(title='Old Title')
# Use a real dict for SESSIONS so .get() works, pre-populated with our session
fake_sessions = {'sid': s}
with patch('api.streaming.get_session', return_value=s), \
patch('api.streaming._aux_title_configured', return_value=True), \
patch('api.streaming._generate_llm_session_title_via_aux',
return_value=('New Refreshed Title', 'llm_ok', 'raw')), \
patch('api.streaming.SESSIONS', fake_sessions), \
patch('api.streaming.LOCK', threading.Lock()):
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
title_events = [(n, d) for n, d in events if n == 'title']
assert len(title_events) == 1
assert title_events[0][1]['title'] == 'New Refreshed Title'
def test_exceptions_are_silently_swallowed(self):
"""Any unexpected error inside must not propagate — it's a background daemon."""
put, events = self._make_put_event()
with patch('api.streaming.get_session', side_effect=RuntimeError('oops')):
# Should not raise
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
assert events == []
# ---------------------------------------------------------------------------
# _maybe_schedule_title_refresh
# ---------------------------------------------------------------------------
class TestMaybeScheduleTitleRefresh:
def _noop_put(self, name, data):
pass
def test_does_nothing_when_disabled(self):
with patch('api.streaming._get_title_refresh_interval', return_value=0):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_does_nothing_when_title_is_empty(self):
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(title='', messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_does_nothing_for_untitled(self):
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(title='Untitled', messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_does_nothing_when_title_not_llm_generated(self):
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(llm_title_generated=False,
messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_does_nothing_when_exchange_count_not_at_interval(self):
"""Refresh only fires when exchange_count % interval == 0 (and > 0)."""
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
# 4 exchanges — not a multiple of 5
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 4)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []
def test_spawns_thread_at_exact_interval(self):
"""Refresh fires when exchange_count == refresh_interval."""
with patch('api.streaming._get_title_refresh_interval', return_value=5):
spawned = []
with patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread_cls.return_value = mock_thread
# 5 user messages = 5 exchanges
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert mock_thread_cls.called
assert mock_thread.start.called
def test_spawns_thread_at_multiple_of_interval(self):
"""Refresh fires at 10 exchanges when interval is 5."""
with patch('api.streaming._get_title_refresh_interval', return_value=5):
with patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread_cls.return_value = mock_thread
# 10 exchanges
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 10)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert mock_thread_cls.called
def test_does_nothing_when_no_exchange_content(self):
"""Even at interval, if both snippets are empty, don't spawn."""
with patch('api.streaming._get_title_refresh_interval', return_value=5), \
patch('api.streaming._latest_exchange_snippets', return_value=('', '')):
spawned = []
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
_maybe_schedule_title_refresh(session, self._noop_put, None)
assert spawned == []

View File

@@ -0,0 +1,84 @@
"""Regression tests for settings picker active-state highlighting.
The theme, skin, and font-size pickers in the Appearance settings tab must show
the currently-selected option with a visible accent border. This was broken because
the CSS rule used !important on border-color:var(--border) which overrode the inline
style that _syncThemePicker/etc. set. Fixed by moving to .active CSS class + !important
override on the active state.
Issue: #1059 (settings picker active state)
"""
from pathlib import Path
BOOT_JS = (Path(__file__).parent.parent / "static" / "boot.js").read_text(encoding="utf-8")
STYLE_CSS = (Path(__file__).parent.parent / "static" / "style.css").read_text(encoding="utf-8")
class TestSettingsPickerActiveState:
"""The selected picker card must be visually distinct via the .active class."""
def test_theme_picker_uses_active_class(self):
"""_syncThemePicker must toggle .active class, not set inline borderColor."""
idx = BOOT_JS.find("function _syncThemePicker(")
assert idx >= 0, "_syncThemePicker function not found in boot.js"
body = BOOT_JS[idx:idx + 300]
assert "classList.toggle" in body, (
"_syncThemePicker must use classList.toggle('active', ...) — "
"inline style.borderColor is overridden by !important CSS rules"
)
# Confirm no accent/border2 color values set inline (clearing with '' is OK)
assert "var(--accent)" not in body and "var(--border2)" not in body, (
"_syncThemePicker must not set var(--accent) or var(--border2) inline — "
"those are overridden by !important CSS rules"
)
def test_font_size_picker_uses_active_class(self):
"""_syncFontSizePicker must toggle .active class."""
idx = BOOT_JS.find("function _syncFontSizePicker(")
assert idx >= 0, "_syncFontSizePicker function not found in boot.js"
body = BOOT_JS[idx:idx + 300]
assert "classList.toggle" in body, (
"_syncFontSizePicker must use classList.toggle('active', ...)"
)
assert "var(--accent)" not in body and "var(--border2)" not in body, (
"_syncFontSizePicker must not set var(--accent) or var(--border2) inline"
)
def test_skin_picker_uses_active_class(self):
"""_syncSkinPicker must toggle .active class."""
idx = BOOT_JS.find("function _syncSkinPicker(")
assert idx >= 0, "_syncSkinPicker function not found in boot.js"
body = BOOT_JS[idx:idx + 300]
assert "classList.toggle" in body, (
"_syncSkinPicker must use classList.toggle('active', ...)"
)
assert "var(--accent)" not in body and "var(--border2)" not in body, (
"_syncSkinPicker must not set var(--accent) or var(--border2) inline"
)
def test_css_active_rule_beats_base_rule(self):
"""CSS must have a .active rule with !important that overrides the base border-color rule."""
assert ".theme-pick-btn.active" in STYLE_CSS, (
"style.css must have a .theme-pick-btn.active rule"
)
assert ".font-size-pick-btn.active" in STYLE_CSS, (
"style.css must have a .font-size-pick-btn.active rule"
)
assert ".skin-pick-btn.active" in STYLE_CSS, (
"style.css must have a .skin-pick-btn.active rule"
)
# The active rule must use !important to beat the base !important rule
idx = STYLE_CSS.find(".theme-pick-btn.active")
rule = STYLE_CSS[idx:idx + 200]
assert "!important" in rule, (
".theme-pick-btn.active must use !important to override "
"the base border-color:var(--border)!important rule"
)
def test_active_rule_uses_accent_color(self):
"""The .active rule must apply the accent color to make selection visible."""
idx = STYLE_CSS.find(".theme-pick-btn.active")
rule = STYLE_CSS[idx:idx + 200]
assert "var(--accent)" in rule, (
".theme-pick-btn.active must set border-color to var(--accent)"
)

View File

@@ -0,0 +1,375 @@
"""Regression tests for busy_input_mode (PR #1062, closes #720).
Pins the wiring for the three modes (queue / interrupt / steer):
- The setting key + default + enum validation in api/config.py
- Three slash commands registered in static/commands.js
- send()'s busy branch reads window._busyInputMode and dispatches
- Boot initializes window._busyInputMode from settings
- 17 new i18n keys present in all 6 locale blocks
Issue: #720 (configurable busy-input behaviour)
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8")
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
# ── Backend: setting registration + enum validation ─────────────────────
class TestBusyInputModeSetting:
"""The new setting key must be registered with a default and enum validator."""
def test_default_is_queue(self):
"""Default value preserves existing queue behaviour for users who don't touch the setting."""
assert '"busy_input_mode": "queue"' in CONFIG_PY, (
"_DEFAULT_SETTINGS must include busy_input_mode='queue' so existing users see no change"
)
def test_enum_validator_present(self):
"""_SETTINGS_ENUM_KEYS must validate busy_input_mode against {queue, interrupt, steer}."""
# Find the entry inside the enum dict (a set literal as the value)
idx = CONFIG_PY.find('"busy_input_mode": {')
assert idx >= 0, "busy_input_mode entry missing from _SETTINGS_ENUM_KEYS"
block = CONFIG_PY[idx:idx + 200]
assert '"queue"' in block and '"interrupt"' in block and '"steer"' in block, (
"busy_input_mode enum must contain {queue, interrupt, steer}"
)
# ── Frontend: slash commands ─────────────────────────────────────────────
class TestSlashCommandRegistration:
"""The three new slash commands must be registered in COMMANDS array."""
def test_queue_command_registered(self):
assert "name:'queue'" in COMMANDS_JS and "fn:cmdQueue" in COMMANDS_JS
def test_interrupt_command_registered(self):
assert "name:'interrupt'" in COMMANDS_JS and "fn:cmdInterrupt" in COMMANDS_JS
def test_steer_command_registered(self):
assert "name:'steer'" in COMMANDS_JS and "fn:cmdSteer" in COMMANDS_JS
def test_all_three_busy_commands_are_no_echo(self):
"""All three busy commands must set noEcho:true so the slash invocation
is not echoed as a visible user bubble. Without noEcho, /queue causes a
double-bubble: the raw slash text appears, then the queued message appears
again when the drain fires.
"""
for name in ("queue", "interrupt", "steer"):
idx = COMMANDS_JS.find(f"name:'{name}'")
assert idx >= 0, f"{name} not registered"
block = COMMANDS_JS[idx:idx + 250]
assert "noEcho:true" in block, (
f"/{name} registration must set noEcho:true — "
"without it the command text is echoed as a user bubble, causing duplicates"
)
class TestSlashCommandHandlers:
"""The three handler functions must guard properly and call cancelStream where appropriate."""
def test_cmd_queue_handles_idle_state(self):
"""/queue when idle now sends the message normally instead of showing an
error toast. The if(!S.busy) guard must still exist — it routes to the
idle-send path rather than the queue path."""
idx = COMMANDS_JS.find("async function cmdQueue(")
assert idx >= 0
body = COMMANDS_JS[idx:idx + 600]
assert "if(!S.busy)" in body, "/queue must have an if(!S.busy) guard that routes to send()"
def test_cmd_interrupt_calls_cancel_stream(self):
idx = COMMANDS_JS.find("async function cmdInterrupt(")
assert idx >= 0
body = COMMANDS_JS[idx:idx + 1300] # expanded: idle-fallback block added before the busy path
assert "queueSessionMessage" in body, "/interrupt must queue the new message before cancelling"
assert "cancelStream" in body, "/interrupt must call cancelStream() so the drain re-sends"
def test_cmd_steer_delegates_to_try_steer(self):
"""/steer delegates to _trySteer which calls /api/chat/steer with
a queue+cancel fallback. The fallback path is exercised by tests
in test_real_steer.py — this test just pins the delegation."""
idx = COMMANDS_JS.find("async function cmdSteer(")
assert idx >= 0
body = COMMANDS_JS[idx:idx + 800]
# cmdSteer now delegates to _trySteer; the fallback (queueSessionMessage
# + cancelStream) lives inside _trySteer.
assert "_trySteer" in body, "cmdSteer must call _trySteer to use the real /api/chat/steer endpoint"
# The shared helper must contain the fallback path
helper_idx = COMMANDS_JS.find("async function _trySteer(")
assert helper_idx >= 0, "_trySteer helper must exist"
helper_body = COMMANDS_JS[helper_idx:helper_idx + 1500]
assert "queueSessionMessage" in helper_body
assert "cancelStream" in helper_body
# Toast should differ from interrupt to signal it's the steer path
assert "cmd_steer_fallback" in helper_body or "steer_fallback" in helper_body
# ── send() busy branch ───────────────────────────────────────────────────
def test_slash_commands_clear_pending_files(self):
"""All three busy command handlers must clear S.pendingFiles (directly
or via _trySteer) after enqueuing, so staged files are not duplicated.
cmdQueue and cmdInterrupt call queueSessionMessage themselves and clear
S.pendingFiles directly. cmdSteer delegates to _trySteer. The fallback/interrupt path clears
S.pendingFiles inside _trySteer; the success path returns early and
send() handles the post-await clear. Either way files are not
duplicated — we verify by checking _trySteer body for the clearing.
"""
# cmdQueue and cmdInterrupt clear pendingFiles directly
for fn_name in ("cmdQueue", "cmdInterrupt"):
idx = COMMANDS_JS.find(f"function {fn_name}(")
assert idx >= 0, f"{fn_name} not found"
body = COMMANDS_JS[idx:idx + 800]
assert "S.pendingFiles=[]" in body, (
f"{fn_name} must clear S.pendingFiles after queueSessionMessage"
)
assert "renderTray()" in body, (
f"{fn_name} must call renderTray() after clearing pendingFiles"
)
# cmdSteer delegates to _trySteer; that helper clears pendingFiles
idx_try = COMMANDS_JS.find("function _trySteer(")
assert idx_try >= 0, "_trySteer not found"
try_body = COMMANDS_JS[idx_try:idx_try + 1200]
assert "S.pendingFiles=[]" in try_body, (
"_trySteer must clear S.pendingFiles in its fallback path — "
"without this, files are lost on steer→interrupt fallback"
)
assert "renderTray()" in try_body, (
"_trySteer must call renderTray() after clearing pendingFiles"
)
class TestBusySendButton:
"""The composer send button must remain usable for busy-input actions."""
def test_update_send_btn_uses_single_primary_action_button(self):
idx = UI_JS.find("function updateSendBtn()")
assert idx >= 0, "updateSendBtn() not found"
body = UI_JS[idx:UI_JS.find("function setBusy", idx)]
assert "getComposerPrimaryAction()" in body, (
"updateSendBtn must derive icon/color/enabled state from one composer-primary action helper"
)
assert "btn.dataset.action=action" in body, (
"btnSend should expose its current action for CSS, tests, and accessibility"
)
assert "btn.classList.toggle('stop',action==='stop')" in body, (
"busy/no-draft state should turn the single primary button into the red stop action"
)
assert "btn.style.display=''" in body, (
"the single primary button should remain visible while busy; it becomes Stop when there is no draft"
)
def test_composer_primary_action_accounts_for_all_busy_input_modes(self):
idx = UI_JS.find("function getComposerPrimaryAction()")
assert idx >= 0, "getComposerPrimaryAction() not found"
body = UI_JS[idx:UI_JS.find("function _setComposerPrimaryButtonIcon", idx)]
assert "return 'stop'" in body, "busy/no-draft + active stream must map to stop"
assert "return 'queue'" in body, "queue mode and unavailable steer/interrupt fallbacks must map to queue"
assert "return 'interrupt'" in body, "interrupt mode with an active stream must map to interrupt"
assert "return 'steer'" in body, "steer mode with active stream support must map to steer"
assert "window._busyInputMode||'queue'" in body, "helper must respect the Busy input mode setting"
assert "_getExplicitBusyCommandAction(msg&&msg.value)" in body, (
"explicit /queue, /interrupt, and /steer drafts must override the Busy input mode for button visuals"
)
def test_explicit_busy_commands_override_button_visual_action(self):
idx = UI_JS.find("function _getExplicitBusyCommandAction(")
assert idx >= 0, "_getExplicitBusyCommandAction() not found"
body = UI_JS[idx:UI_JS.find("function getComposerPrimaryAction", idx)]
assert "name==='queue'" in body and "return 'queue'" in body, (
"typing /queue <message> should show the queue/list-end button even in another busy mode"
)
assert "name==='steer'" in body and "return 'steer'" in body, (
"typing /steer <message> should show the steer/compass button even when the global mode is queue"
)
assert "name==='interrupt'" in body and "return 'interrupt'" in body, (
"typing /interrupt <message> should show the interrupt/skip-forward button even in another busy mode"
)
assert "if(!args) return null" in body, (
"partial slash commands without a payload should not override the primary button while the user is still typing"
)
def test_send_button_click_uses_primary_action_handler(self):
assert "function handleComposerPrimaryAction()" in UI_JS, (
"btnSend click should route through a primary action handler so Stop can cancel instead of sending"
)
assert "handleComposerPrimaryAction" in BOOT_JS, (
"boot.js should wire btnSend to handleComposerPrimaryAction(), not directly to send()"
)
class TestSendBusyBranchDispatch:
"""send()'s busy block must read window._busyInputMode and branch accordingly."""
def test_send_reads_busy_input_mode(self):
# The send() function should read window._busyInputMode in the busy block
send_idx = MESSAGES_JS.find("async function send(")
assert send_idx >= 0
# Look in the first ~3000 chars of send() for the busy mode read
send_body = MESSAGES_JS[send_idx:send_idx + 3000]
assert "_busyInputMode" in send_body, (
"send() must read window._busyInputMode in the S.busy branch"
)
def test_send_calls_cancel_stream_on_interrupt(self):
send_idx = MESSAGES_JS.find("async function send(")
send_body = MESSAGES_JS[send_idx:send_idx + 3000]
# The interrupt branch must call cancelStream
assert "cancelStream" in send_body
# And queue before cancel (otherwise the drain has nothing to pick up)
# Verify the order textually: queueSessionMessage appears before cancelStream
# within the busy block's interrupt branch
cancel_idx = send_body.find("cancelStream")
queue_idx = send_body.find("queueSessionMessage")
assert queue_idx >= 0 and cancel_idx >= 0
assert queue_idx < cancel_idx, (
"queueSessionMessage must run before cancelStream so the drain "
"after setBusy(false) picks up the queued message"
)
def test_slash_commands_intercepted_before_busymode_routing(self):
"""The three busy-control slash commands (/steer /interrupt /queue) must be
intercepted at the TOP of the busy block — before the busyMode routing — so
they execute immediately while the agent is running.
Without this intercept, typing /steer while busy queues the text as a plain
message. When it drains after the turn ends there is no active stream, so
cmdSteer says "No active task to stop." and the steer is lost entirely.
"""
send_idx = MESSAGES_JS.find("async function send(")
assert send_idx >= 0, "send() not found"
# Look in the first 500 chars of the busy block for the intercept
busy_start = MESSAGES_JS.find("S.busy||compressionRunning", send_idx)
assert busy_start >= 0, "busy block not found"
# The intercept must appear BEFORE the busyMode assignment
intercept_idx = MESSAGES_JS.find("'steer','interrupt','queue'", busy_start)
busymode_idx = MESSAGES_JS.find("_busyInputMode||'queue'", busy_start)
assert intercept_idx >= 0, (
"send() must intercept /steer /interrupt /queue before the busyMode "
"routing block — otherwise they queue instead of executing immediately"
)
assert intercept_idx < busymode_idx, (
"The slash-command intercept must come BEFORE the busyMode routing "
"so /steer executes while the agent is running, not after the turn ends"
)
def test_steer_intercept_calls_handler_directly(self):
"""The busy-intercept must dispatch via _bc.fn(_pc.args), not queue the text."""
send_idx = MESSAGES_JS.find("async function send(")
busy_start = MESSAGES_JS.find("S.busy||compressionRunning", send_idx)
intercept_idx = MESSAGES_JS.find("'steer','interrupt','queue'", busy_start)
assert intercept_idx >= 0
# Get the intercept block (up to the next busyMode assignment)
busymode_idx = MESSAGES_JS.find("_busyInputMode||'queue'", busy_start)
intercept_block = MESSAGES_JS[intercept_idx:busymode_idx]
assert "_bc.fn(_pc.args)" in intercept_block, (
"The intercept must call the command handler directly via _bc.fn(_pc.args)"
)
assert "return;" in intercept_block, (
"The intercept must return after dispatching so send() does not also queue"
)
def test_steer_intercept_clears_input_before_await(self):
"""The intercept must clear $('msg').value BEFORE awaiting the handler.
Without the sync clear, the input field still shows '/steer foo' after
the steer fires. If the user presses Enter again (a common reflex while
waiting for the toast), send() re-runs and either re-fires the command
or — once the turn ended — drops a confusing 'No active task to stop.'
"""
send_idx = MESSAGES_JS.find("async function send(")
busy_start = MESSAGES_JS.find("S.busy||compressionRunning", send_idx)
intercept_idx = MESSAGES_JS.find("'steer','interrupt','queue'", busy_start)
busymode_idx = MESSAGES_JS.find("_busyInputMode||'queue'", busy_start)
intercept_block = MESSAGES_JS[intercept_idx:busymode_idx]
clear_idx = intercept_block.find("$('msg').value=''")
await_idx = intercept_block.find("await _bc.fn")
assert clear_idx >= 0, (
"The intercept must clear $('msg').value (so the field doesn't keep "
"showing /steer foo after the command fires)"
)
assert await_idx >= 0, "await _bc.fn(...) must be present in the intercept"
assert clear_idx < await_idx, (
"$('msg').value='' must be cleared BEFORE awaiting the handler — "
"otherwise a reflexive Enter press during the await re-fires the command"
)
# ── Boot init + settings panel wiring ───────────────────────────────────
class TestBootAndPanelsWiring:
def test_boot_init_default_path(self):
"""Boot success path initialises window._busyInputMode from settings."""
assert "window._busyInputMode=(s.busy_input_mode||'queue')" in BOOT_JS
def test_boot_init_fallback_path(self):
"""Boot fallback path (settings load failed) initialises to safe default."""
# The fallback should set window._busyInputMode='queue'
assert "window._busyInputMode='queue'" in BOOT_JS
def test_panels_load_save_apply(self):
assert "settingsBusyInputMode" in PANELS_JS, "panels.js must load the setting"
assert "body.busy_input_mode" in PANELS_JS, "saveSettings must include busy_input_mode in body"
assert "window._busyInputMode=body.busy_input_mode" in PANELS_JS, (
"_applySavedSettingsUi must propagate busy_input_mode to the global"
)
def test_index_html_dropdown_has_three_options(self):
idx = INDEX_HTML.find('id="settingsBusyInputMode"')
assert idx >= 0
block = INDEX_HTML[idx:idx + 800]
assert 'value="queue"' in block
assert 'value="interrupt"' in block
assert 'value="steer"' in block
# ── i18n locale coverage ─────────────────────────────────────────────────
class TestI18nKeys:
"""All 17 new keys must appear in each of the 6 locale blocks."""
REQUIRED_KEYS = [
"cmd_queue",
"cmd_interrupt",
"cmd_steer",
"cmd_queue_no_msg",
"cmd_queue_not_busy",
"cmd_queue_confirm",
"cmd_interrupt_no_msg",
"cmd_interrupt_confirm",
"cmd_steer_no_msg",
"cmd_steer_fallback",
"busy_steer_fallback",
"busy_interrupt_confirm",
"settings_label_busy_input_mode",
"settings_desc_busy_input_mode",
"settings_busy_input_mode_queue",
"settings_busy_input_mode_interrupt",
"settings_busy_input_mode_steer",
]
def test_each_key_appears_at_least_six_times(self):
"""Each key should appear once per locale (en, ru, es, de, zh, zh-Hant) = 6 occurrences minimum."""
for key in self.REQUIRED_KEYS:
count = I18N_JS.count(f"{key}:")
assert count >= 6, (
f"i18n key {key!r} appears {count} times; expected ≥6 (one per locale block)"
)
def test_key_count_total(self):
"""17 keys × 6 locales = 102 minimum occurrences across the file."""
total = sum(I18N_JS.count(f"{key}:") for key in self.REQUIRED_KEYS)
assert total >= 17 * 6, (
f"Total i18n occurrences = {total}; expected ≥ {17*6}"
)

View File

@@ -0,0 +1,108 @@
"""
Tests for #745: code blocks losing newlines when not preceded by double blank line.
Root cause: the paragraph-splitter in renderMd() replaced \n with <br> inside
<pre><code> blocks when they were not separated by a double newline from surrounding
text. The fix stashes <pre> blocks (and pre-header divs, mermaid, katex) before
the paragraph split and restores them afterwards.
"""
import re
import subprocess
import sys
import os
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
def get_ui_js():
return open(UI_JS, encoding='utf-8').read()
class TestCodeBlockNewlinePreservation:
def test_pre_stash_present(self):
"""The _pre_stash variable must exist in ui.js."""
src = get_ui_js()
assert '_pre_stash' in src, "_pre_stash not found in ui.js"
def test_pre_stash_token_E_used(self):
"""Stash token \\x00E must be used for pre-block stashing."""
src = get_ui_js()
assert r'\x00E' in src, r"\x00E stash token not found in ui.js"
def test_stash_before_paragraph_split(self):
"""_pre_stash must be populated BEFORE the parts=s.split line."""
src = get_ui_js()
pre_stash_pos = src.index('_pre_stash=[]')
split_pos = src.index('const parts=s.split(/\\n{2,}/)')
assert pre_stash_pos < split_pos, \
"_pre_stash must be initialised before the paragraph split"
def test_restore_after_paragraph_split(self):
"""_pre_stash restore must happen AFTER the paragraph map/join line."""
src = get_ui_js()
restore_pos = src.index('_pre_stash[+i]')
split_pos = src.index("}).join('\\n');", src.index('const parts=s.split'))
assert restore_pos > split_pos, \
"_pre_stash must be restored after the paragraph split/join"
def test_paragraph_split_bypasses_stash_tokens(self):
"""The paragraph map must bypass lines that start with \\x00E (pre stash).
Also accepts a character class like \\x00[EQ] when other stash tokens
share the same bypass (e.g. \\x00Q for blockquote stash)."""
src = get_ui_js()
# The map line must check for \x00E in its bypass condition
map_line = next(
l for l in src.splitlines()
if 'parts.map' in l and '<br>' in l
)
assert r'\x00E' in map_line or r'\x00[E' in map_line, (
r"paragraph map must bypass \x00E stash tokens (literally or as "
r"part of a character class like \x00[EQ])"
)
def test_pre_regex_covers_pre_header_div(self):
"""The stash regex must match <div class=\"pre-header\"> before <pre>."""
src = get_ui_js()
# Find the replacement regex used to populate _pre_stash
stash_block_idx = src.index('_pre_stash=[]')
stash_block = src[stash_block_idx:stash_block_idx + 400]
assert 'pre-header' in stash_block, \
"pre-stash regex must match <div class=\"pre-header\"> wrappers"
def test_mermaid_covered_by_stash(self):
"""The stash regex must also cover mermaid-block divs."""
src = get_ui_js()
stash_block_idx = src.index('_pre_stash=[]')
stash_block = src[stash_block_idx:stash_block_idx + 400]
assert 'mermaid-block' in stash_block, \
"pre-stash regex must cover mermaid-block divs"
def test_katex_covered_by_stash(self):
"""The stash regex must also cover katex-block divs."""
src = get_ui_js()
stash_block_idx = src.index('_pre_stash=[]')
stash_block = src[stash_block_idx:stash_block_idx + 400]
assert 'katex-block' in stash_block, \
"pre-stash regex must cover katex-block divs"
def test_js_syntax_valid(self):
"""ui.js must pass node --check after the fix."""
result = subprocess.run(
['node', '--check', UI_JS],
capture_output=True, text=True
)
assert result.returncode == 0, \
f"node --check failed:\n{result.stderr}"
def test_stash_token_e_not_used_elsewhere(self):
"""\\x00E must only appear in the pre-stash section (not reused)."""
src = get_ui_js()
occurrences = [
i for i in range(len(src))
if src[i:i+4] == r'\x00' and i + 4 < len(src) and src[i+4] == 'E'
]
# Allow 2 occurrences: the push token and the restore regex
# (may be 3 if there's also a comment mentioning it)
assert len(occurrences) >= 2, \
r"Expected at least 2 uses of \x00E (push + restore)"

View File

@@ -0,0 +1,107 @@
"""Tests for inline HTML preview in workspace panel (issue #779)."""
import pytest
def _get_routes_content():
return open("api/routes.py", encoding="utf-8").read()
def _get_workspace_js():
return open("static/workspace.js", encoding="utf-8").read()
def _get_index_html():
return open("static/index.html", encoding="utf-8").read()
def test_inline_preview_param_in_file_raw():
"""?inline=1 must bypass Content-Disposition: attachment for text/html."""
content = _get_routes_content()
assert "inline_preview" in content, (
"_handle_file_raw must read the inline query parameter"
)
assert "html_inline_ok" in content, (
"_handle_file_raw must allow HTML inline when inline_preview=True"
)
def test_iframe_uses_inline_param():
"""workspace.js must pass &inline=1 when setting the preview iframe src."""
content = _get_workspace_js()
assert "inline=1" in content, (
"workspace.js must pass ?inline=1 to api/file/raw for the HTML preview iframe"
)
def test_html_preview_iframe_exists_in_html():
"""The previewHtmlIframe element must be present in index.html."""
content = _get_index_html()
assert "previewHtmlIframe" in content, (
"index.html must contain the previewHtmlIframe element"
)
def test_html_exts_defined_in_workspace_js():
"""HTML_EXTS set must include .html and .htm."""
content = _get_workspace_js()
assert "HTML_EXTS" in content, "workspace.js must define HTML_EXTS"
assert "'.html'" in content or '".html"' in content, "HTML_EXTS must include .html"
assert "'.htm'" in content or '".htm"' in content, "HTML_EXTS must include .htm"
def test_sandbox_allows_scripts_only():
"""iframe sandbox must not include allow-same-origin (XSS risk)."""
content = _get_index_html()
# Find the sandbox attribute value
import re
sandboxes = re.findall(r'sandbox="([^"]*)"', content)
preview_sandboxes = [s for s in sandboxes if "allow" in s]
for sb in preview_sandboxes:
assert "allow-same-origin" not in sb, (
"HTML preview iframe must not have allow-same-origin (would expose parent cookies)"
)
def test_mime_map_includes_html_and_htm():
"""MIME_MAP must map .html/.htm to text/html — without this, _handle_file_raw
falls back to application/octet-stream and browsers refuse to render the
response inside the preview iframe (issue #779 follow-up: PR #1070)."""
from api.config import MIME_MAP
assert MIME_MAP.get(".html") == "text/html", (
"MIME_MAP['.html'] must be 'text/html' for the workspace HTML preview iframe"
)
assert MIME_MAP.get(".htm") == "text/html", (
"MIME_MAP['.htm'] must be 'text/html' for the workspace HTML preview iframe"
)
def test_inline_html_response_sets_csp_sandbox():
"""Defense-in-depth: ?inline=1 HTML responses must set Content-Security-Policy:
sandbox so the same origin isolation applies even when the URL is opened
directly in a top-level tab (not just inside the workspace panel iframe).
Without this, a user tricked into clicking a chat link like
/api/file/raw?path=evil.html&inline=1 would render the HTML in the WebUI's
origin without any sandbox, giving the page full access to cookies and
localStorage. The CSP sandbox directive (no allow-same-origin) downgrades
the document to a unique opaque origin server-side.
"""
content = _get_routes_content()
# Find the html_inline_ok block in _handle_file_raw
idx = content.find("html_inline_ok")
assert idx != -1, "html_inline_ok block not found"
block = content[idx:idx + 2500]
assert "Content-Security-Policy" in block, (
"_handle_file_raw must set Content-Security-Policy header on inline HTML responses"
)
assert "sandbox" in block, (
"CSP must include the sandbox directive"
)
# Must NOT have allow-same-origin in the sandbox directive
csp_sections = [line for line in block.splitlines() if "sandbox" in line and "Policy" in line]
for line in csp_sections:
# The line setting the CSP header — make sure it doesn't grant same-origin
if "send_header" in line:
assert "allow-same-origin" not in line, (
"CSP sandbox must NOT include allow-same-origin — that would defeat the isolation"
)

View File

@@ -0,0 +1,92 @@
"""
Tests for #886: ordered list items always rendered as "1." regardless of position.
Root cause: when LLMs output numbered lists with blank lines between items,
the paragraph-splitter in renderMd() splits the markdown into one chunk per item,
so the ordered-list regex wraps each item in its own <ol>. Each <ol> restarts
at 1, producing "1. 1. 1." instead of "1. 2. 3.".
Fix: emit value="N" on every <li> so the correct ordinal is preserved even when
items end up in separate <ol> containers after the paragraph split.
"""
import os
import re
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
def get_ui_js():
return open(UI_JS, encoding='utf-8').read()
class TestOrderedListNumbering:
def test_li_value_attr_present_in_ordered_list_block(self):
"""The ordered-list renderer must emit value= on each <li>."""
src = get_ui_js()
# Locate the ordered-list replace block
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
# Extract a window large enough to cover the whole closure (~400 chars)
ol_block = src[ol_idx:ol_idx + 500]
assert 'value=' in ol_block, (
"Ordered-list block must emit value= attribute on <li> elements to "
"preserve numbering when items are separated by blank lines (#886)"
)
def test_li_value_uses_parsed_number(self):
"""The value= must be derived from parseInt of the captured digit, not hardcoded."""
src = get_ui_js()
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
ol_block = src[ol_idx:ol_idx + 500]
assert 'parseInt' in ol_block, (
"Ordered-list block should use parseInt() to parse the list number (#886)"
)
def test_numMatch_variable_present(self):
"""The numMatch variable (or equivalent digit capture) must exist in the OL block."""
src = get_ui_js()
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
ol_block = src[ol_idx:ol_idx + 500]
# Either numMatch or a similar digit-capture variable
assert 'numMatch' in ol_block or re.search(r'match\(/.*\\d', ol_block), (
"Ordered-list block should capture the list item number with a regex match (#886)"
)
def test_valAttr_or_value_template_present(self):
"""The <li> template must include the value attribute conditionally or unconditionally."""
src = get_ui_js()
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
ol_block = src[ol_idx:ol_idx + 500]
# Either a valAttr variable or an inline value= in the template
has_val_attr = 'valAttr' in ol_block
has_inline_value = re.search(r'<li.*value=', ol_block)
assert has_val_attr or has_inline_value, (
"Ordered-list block must have value= on <li> (via valAttr var or inline) (#886)"
)
def test_ordered_list_comment_references_issue(self):
"""A comment near the OL fix should reference the issue (#886) or the symptom."""
src = get_ui_js()
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found in ui.js"
# Look at the 300 chars BEFORE the replace line for an explanatory comment
context = src[max(0, ol_idx - 300):ol_idx]
has_comment = '#886' in context or '1. 1. 1.' in context or 'blank lines' in context.lower()
assert has_comment, (
"Expected a comment near the OL fix explaining the blank-line issue (#886)"
)
def test_list_without_blank_lines_unaffected(self):
"""A compact list (no blank lines) should still produce one <ol> with sequential items."""
src = get_ui_js()
# Structural check: the regex still captures multi-line blocks (\\n? allows groups)
ol_idx = src.find('s=s.replace(/((?:^(?: )?\\d+\\. .+\\n?)+)/gm')
assert ol_idx != -1, "Ordered-list replace block not found"
# The \\n? quantifier that allows grouping must still be present
assert '\\n?' in src[ol_idx:ol_idx + 80], (
"The \\\\n? in the ordered-list regex was removed — compact lists may break"
)

View File

@@ -0,0 +1,25 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
def test_app_titlebar_no_longer_contains_tps_chip():
assert 'id="tpsStat"' not in INDEX_HTML
def test_app_titlebar_returns_to_centered_desktop_layout():
assert ".app-titlebar{display:flex;align-items:center;justify-content:center;" in STYLE_CSS
assert ".app-titlebar-inner{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%;justify-content:center;}" in STYLE_CSS
def test_app_titlebar_subtitle_shows_message_count_again():
assert "subText = t('n_messages', vis.length);" in PANELS_JS
def test_queue_updates_do_not_hijack_app_titlebar_subtitle():
assert "_syncQueueTitlebar" not in UI_JS

View File

@@ -0,0 +1,52 @@
"""
Regression test for PR #1071: approval card must render above the queue flyout.
Both `.approval-card` and `.queue-card` are siblings inside `.composer-flyout`
and share the same absolute positioning slot just above the composer. When
both are visible at the same time (queue flyout open + tool approval card
sliding up) the approval card MUST win the stacking order so its security-
relevant Allow / Deny buttons stay clickable.
The old CSS had `.queue-card { z-index: 2 }` and no z-index on
`.approval-card.visible`, so the queue card painted on top and blocked the
approval buttons. The fix raises `.approval-card.visible` to z-index 3.
This test pins the invariant: approval-card.visible z-index must be strictly
greater than queue-card z-index.
"""
import re
from pathlib import Path
CSS = Path("static/style.css").read_text(encoding="utf-8")
def _z_index_of(selector_regex: str) -> int | None:
m = re.search(selector_regex + r"\s*\{[^}]*z-index:(\d+)", CSS)
return int(m.group(1)) if m else None
def test_approval_card_visible_outranks_queue_card():
queue_z = _z_index_of(r"\.queue-card")
approval_visible_z = _z_index_of(r"\.approval-card\.visible")
assert queue_z is not None, ".queue-card must declare a z-index"
assert approval_visible_z is not None, (
".approval-card.visible must declare a z-index — without it, the approval "
"buttons get covered by the queue flyout (PR #1071)"
)
assert approval_visible_z > queue_z, (
f".approval-card.visible z-index ({approval_visible_z}) must be strictly "
f"greater than .queue-card z-index ({queue_z}) so approval buttons "
f"remain clickable when both flyouts are open."
)
def test_approval_card_visible_outranks_terminal_card():
terminal_z = _z_index_of(r"\.composer-terminal-panel")
approval_visible_z = _z_index_of(r"\.approval-card\.visible")
assert terminal_z is not None, ".composer-terminal-panel must declare a z-index"
assert approval_visible_z is not None
assert approval_visible_z > terminal_z, (
f".approval-card.visible z-index ({approval_visible_z}) must stay above "
f".composer-terminal-panel z-index ({terminal_z}) so approval controls "
f"remain clickable when the terminal flyout is open."
)

View File

@@ -0,0 +1,188 @@
"""Tests for approval queue multi-entry support (issue #527).
Previously _pending[sid] held one entry, so simultaneous approvals overwrote
each other. This PR changes submit_pending() to append to a list and adds
approval_id so /api/approval/respond can target a specific entry.
"""
import json
import pathlib
import re
import sys
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(REPO_ROOT))
ROUTES_SRC = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Static-analysis: Python routes
# ---------------------------------------------------------------------------
def test_submit_pending_appends_to_list():
"""submit_pending() must append to a list, not overwrite."""
# The new wrapper must contain queue.append
assert "queue.append(entry)" in ROUTES_SRC, \
"submit_pending() must append entry to a list queue, not overwrite _pending[sid]"
def test_submit_pending_adds_approval_id():
"""Each queued entry must get a unique approval_id."""
assert "approval_id" in ROUTES_SRC and "uuid.uuid4().hex" in ROUTES_SRC, \
"submit_pending() must assign a uuid4 approval_id to each queued entry"
def test_handle_approval_pending_returns_count():
"""_handle_approval_pending must return pending_count in its response."""
assert '"pending_count"' in ROUTES_SRC, \
"_handle_approval_pending must include pending_count in the JSON response"
def test_handle_approval_respond_pops_by_approval_id():
"""_handle_approval_respond must target entry by approval_id."""
assert 'approval_id = body.get("approval_id"' in ROUTES_SRC, \
"_handle_approval_respond must read approval_id from request body"
assert 'entry.get("approval_id") == approval_id' in ROUTES_SRC, \
"_handle_approval_respond must find and pop the matching entry by approval_id"
def test_handle_approval_respond_fallback_to_oldest():
"""When no approval_id is given, fall back to popping the oldest entry (FIFO)."""
# The fallback path: queue.pop(0) when approval_id is empty
assert "queue.pop(0)" in ROUTES_SRC, \
"_handle_approval_respond must fall back to popping the oldest entry when approval_id is absent"
def test_backward_compat_legacy_dict_value():
"""The respond handler must tolerate a legacy single-dict value in _pending."""
assert "Legacy single-dict value" in ROUTES_SRC or \
"# Legacy single-dict" in ROUTES_SRC or \
"elif queue:" in ROUTES_SRC, \
"respond handler must handle legacy single-dict _pending values for backward compatibility"
# ---------------------------------------------------------------------------
# Static-analysis: JavaScript frontend
# ---------------------------------------------------------------------------
def test_respond_sends_approval_id():
"""respondApproval() must include approval_id in the POST body."""
assert "approval_id: approvalId" in MESSAGES_JS, \
"respondApproval() must send approval_id in the POST body to /api/approval/respond"
def test_show_approval_card_accepts_count():
"""showApprovalCard must accept a pendingCount parameter."""
assert re.search(r"function showApprovalCard\(pending,\s*pendingCount\)", MESSAGES_JS), \
"showApprovalCard() must accept a pendingCount argument"
def test_show_approval_card_renders_counter():
"""showApprovalCard must display a '1 of N pending' counter when N > 1."""
assert '"1 of " + pendingCount + " pending"' in MESSAGES_JS or \
"'1 of ' + pendingCount + ' pending'" in MESSAGES_JS, \
"showApprovalCard() must render '1 of N pending' counter for multiple queued approvals"
def test_approval_current_id_tracked():
"""_approvalCurrentId must be set and cleared around each approval."""
assert "_approvalCurrentId" in MESSAGES_JS, \
"_approvalCurrentId must track the approval_id of the currently displayed card"
assert "_approvalCurrentId = pending.approval_id" in MESSAGES_JS or \
"_approvalCurrentId = pending.approval_id || null" in MESSAGES_JS, \
"_approvalCurrentId must be assigned from pending.approval_id"
# Must be nulled on respond
assert "_approvalCurrentId = null" in MESSAGES_JS, \
"_approvalCurrentId must be cleared when respondApproval() is called"
def test_polling_passes_count_to_show():
"""The poll loop must pass pending_count to showApprovalCard."""
assert "showApprovalCard(data.pending, data.pending_count" in MESSAGES_JS, \
"Poll loop must pass data.pending_count to showApprovalCard"
# ---------------------------------------------------------------------------
# HTML: counter element present
# ---------------------------------------------------------------------------
def test_approval_counter_element_exists():
"""index.html must contain an approvalCounter element."""
assert 'id="approvalCounter"' in INDEX_HTML, \
"index.html must contain an element with id='approvalCounter' for the '1 of N' display"
# ---------------------------------------------------------------------------
# Functional: multiple entries behave correctly (via routes module directly)
# ---------------------------------------------------------------------------
def test_multiple_approvals_both_surfaced():
"""Two submit_pending calls must produce two queued entries, not one."""
import threading
from api import routes as r
# Reset state
sid = "test-multi-approval-sid"
with r._lock:
r._pending.pop(sid, None)
r.submit_pending(sid, {"command": "cmd1", "pattern_key": "p1", "pattern_keys": ["p1"], "description": "d1"})
r.submit_pending(sid, {"command": "cmd2", "pattern_key": "p2", "pattern_keys": ["p2"], "description": "d2"})
with r._lock:
queue = r._pending.get(sid)
assert isinstance(queue, list), "After two submit_pending calls, _pending[sid] must be a list"
assert len(queue) == 2, f"Expected 2 queued entries, got {len(queue)}"
assert queue[0]["command"] == "cmd1"
assert queue[1]["command"] == "cmd2"
assert queue[0].get("approval_id"), "First entry must have an approval_id"
assert queue[1].get("approval_id"), "Second entry must have an approval_id"
assert queue[0]["approval_id"] != queue[1]["approval_id"], "Each entry must have a unique approval_id"
# Cleanup
with r._lock:
r._pending.pop(sid, None)
def test_respond_by_approval_id_pops_correct_entry():
"""Responding with approval_id must remove only the targeted entry."""
from api import routes as r
sid = "test-respond-by-id-sid"
with r._lock:
r._pending.pop(sid, None)
r.submit_pending(sid, {"command": "cmd1", "pattern_key": "p1", "pattern_keys": ["p1"], "description": "d1"})
r.submit_pending(sid, {"command": "cmd2", "pattern_key": "p2", "pattern_keys": ["p2"], "description": "d2"})
with r._lock:
queue = r._pending.get(sid, [])
aid2 = queue[1]["approval_id"] if len(queue) > 1 else None
assert aid2, "Second entry must have an approval_id"
# Respond to the SECOND entry by its approval_id
# We call the handler internals directly (no HTTP)
with r._lock:
queue = r._pending.get(sid, [])
popped = None
for i, entry in enumerate(queue):
if entry.get("approval_id") == aid2:
popped = queue.pop(i)
break
assert popped is not None, "Should have found and popped entry by approval_id"
assert popped["command"] == "cmd2", "Popped the wrong entry"
with r._lock:
remaining = r._pending.get(sid, [])
assert len(remaining) == 1, "One entry should remain after popping the second"
assert remaining[0]["command"] == "cmd1", "The remaining entry should be cmd1"
# Cleanup
with r._lock:
r._pending.pop(sid, None)

View File

@@ -0,0 +1,288 @@
"""
Tests for fix/approval-stuck-thinking:
Verify that /api/approval/respond correctly unblocks gateway approval queues
and that the approval module exports the symbols streaming.py and routes.py
need to prevent the UI getting stuck in "Thinking…" during dangerous commands.
"""
import json
import threading
import uuid
import urllib.request
import urllib.error
import urllib.parse
import pytest
# Import approval internals — shared module-level state within this process.
# The HTTP tests use the test server (port 8788, separate process).
# The unit tests operate directly on the module.
try:
from tools.approval import (
register_gateway_notify,
unregister_gateway_notify,
resolve_gateway_approval,
_gateway_queues,
_gateway_notify_cbs,
_lock,
_ApprovalEntry,
submit_pending,
)
# has_pending and pop_pending were removed from tools.approval when the
# agent renamed has_pending -> has_blocking_approval (gateway queue check)
# and removed the polling-mode pop_pending. Routes now check _pending
# directly. These symbols are no longer part of the public API.
APPROVAL_AVAILABLE = True
except ImportError:
APPROVAL_AVAILABLE = False
pytestmark = pytest.mark.skipif(
not APPROVAL_AVAILABLE,
reason="tools.approval not available in this environment"
)
from tests._pytest_port import BASE
def get(path):
url = BASE + path
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
def post(path, body=None):
url = BASE + path
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
# ── Unit tests (in-process, no HTTP server needed) ──────────────────────────
class TestGatewayApprovalUnblocking:
"""Unit tests for the gateway queue unblocking mechanism."""
def test_resolve_gateway_approval_sets_event(self):
"""resolve_gateway_approval() must set the entry's event and store the result."""
sid = f"unit-resolve-{uuid.uuid4().hex[:8]}"
data = {"command": "rm -rf /tmp/x", "description": "recursive delete"}
entry = _ApprovalEntry(data)
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
resolved = resolve_gateway_approval(sid, "once", resolve_all=False)
assert resolved == 1
assert entry.event.is_set()
assert entry.result == "once"
# Queue should be cleaned up
with _lock:
assert sid not in _gateway_queues
def test_resolve_gateway_approval_deny(self):
"""Deny choice is propagated correctly."""
sid = f"unit-deny-{uuid.uuid4().hex[:8]}"
entry = _ApprovalEntry({"command": "pkill -9 x", "description": "force kill"})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
resolve_gateway_approval(sid, "deny")
assert entry.result == "deny"
def test_resolve_gateway_approval_no_queue_is_harmless(self):
"""resolve_gateway_approval with no queue entry returns 0, no crash."""
sid = f"unit-no-queue-{uuid.uuid4().hex[:8]}"
result = resolve_gateway_approval(sid, "once")
assert result == 0
def test_resolve_all_unblocks_multiple_entries(self):
"""resolve_all=True unblocks every pending entry in the queue."""
sid = f"unit-resolve-all-{uuid.uuid4().hex[:8]}"
entries = [_ApprovalEntry({"command": f"cmd{i}"}) for i in range(3)]
with _lock:
_gateway_queues[sid] = list(entries)
resolved = resolve_gateway_approval(sid, "session", resolve_all=True)
assert resolved == 3
for e in entries:
assert e.event.is_set()
assert e.result == "session"
def test_register_and_fire_notify_cb(self):
"""register_gateway_notify stores the cb; calling it delivers approval data."""
sid = f"unit-notify-{uuid.uuid4().hex[:8]}"
fired = []
register_gateway_notify(sid, lambda d: fired.append(d))
with _lock:
cb = _gateway_notify_cbs.get(sid)
assert cb is not None
data = {"command": "test", "description": "test"}
cb(data)
assert fired == [data]
unregister_gateway_notify(sid)
def test_unregister_clears_cb_and_signals_entries(self):
"""unregister_gateway_notify removes cb and unblocks any queued entries."""
sid = f"unit-unreg-{uuid.uuid4().hex[:8]}"
register_gateway_notify(sid, lambda d: None)
entry = _ApprovalEntry({"command": "x"})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
unregister_gateway_notify(sid)
assert entry.event.is_set(), "unregister should signal blocked entries"
with _lock:
assert sid not in _gateway_notify_cbs
assert sid not in _gateway_queues
def test_streaming_approval_integration(self):
"""
End-to-end unit simulation of the streaming.py fix:
1. streaming.py registers notify_cb
2. check_all_command_guards fires notify_cb (pushing approval SSE)
3. User responds — resolve_gateway_approval unblocks agent thread
4. Agent thread sees choice and continues
"""
sid = f"unit-e2e-{uuid.uuid4().hex[:8]}"
approval_events_sent = []
# Step 1: streaming.py registers the notify callback
def _approval_notify_cb(approval_data):
approval_events_sent.append(approval_data) # would be put('approval', ...)
register_gateway_notify(sid, _approval_notify_cb)
# Step 2: check_all_command_guards fires the callback and queues an entry
approval_data = {
"command": "rm -rf /tmp/test",
"pattern_key": "recursive delete",
"pattern_keys": ["recursive delete"],
"description": "recursive delete",
}
entry = _ApprovalEntry(approval_data)
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
# notify_cb fires synchronously (gateway notifies user)
with _lock:
cb = _gateway_notify_cbs.get(sid)
cb(approval_data)
assert len(approval_events_sent) == 1, "approval SSE event should have been queued"
# Step 3: user responds via /api/approval/respond → resolve_gateway_approval
resolved = resolve_gateway_approval(sid, "once")
assert resolved == 1
# Step 4: agent thread is unblocked with the correct choice
assert entry.event.is_set()
assert entry.result == "once"
# Cleanup
unregister_gateway_notify(sid)
# ── Symbol existence tests ───────────────────────────────────────────────────
class TestApprovalModuleExports:
"""Verify the module exports all symbols that streaming.py and routes.py need."""
def test_register_gateway_notify_exported(self):
import tools.approval as ap
assert hasattr(ap, "register_gateway_notify"), \
"tools.approval must export register_gateway_notify"
def test_unregister_gateway_notify_exported(self):
import tools.approval as ap
assert hasattr(ap, "unregister_gateway_notify"), \
"tools.approval must export unregister_gateway_notify"
def test_resolve_gateway_approval_exported(self):
import tools.approval as ap
assert hasattr(ap, "resolve_gateway_approval"), \
"tools.approval must export resolve_gateway_approval"
def test_approval_entry_exported(self):
import tools.approval as ap
assert hasattr(ap, "_ApprovalEntry"), \
"tools.approval must export _ApprovalEntry"
# ── HTTP regression tests (test server, port 8788) ───────────────────────────
class TestApprovalHTTPEndpoints:
"""
Regression tests for /api/approval/respond against the live test server.
These verify that the HTTP layer behaves correctly — they don't rely on
in-process module state shared with the server subprocess.
"""
def test_respond_returns_ok_no_pending(self):
"""respond with no pending entry returns ok (no crash, no 500)."""
sid = f"http-no-pending-{uuid.uuid4().hex[:8]}"
result, status = post("/api/approval/respond", {
"session_id": sid,
"choice": "deny",
})
assert status == 200
assert result["ok"] is True
def test_respond_clears_injected_pending(self):
"""Inject a pending entry, respond, verify it's cleared."""
sid = f"http-clear-{uuid.uuid4().hex[:8]}"
cmd = "rm -rf /tmp/testdir"
inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(sid)}"
f"&pattern_key=recursive+delete&command={urllib.parse.quote(cmd)}")
assert inject["ok"] is True
data = get(f"/api/approval/pending?session_id={urllib.parse.quote(sid)}")
assert data["pending"] is not None
result, status = post("/api/approval/respond", {
"session_id": sid,
"choice": "deny",
})
assert status == 200
assert result["ok"] is True
data2 = get(f"/api/approval/pending?session_id={urllib.parse.quote(sid)}")
assert data2["pending"] is None, "pending should be cleared after respond"
def test_respond_rejects_invalid_choice(self):
"""respond with an unknown choice returns 400."""
result, status = post("/api/approval/respond", {
"session_id": "some-session",
"choice": "INVALID",
})
assert status == 400
def test_respond_requires_session_id(self):
"""respond without session_id returns 400."""
result, status = post("/api/approval/respond", {"choice": "deny"})
assert status == 400
def test_respond_session_choice_clears_pending(self):
"""Inject pending, respond with 'session', verify cleared."""
sid = f"http-session-{uuid.uuid4().hex[:8]}"
inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(sid)}"
f"&pattern_key=force+kill+processes&command=pkill+-9+something")
assert inject["ok"] is True
result, status = post("/api/approval/respond", {
"session_id": sid,
"choice": "session",
})
assert status == 200
assert result["choice"] == "session"
data = get(f"/api/approval/pending?session_id={urllib.parse.quote(sid)}")
assert data["pending"] is None

View File

@@ -0,0 +1,107 @@
"""Regression tests: auth sessions persist across process restarts.
_sessions is an in-memory dict. Without persistence, any restart (launchd,
systemd, container) invalidates all active browser sessions and floods clients
with 401s until they clear cookies. The HMAC signing key already persists to
STATE_DIR; this PR persists the session table using the same pattern.
"""
import importlib
import json
import os
import sys
import tempfile
import time
import unittest
from pathlib import Path
# Isolate state dir so tests never touch real sessions
_TEST_STATE = Path(tempfile.mkdtemp())
os.environ["HERMES_WEBUI_STATE_DIR"] = str(_TEST_STATE)
sys.path.insert(0, str(Path(__file__).parent.parent))
import api.auth as auth
class TestSessionPersistence(unittest.TestCase):
"""Sessions survive a simulated process restart (module reload)."""
def setUp(self) -> None:
auth._sessions.clear()
sessions_file = _TEST_STATE / '.sessions.json'
if sessions_file.exists():
sessions_file.unlink()
def _simulate_restart(self) -> None:
"""Reload auth module to simulate a fresh process start.
api.auth does `from api.config import STATE_DIR` at module level, so
`_SESSIONS_FILE` is computed from api.config.STATE_DIR at reload time.
We temporarily override api.config.STATE_DIR so the reload uses the
test state dir without reloading api.config itself (which would
invalidate imported references like STREAM_PARTIAL_TEXT in other tests).
"""
import api.config as _config
_saved = _config.STATE_DIR
_config.STATE_DIR = _TEST_STATE
try:
importlib.reload(auth)
finally:
_config.STATE_DIR = _saved
def test_session_survives_restart(self) -> None:
"""A session created before restart should still verify after reload."""
cookie = auth.create_session()
self.assertTrue(auth.verify_session(cookie))
self._simulate_restart()
self.assertTrue(auth.verify_session(cookie),
"Session must survive process restart via persisted .sessions.json")
def test_invalidated_session_does_not_survive_restart(self) -> None:
"""Invalidating a session must be reflected after reload."""
cookie = auth.create_session()
auth.invalidate_session(cookie)
self._simulate_restart()
self.assertFalse(auth.verify_session(cookie),
"Invalidated session must not be reinstated after restart")
def test_expired_sessions_pruned_on_load(self) -> None:
"""Sessions that expire between restarts must not be loaded."""
sessions_file = _TEST_STATE / '.sessions.json'
# Write a sessions file with one expired and one valid entry
now = time.time()
sessions_file.write_text(json.dumps({
"expired_token": now - 10,
"valid_token": now + 3600,
}))
self._simulate_restart()
self.assertNotIn("expired_token", auth._sessions)
self.assertIn("valid_token", auth._sessions)
def test_sessions_file_permissions(self) -> None:
"""Sessions file must be owner-read-only (0600)."""
auth.create_session()
sessions_file = _TEST_STATE / '.sessions.json'
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
mode = oct(sessions_file.stat().st_mode & 0o777)
self.assertEqual(mode, oct(0o600),
f".sessions.json permissions {mode} — expected 0o600")
def test_malformed_sessions_file_starts_fresh(self) -> None:
"""A corrupt sessions file must not crash auth — start with empty dict."""
sessions_file = _TEST_STATE / '.sessions.json'
sessions_file.write_text("not valid json {{{{")
self._simulate_restart()
self.assertEqual(auth._sessions, {},
"Corrupt sessions file must result in empty session dict")
def test_sessions_file_wrong_type_starts_fresh(self) -> None:
"""A sessions file containing a non-dict must be ignored."""
sessions_file = _TEST_STATE / '.sessions.json'
sessions_file.write_text(json.dumps(["list", "not", "dict"]))
self._simulate_restart()
self.assertEqual(auth._sessions, {})
if __name__ == "__main__":
unittest.main()

134
tests/test_auth_sessions.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Tests for auth session lifecycle — session creation, verification, expiry,
and lazy pruning of expired entries.
"""
import time
import unittest
from pathlib import Path
import tempfile
import os
# Isolate state dir so we don't touch real sessions
_TEST_STATE = Path(tempfile.mkdtemp())
os.environ["HERMES_WEBUI_STATE_DIR"] = str(_TEST_STATE)
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
import importlib
# Force re-import of auth module so it picks up our TEST_STATE_DIR
auth = importlib.import_module("api.auth")
class TestSessionPruning(unittest.TestCase):
"""Verify expired session cleanup works correctly."""
def setUp(self):
# Clear any leftover sessions from other tests
auth._sessions.clear()
def test_session_created_valid(self):
"""A fresh session token should verify as valid."""
token = auth.create_session()
self.assertTrue(auth.verify_session(token))
def test_expired_session_pruned(self):
"""Manually inserting an expired entry should be pruned on next verify_session call."""
# Insert sessions that have already expired
auth._sessions["fake_token"] = time.time() - 100
auth._sessions["another_fake"] = time.time() - 50
# Insert one valid session (far future)
auth._sessions["good_token"] = time.time() + 3600
# _sessions has 3 entries, 2 expired
self.assertEqual(len(auth._sessions), 3)
# Call verify_session — this triggers _prune_expired_sessions()
# Cookie format is token.signature, so we need a dot to pass the early check
auth.verify_session("fake_token.fake_sig")
# After verification, only the valid session should remain
self.assertEqual(len(auth._sessions), 1)
self.assertIn("good_token", auth._sessions)
self.assertNotIn("fake_token", auth._sessions)
self.assertNotIn("another_fake", auth._sessions)
def test_prune_does_not_remove_valid_sessions(self):
"""_prune_expired_sessions should never remove sessions that are still active."""
auth._sessions["active_1"] = time.time() + 86400 # 24 hours from now
auth._sessions["active_2"] = time.time() + 7200 # 2 hours from now
auth._sessions["expired_1"] = time.time() - 10
auth._prune_expired_sessions()
self.assertEqual(len(auth._sessions), 2)
self.assertIn("active_1", auth._sessions)
self.assertIn("active_2", auth._sessions)
self.assertNotIn("expired_1", auth._sessions)
def test_verify_session_prunes_before_verification(self):
"""verify_session should prune expired entries before checking the target token.
This ensures that _prune_expired_sessions() is called at the very top
of verify_session(), so cleanup happens on every auth check.
"""
auth._sessions["expired_for_test"] = time.time() - 999
# verify_session with an invalid cookie triggers the full path:
# _prune_expired_sessions -> signature check -> return False
result = auth.verify_session("nonexistent.bad_sig")
self.assertFalse(result)
# The expired entry should have been cleaned up
self.assertNotIn("expired_for_test", auth._sessions)
def test_prune_handles_empty_dict(self):
"""_prune_expired_sessions should be safe on an empty dict."""
auth._sessions.clear()
auth._prune_expired_sessions()
self.assertEqual(len(auth._sessions), 0)
def test_session_ttl_is_24_hours(self):
"""Newly created sessions should have the expected 24-hour TTL."""
auth._sessions.clear()
token_hex = auth.create_session().split(".")[0]
# The _sessions dict stores token -> expiry_time
# We can check the expiry is approximately SESSION_TTL seconds from now
# by looking up the raw entry via the token
from api.auth import _sessions, SESSION_TTL
# find our entry
for t, exp in _sessions.items():
if t == token_hex:
# expiry should be within 5 seconds of now + SESSION_TTL
expected = time.time() + SESSION_TTL
self.assertAlmostEqual(exp, expected, delta=5)
break
else:
self.fail("Session token not found in _sessions")
class TestSessionInvalidation(unittest.TestCase):
"""Test session logout / invalidation."""
def setUp(self):
auth._sessions.clear()
def test_invalidate_session_removes_token(self):
"""Calling invalidate_session should remove the token from _sessions."""
token = auth.create_session()
self.assertTrue(auth.verify_session(token))
auth.invalidate_session(token)
# Token should be gone
self.assertFalse(auth.verify_session(token))
def test_invalidate_unknown_token_is_safe(self):
"""Invalidating a non-existent token should not raise."""
auth._sessions.clear()
auth.invalidate_session("nonexistent_token")
# Should not raise
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,168 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _read(relpath: str) -> str:
return (ROOT / relpath).read_text(encoding="utf-8")
def _compressed_listener_block() -> str:
src = _read("static/messages.js")
start = src.find("source.addEventListener('compressed'")
assert start != -1, "compressed SSE listener not found"
end = src.find("source.addEventListener('metering'", start)
assert end != -1, "metering listener after compressed SSE listener not found"
return src[start:end]
def test_auto_compression_sse_uses_transient_card_not_fake_message():
"""Auto compression must not inject display-only text into S.messages."""
src = _read("static/messages.js")
block = _compressed_listener_block()
assert "*[Context was auto-compressed to continue the conversation]*" not in src
assert "S.messages.push" not in block
assert "setCompressionUi" in block
assert "phase:'done'" in block
assert "automatic:true" in block
assert "_setCompressionSessionLock" in block
def test_auto_compression_sse_keeps_inactive_and_malformed_paths_safe():
block = _compressed_listener_block()
guard = "if(!S.session||S.session.session_id!==activeSid) return;"
assert guard in block
assert block.index(guard) < block.index("setCompressionUi")
assert "try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }" in block
def test_auto_compression_card_reuses_compression_card_renderer():
src = _read("static/ui.js")
start = src.find("function _autoCompressionCardsHtml")
assert start != -1, "auto compression card helper not found"
end = src.find("function _compressionCardsNode", start)
assert end != -1, "compression cards node helper not found after auto helper"
helper = src[start:end]
assert "if(state.automatic) return _autoCompressionCardsHtml(state);" in src
assert "tool-card-row compression-card-row" in helper
assert "tool-card-compress-complete tool-card-compress-auto" in helper
assert "auto_compress_label" in helper
def test_auto_compression_card_survives_compression_session_rotation():
src = _read("static/messages.js")
assert "window._compressionUi.sessionId===activeSid" in src
assert "sessionId:d.session.session_id" in src
def test_preserved_task_list_marker_is_detected_case_insensitively():
src = _read("static/ui.js")
marker = "[your active task list was preserved across context compression]"
start = src.find("function _isPreservedCompressionTaskListMessage")
assert start != -1, "preserved task list detector not found"
end = src.find("function _preservedCompressionTaskListPreview", start)
assert end != -1, "preserved task list preview helper not found after detector"
detector = src[start:end]
assert "m.role!=='user'" in detector
assert marker.strip("[]") in detector.lower()
assert ".test(text)" in detector
assert "/i.test" in detector
def test_context_compaction_marker_is_detected_across_roles():
src = _read("static/ui.js")
start = src.find("function _isContextCompactionMessage")
assert start != -1, "context compaction detector not found"
end = src.find("function _isPreservedCompressionTaskListMessage", start)
assert end != -1, "preserved task list detector not found after context detector"
detector = src[start:end]
assert "m.role==='tool'" in detector
assert "m.role!=='assistant'" not in detector
assert "[context compaction" in detector.lower()
assert "context compaction" in detector.lower()
def test_context_compaction_branch_precedes_user_bubble_branch():
src = _read("static/ui.js")
loop_start = src.find("for(let vi=0;vi<visWithIdx.length;vi++)")
assert loop_start != -1, "message render loop not found"
loop_end = src.find("if(!currentAssistantTurn)", loop_start)
assert loop_end != -1, "assistant render branch not found after context branch"
render_prefix = src[loop_start:loop_end]
context_idx = render_prefix.find("if(_isContextCompactionMessage(m))")
user_idx = render_prefix.find("if(isUser)")
assert context_idx != -1, "context compaction render branch not found"
assert user_idx != -1, "normal user bubble render branch not found"
assert context_idx < user_idx
assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" in render_prefix
def test_preserved_task_list_skips_normal_visible_message_path():
src = _read("static/ui.js")
visible_filter_start = src.find("const vis=S.messages.filter")
assert visible_filter_start != -1, "visible message filter not found"
visible_filter_end = src.find("$('emptyState')", visible_filter_start)
assert visible_filter_end != -1, "empty state update after visible filter not found"
visible_filter = src[visible_filter_start:visible_filter_end]
assert "if(_isContextCompactionMessage(m)) return false;" in visible_filter
assert "if(_isPreservedCompressionTaskListMessage(m)) return false;" in visible_filter
vis_idx_start = src.find("for(const m of S.messages)", visible_filter_end)
assert vis_idx_start != -1, "raw message index loop not found"
vis_idx_end = src.find("let lastUserRawIdx", vis_idx_start)
assert vis_idx_end != -1, "last user index lookup after raw message loop not found"
vis_idx_loop = src[vis_idx_start:vis_idx_end]
assert "if(_isPreservedCompressionTaskListMessage(m))" in vis_idx_loop
assert "preservedCompressionRawIdxs.push(rawIdx)" in vis_idx_loop
assert "continue;" in vis_idx_loop
def test_preserved_task_list_renders_through_compression_card_path():
src = _read("static/ui.js")
start = src.find("function _preservedCompressionTaskListCardHtml")
assert start != -1, "preserved task list card helper not found"
end = src.find("function _preservedCompressionTaskListCardsHtml", start)
assert end != -1, "preserved task list card list helper not found"
helper = src[start:end]
assert "_compressionStatusCardHtml" in helper
assert "preserved_task_list_label" in helper
assert "tool-card-compress-reference" in helper
assert "data-compression-card=\"1\"" in helper
assert "li('list-todo',13)" in helper
assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" in src
def test_preserved_task_list_attaches_once_per_render():
src = _read("static/ui.js")
assert "function _latestPreservedCompressionTaskListMessages" in src
assert ".reverse().find(m=>_isPreservedCompressionTaskListMessage(m))" in src
assert "const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages);" in src
assert "S.messages.filter(m=>_isPreservedCompressionTaskListMessage(m))" not in src
assert "let preservedCompressionTaskCardsAttached=!!referenceNode;" in src
assert "const preservedForThisCard=preservedCompressionTaskCardsAttached?[]:preservedCompressionTaskMessages;" in src
assert "if(preservedForThisCard.length) preservedCompressionTaskCardsAttached=true;" in src
assert "(!preservedCompressionTaskCardsAttached&&(!referenceMessage||compressionState)&&preservedCompressionTaskMessages.length)" in src
def test_preserved_task_list_rendering_does_not_mutate_history():
src = _read("static/ui.js")
start = src.find("function _isPreservedCompressionTaskListMessage")
assert start != -1, "preserved task list detector not found"
end = src.find("function _isSameLocalDay", start)
assert end != -1, "end of preserved task list render helpers not found"
preserved_helpers = src[start:end]
assert "S.messages" not in preserved_helpers
assert ".splice(" not in preserved_helpers
assert "delete " not in preserved_helpers

View File

@@ -0,0 +1,148 @@
"""Regression tests for the /background task tracker.
Covers two bugs caught in review of PR #932:
1. `get_results()` was calling `_BACKGROUND_TASKS.pop(parent_sid, [])`, which
removed EVERY task (including still-running ones) on the first poll. Once
popped, `complete_background()` could no longer find the task to mark done,
so the final answer was silently lost.
2. The `_handle_background` worker thread called `_run_agent_streaming` but
never invoked `complete_background()` after it returned. With no completion
hook, every background task stayed in `status="running"` forever —
`get_results()` filtered them out of its "done" list, and the user never
saw the result.
These two bugs together made the `/background` command completely
non-functional as originally shipped. The fix in api/background.py +
api/routes.py wires the completion hook and keeps running tasks in the
tracker until they resolve.
"""
from __future__ import annotations
import os
import pathlib
import sys
import time
import unittest
from unittest.mock import patch
# Ensure the repo root is importable without relying on CWD.
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
class TestGetResultsKeepsRunningTasks(unittest.TestCase):
"""get_results() MUST NOT drop still-running tasks from _BACKGROUND_TASKS."""
def setUp(self):
import api.background as bg
bg._BACKGROUND_TASKS.clear()
self.bg = bg
def test_running_tasks_survive_get_results_call(self):
"""A running task must remain in the tracker so complete_background()
can still find it after the first poll returns."""
parent = "parent-session-1"
self.bg.track_background(
parent_sid=parent, bg_sid="bg-a", stream_id="s-a",
task_id="task-a", prompt="long task",
)
# First poll: task is still running, no done results to return
results = self.bg.get_results(parent)
self.assertEqual(results, [], "no done tasks yet — nothing to return")
# The running task MUST still be tracked — otherwise the worker
# thread's complete_background call cannot find it.
remaining = self.bg.get_background_tasks(parent)
self.assertEqual(len(remaining), 1, (
"get_results dropped the still-running task — subsequent "
"complete_background() calls will silently no-op and the "
"result will be lost forever"
))
self.assertEqual(remaining[0]["status"], "running")
self.assertEqual(remaining[0]["task_id"], "task-a")
def test_done_tasks_are_returned_and_removed(self):
"""Done tasks are returned and popped; running tasks stay."""
parent = "parent-session-2"
self.bg.track_background(parent, "bg-done", "s-d", "task-done", "p1")
self.bg.track_background(parent, "bg-run", "s-r", "task-run", "p2")
self.bg.complete_background(parent, "task-done", "42")
results = self.bg.get_results(parent)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["task_id"], "task-done")
self.assertEqual(results[0]["answer"], "42")
# Done one is gone; running one is still tracked
remaining = self.bg.get_background_tasks(parent)
self.assertEqual(len(remaining), 1)
self.assertEqual(remaining[0]["task_id"], "task-run")
self.assertEqual(remaining[0]["status"], "running")
def test_complete_after_poll_still_reaches_tracker(self):
"""Regression for the original bug: poll → complete → poll must surface
the result. Before the fix, the first poll popped the running task and
complete_background()'s loop iterated over an empty list."""
parent = "parent-session-3"
self.bg.track_background(parent, "bg-x", "s-x", "task-x", "slow task")
# Frontend polls before the task finishes
first = self.bg.get_results(parent)
self.assertEqual(first, [])
# Worker thread finishes and calls complete_background
self.bg.complete_background(parent, "task-x", "answer!")
# Next poll must surface the answer
second = self.bg.get_results(parent)
self.assertEqual(len(second), 1)
self.assertEqual(second[0]["task_id"], "task-x")
self.assertEqual(second[0]["answer"], "answer!")
def test_empty_parent_is_cleaned_up(self):
"""When all tasks are done and returned, the parent key is removed from the dict."""
parent = "parent-session-4"
self.bg.track_background(parent, "bg-1", "s-1", "task-1", "p")
self.bg.complete_background(parent, "task-1", "ok")
self.bg.get_results(parent)
self.assertNotIn(parent, self.bg._BACKGROUND_TASKS)
class TestBackgroundCompletionHookWiring(unittest.TestCase):
"""Static check: the _handle_background worker thread must call
complete_background() after _run_agent_streaming returns. Without this,
running tasks stay forever-running and the user never sees the result.
"""
def test_run_bg_and_notify_calls_complete_background(self):
"""_handle_background must wrap _run_agent_streaming in a function
that subsequently invokes complete_background(parent_sid, task_id, answer)."""
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
# Locate the _handle_background function
idx = routes_src.find("def _handle_background(")
self.assertGreater(idx, -1, "_handle_background() not found in routes.py")
# Take a generous window around the function body
end = routes_src.find("\ndef ", idx + 1)
body = routes_src[idx:end if end > 0 else idx + 3000]
self.assertIn("complete_background", body, (
"_handle_background worker must call complete_background() after "
"_run_agent_streaming returns — otherwise the tracker never "
"transitions the task to status='done' and /api/background/status "
"returns nothing forever. See api/background.py:complete_background."
))
# Must extract the last assistant message content from the bg session
self.assertIn("_run_agent_streaming", body)
self.assertIn("Session.load", body, (
"_run_bg_and_notify must reload the bg session to extract the "
"final assistant reply so complete_background gets an actual answer"
))
if __name__ == "__main__":
unittest.main()

241
tests/test_batch_fixes.py Normal file
View File

@@ -0,0 +1,241 @@
"""Tests for the batch of fixes from PRs #506-#521 (v0.50.47).
Covers:
- /root workspace unblocking (#510/#521)
- Attached-files split guard (#521)
- custom_providers model visibility (#515/#519)
- Cron skill cache invalidation (#507/#508)
- System (auto) theme (#504/#506/#509/#514)
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(rel):
return (REPO / rel).read_text()
# ── Group A: /root workspace ──────────────────────────────────────────────────
class TestRootWorkspaceUnblocked:
def test_root_not_in_blocked_system_roots(self):
src = read("api/workspace.py")
assert "Path('/root')" not in src, (
"/root must not be in _BLOCKED_SYSTEM_ROOTS — "
"breaks deployments where Hermes runs as root"
)
def test_etc_still_blocked(self):
"""Sanity: other dangerous paths remain blocked.
After the macOS symlink fix, blocked roots are listed as bare strings
in a tuple and ``_workspace_blocked_roots()`` materialises both the
literal and resolved-canonical Path forms. Assert the source still
names ``/etc`` and ``/proc`` as blocked roots.
"""
src = read("api/workspace.py")
assert "'/etc'" in src or 'Path("/etc")' in src or "Path('/etc')" in src
assert "'/proc'" in src or 'Path("/proc")' in src or "Path('/proc')" in src
def test_split_guard_present(self):
src = read("api/streaming.py")
assert "'\\n\\n[Attached files:' in msg_text" in src, (
"base_text split must guard against missing '[Attached files:' "
"to avoid empty-string on plain messages"
)
# ── Group B: custom_providers visibility ─────────────────────────────────────
class TestCustomProvidersVisibility:
def test_has_custom_providers_variable_present(self):
src = read("api/config.py")
assert "_has_custom_providers" in src, (
"_has_custom_providers variable must exist in get_available_models()"
)
def test_discard_custom_conditional_on_no_custom_providers(self):
src = read("api/config.py")
assert "not _has_custom_providers" in src, (
"detected_providers.discard('custom') must be gated on "
"'not _has_custom_providers'"
)
def test_custom_providers_isinstance_check(self):
src = read("api/config.py")
assert "isinstance(_custom_providers_cfg, list)" in src, (
"_has_custom_providers must check isinstance(..., list)"
)
# ── Group C: cron skill cache ─────────────────────────────────────────────────
class TestCronSkillCacheInvalidation:
def _panels_src(self):
return read("static/panels.js")
def test_cache_busted_on_form_open(self):
src = self._panels_src()
# toggleCronForm should set cache to null unconditionally
# openCronCreate() opens the task create form (renamed from toggleCronForm
# in the main-view refactor). It must null the skills cache before fetching.
m = re.search(
r'function openCronCreate\(\)\{.*?_cronSkillsCache\s*=\s*null',
src, re.DOTALL
)
assert m, (
"openCronCreate must unconditionally null _cronSkillsCache "
"before fetching skills"
)
def test_cache_not_guarded_by_if_on_open(self):
src = self._panels_src()
# openCronCreate must not gate the fetch behind an if(!_cronSkillsCache) guard.
m = re.search(
r'function openCronCreate\(\)\{.*?\}',
src, re.DOTALL
)
assert m, "openCronCreate definition not found"
assert "if(!_cronSkillsCache)" not in m.group(0), (
"openCronCreate should not use 'if(!_cronSkillsCache)' guard — "
"cache must always be busted on open"
)
def test_cache_busted_on_skill_save(self):
src = self._panels_src()
# saveSkillForm() is the handler invoked on skill save (renamed from
# submitSkillSave in the main-view refactor; the old name still aliases it).
m = re.search(
r'async function saveSkillForm\(\).*?_skillsData\s*=\s*null.*?_cronSkillsCache\s*=\s*null',
src, re.DOTALL
)
assert m, (
"_cronSkillsCache must be set to null in saveSkillForm() "
"right after _skillsData = null"
)
# ── Group D: System (auto) theme ──────────────────────────────────────────────
class TestSystemTheme:
def test_apply_theme_helper_in_boot_js(self):
src = read("static/boot.js")
assert "function _applyTheme(" in src, (
"_applyTheme helper function must be defined in boot.js"
)
def test_apply_theme_resolves_system(self):
src = read("static/boot.js")
assert "normalized.theme==='system'" in src or "=== 'system'" in src, (
"_applyTheme must branch on 'system' to resolve via matchMedia"
)
def test_apply_theme_uses_matchmedia(self):
src = read("static/boot.js")
assert "prefers-color-scheme" in src, (
"_applyTheme must use matchMedia('(prefers-color-scheme:dark)')"
)
def test_load_settings_calls_apply_theme(self):
src = read("static/boot.js")
assert "_applyTheme(appearance.theme)" in src, (
"loadSettings must call _applyTheme() instead of direct data-theme assignment"
)
def test_system_option_in_theme_picker(self):
html = read("static/index.html")
assert "_pickTheme('system')" in html, (
"Theme picker must include a system theme button"
)
assert ">System<" in html, (
"Theme picker must show 'System' label"
)
def test_theme_picker_uses_pick_theme(self):
html = read("static/index.html")
assert "_pickTheme(" in html, (
"Theme buttons must call _pickTheme()"
)
def test_flicker_script_resolves_system(self):
html = read("static/index.html")
# The head flicker-prevention IIFE must handle 'system'
assert "==='system'" in html or "=== 'system'" in html, (
"Flicker-prevention head script must resolve 'system' before setting data-theme"
)
assert "legacy={slate:['dark','slate']" in html, (
"Flicker-prevention head script must normalize legacy theme names on first paint"
)
def test_system_in_commands_themes_list(self):
src = read("static/commands.js")
assert "'system'" in src, (
"/theme command must include 'system' in the valid themes array"
)
def test_commands_uses_apply_theme(self):
src = read("static/commands.js")
assert "_applyTheme(appearance.theme)" in src, (
"cmdTheme must call _applyTheme() with the normalized canonical theme"
)
def test_commands_accept_legacy_theme_aliases(self):
src = read("static/commands.js")
assert "const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});" in src, (
"cmdTheme must accept legacy theme aliases and map them onto canonical appearance values"
)
def test_panels_reverts_via_apply_theme(self):
src = read("static/panels.js")
assert "_applyTheme(_settingsThemeOnOpen)" in src or \
"_applyTheme(" in src, (
"_revertSettingsPreview must call _applyTheme() so 'system' "
"is correctly re-activated on settings discard"
)
def test_panels_saves_system_string_not_resolved(self):
src = read("static/panels.js")
assert "localStorage.getItem('hermes-theme')" in src, (
"_settingsThemeOnOpen must read from localStorage to preserve "
"the 'system' string, not the resolved 'dark'/'light'"
)
def test_i18n_cmd_theme_includes_system_english(self):
src = read("static/i18n.js")
assert "system/dark/light" in src, (
"English cmd_theme i18n key must include 'system' in the theme list"
)
def test_i18n_cmd_theme_all_locales(self):
src = read("static/i18n.js")
count = src.count("system/dark/light")
assert count >= 5, (
f"cmd_theme description should mention 'system' in all 5 locales; "
f"found {count}"
)
def test_theme_listener_cleanup_uses_stable_handler(self):
src = read("static/boot.js")
assert "_systemThemeMq&&_onSystemThemeChange" in src, (
"_applyTheme must track the active OS-theme listener so it can be removed cleanly"
)
assert "removeEventListener('change',_onSystemThemeChange)" in src, (
"_applyTheme must remove the previous OS-theme listener before adding a new one"
)
def test_panels_hydrates_appearance_before_models_fetch(self):
src = read("static/panels.js")
skin_idx = src.index("const skinVal=(settings.skin||'default').toLowerCase();")
# models is now declared as let models=null before the try block
models_idx = src.index("models=await api('/api/models');")
assert skin_idx < models_idx, (
"loadSettingsPanel must hydrate theme/skin before awaiting /api/models, "
"otherwise a slow model fetch can clobber an in-progress skin selection"
)

View File

@@ -0,0 +1,255 @@
"""Regression tests for the blockquote rendering fix (fix/blockquote-rendering).
Root cause: the old rule was `s.replace(/^> (.+)$/gm, ...)` which had three bugs:
1. `.+` required at least one character — bare `>` lines passed through as literal `>`
2. Each line became its own `<blockquote>` — no grouping, so 10-line quotes became
10 stacked `<blockquote>` elements
3. Fenced code blocks inside blockquotes left orphaned `>` literals after the
fence-stash pass had consumed the code content
Fix: group consecutive `>` lines into a single `<blockquote>`, handle bare `>` lines
as `<br>`, and strip the `>` prefix before passing each line to `inlineMd()`.
"""
import re
import pathlib
UI_JS = (pathlib.Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Python mirror of the new blockquote rule + inlineMd (for behavioural tests)
# ---------------------------------------------------------------------------
import html as _html
def _esc(s):
return _html.escape(str(s), quote=True)
def _inline_md(t):
"""Minimal inlineMd mirror — bold, italic, inline-code only."""
t = re.sub(r"`([^`\n]+)`", lambda m: f"<code>{_esc(m.group(1))}</code>", t)
t = re.sub(r"\*\*\*(.+?)\*\*\*", lambda m: f"<strong><em>{_esc(m.group(1))}</em></strong>", t)
t = re.sub(r"\*\*(.+?)\*\*", lambda m: f"<strong>{_esc(m.group(1))}</strong>", t)
t = re.sub(r"\*([^*\n]+)\*", lambda m: f"<em>{_esc(m.group(1))}</em>", t)
return t
def _apply_blockquote(s):
"""Python mirror of the new group-based blockquote rule in ui.js."""
def replacer(m):
block = m.group(0)
lines = block.split("\n")
# Drop a lone trailing ">" artifact that the regex can leave
while lines and lines[-1].strip() in (">", ""):
if lines[-1].strip() == ">":
lines.pop()
break
lines.pop()
processed = []
for l in lines:
stripped = re.sub(r"^>[ \t]?", "", l)
if stripped.strip() == "":
processed.append("<br>")
else:
processed.append(_inline_md(stripped))
inner = "\n".join(processed)
return f"<blockquote>{inner}</blockquote>"
return re.sub(r"((?:^>[^\n]*(?:\n|$))+)", replacer, s, flags=re.MULTILINE)
# ---------------------------------------------------------------------------
# Source-level structural tests
# ---------------------------------------------------------------------------
class TestBlockquoteSourceStructure:
"""The new rule must be present in ui.js and the old single-line rule must be gone."""
def test_old_single_line_rule_removed(self):
"""The old `.+` pattern that skipped blank lines must be gone."""
assert "replace(/^> (.+)$/gm" not in UI_JS, (
"Old single-line blockquote rule still present — it misses blank '>'"
" lines and creates one <blockquote> per line"
)
def test_blockquote_pre_pass_present(self):
"""The blockquote pre-pass (line walker + recursive render + stash)
must be present in ui.js."""
assert "_bq_stash" in UI_JS, (
"Blockquote stash array (_bq_stash) not found — pre-pass missing"
)
assert "_applyBlockquotes" in UI_JS, (
"_applyBlockquotes line-walker function not found"
)
def test_prefix_strip_present(self):
"""The fix must strip the '> ' prefix from each line."""
assert "replace(/^> ?/" in UI_JS, (
"Expected prefix-strip pattern `^> ?` not found in the blockquote block"
)
def test_bq_stash_token_in_paragraph_bypass(self):
"""\\x00Q must be in the paragraph-splitter bypass so blockquote
stash tokens are not wrapped in <p>."""
assert r"\x00[EQ]" in UI_JS, (
"Paragraph-splitter bypass must accept \\x00Q (blockquote token) "
"alongside \\x00E (pre stash token)"
)
def test_bq_stash_restore_present(self):
"""The stash restore must run at the end of renderMd."""
assert r"\x00Q(\d+)\x00" in UI_JS, (
"Blockquote stash restore regex not found in ui.js"
)
# ---------------------------------------------------------------------------
# Behavioural tests (using the Python mirror)
# ---------------------------------------------------------------------------
class TestMultiLineBlockquote:
"""Consecutive > lines must become ONE <blockquote>, not many."""
def test_single_line_still_works(self):
out = _apply_blockquote("> Hello world")
assert out.count("<blockquote>") == 1
assert "Hello world" in out
assert ">" not in out.replace("<blockquote>", "").replace("</blockquote>", "")
def test_two_consecutive_lines_grouped(self):
src = "> Line one\n> Line two"
out = _apply_blockquote(src)
assert out.count("<blockquote>") == 1, (
f"Expected 1 <blockquote>, got {out.count('<blockquote>')}: {out!r}"
)
def test_ten_lines_one_blockquote(self):
src = "\n".join(f"> Line {i}" for i in range(10))
out = _apply_blockquote(src)
assert out.count("<blockquote>") == 1
def test_two_separate_quotes_stay_separate(self):
src = "> First quote\n\n> Second quote"
out = _apply_blockquote(src)
# Each quote is its own group (separated by a blank line)
assert out.count("<blockquote>") == 2
class TestBlankContinuationLines:
"""Bare '>' lines (blank continuation) must not appear as literal '>'."""
def test_bare_gt_line_no_literal(self):
src = "> Para one\n>\n> Para two"
out = _apply_blockquote(src)
assert out.count("<blockquote>") == 1, f"Expected 1 blockquote: {out!r}"
# No stray '>' outside of HTML tags
text_only = re.sub(r"<[^>]+>", "", out)
assert ">" not in text_only, f"Literal '>' in text: {text_only!r}"
def test_bare_gt_no_space_handled(self):
"""'>' with no space at all should also be consumed, not rendered literally."""
src = ">no space after"
out = _apply_blockquote(src)
assert out.count("<blockquote>") == 1
text_only = re.sub(r"<[^>]+>", "", out)
assert ">" not in text_only
def test_blank_line_becomes_br(self):
src = "> First\n>\n> Second"
out = _apply_blockquote(src)
assert "<br>" in out, f"Expected <br> for blank > line: {out!r}"
class TestInlineMarkdownInsideBlockquote:
"""Bold, italic, and inline code must still render correctly inside a blockquote."""
def test_bold_inside_blockquote(self):
out = _apply_blockquote("> This is **important**")
assert "<strong>" in out
assert "<blockquote>" in out
def test_inline_code_inside_blockquote(self):
out = _apply_blockquote("> Run `git status` first")
assert "<code>" in out
assert "<blockquote>" in out
def test_italic_inside_blockquote(self):
out = _apply_blockquote("> *emphasis* here")
assert "<em>" in out
assert "<blockquote>" in out
class TestNoPhantomTrailingBr:
"""The fix must drop both empty trailing lines (from a trailing \\n in the
match) and bare '>' artifacts. Without this, the common case — a blockquote
followed by another paragraph — renders with a phantom <br> right before
</blockquote>, leaving a visible blank line at the bottom of the quote.
"""
def test_input_ending_with_newline_no_trailing_br(self):
"""`> Hello\\n` must NOT produce `<blockquote>Hello\\n<br></blockquote>`."""
out = _apply_blockquote("> Hello\n")
assert "<br></blockquote>" not in out, (
f"Trailing <br> leaked inside the blockquote (phantom blank line): {out!r}"
)
def test_blockquote_followed_by_paragraph_no_trailing_br(self):
"""The common real-world shape: quote + blank line + paragraph."""
src = "> Quoted text\n\nNormal paragraph"
out = _apply_blockquote(src)
assert "<br></blockquote>" not in out, (
f"Trailing <br> leaked inside blockquote when followed by paragraph: {out!r}"
)
def test_multiline_quote_ending_with_newline_no_trailing_br(self):
out = _apply_blockquote("> Line one\n> Line two\n")
assert "<br></blockquote>" not in out, (
f"Multi-line quote ending with \\n must not leave a trailing <br>: {out!r}"
)
def test_quote_with_blank_continuation_then_newline(self):
"""`> A\\n>\\n> B\\n` — the internal `<br>` for the blank line stays,
but the trailing newline must not add a second `<br>` at the end."""
out = _apply_blockquote("> A\n>\n> B\n")
# Internal <br> for the blank-line continuation is intentional
assert "<br>" in out
# But there must not be a <br> immediately before the closing tag
assert "<br></blockquote>" not in out, (
f"Trailing <br> leaked at end of blockquote: {out!r}"
)
class TestBlockquoteFollowedByParagraph:
"""A blockquote followed by a normal paragraph must not bleed into each other."""
def test_non_blockquote_paragraph_untouched(self):
src = "> Quoted text\n\nNormal paragraph"
out = _apply_blockquote(src)
assert out.count("<blockquote>") == 1
assert "Normal paragraph" in out
# Normal paragraph must be outside the blockquote
after_bq = out[out.index("</blockquote>"):]
assert "Normal paragraph" in after_bq
class TestBlockquotePrePassOrdering:
"""Structural checks that lock the ordering of the blockquote pre-pass
relative to the entity-decode and MEDIA-stash passes in renderMd()."""
def test_entity_decode_runs_before_blockquote_pre_pass(self):
"""The entity decode must appear BEFORE the blockquote pre-pass in
renderMd() so &gt;-prefixed lines are recognised as blockquotes."""
# The entity decode is represented by '&gt;' replacement or the
# inline decode line, whichever appears first.
decode_idx = min(
UI_JS.find("replace(/&gt;/g"),
UI_JS.find("replace(/&lt;/g"),
)
bq_stash_idx = UI_JS.find("_bq_stash")
assert decode_idx != -1, "Entity decode (&gt; or &lt;) not found in renderMd"
assert bq_stash_idx != -1, "_bq_stash not found"
assert decode_idx < bq_stash_idx, (
"Entity decode must appear before the blockquote pre-pass (_bq_stash). "
f"decode at {decode_idx}, _bq_stash at {bq_stash_idx}"
)

View File

@@ -0,0 +1,187 @@
"""
Tests for bootstrap.py .env loading fix (issue #730).
bootstrap.py is the primary documented entry point ("python3 bootstrap.py").
Previously it did not load REPO_ROOT/.env, so HERMES_WEBUI_HOST, HERMES_WEBUI_PORT
etc. were silently ignored when launching without start.sh.
Covers:
1. _load_repo_dotenv() sets env vars from a repo .env file
2. _load_repo_dotenv() ignores commented lines and blank lines
3. _load_repo_dotenv() strips quotes from values
4. _load_repo_dotenv() is a no-op when .env does not exist
5. _load_repo_dotenv() prints a warning (not crash) on unreadable .env
6. _load_repo_dotenv() overwrites existing env vars (shell source semantics)
7. _load_repo_dotenv() handles 'export FOO=bar' prefix
8. _load_repo_dotenv() preserves values containing '='
9. Variables are set unconditionally (not setdefault)
10. Structural: loader is called before DEFAULT_HOST/DEFAULT_PORT
"""
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
REPO_ROOT = Path(__file__).parent.parent
class TestLoadRepoDotenv:
def setup_method(self):
self._saved_env = os.environ.copy()
def teardown_method(self):
os.environ.clear()
os.environ.update(self._saved_env)
def _run(self, tmp_path, env_content: str):
"""Write .env to tmp_path and run _load_repo_dotenv() with that root."""
import bootstrap as bs
(tmp_path / ".env").write_text(env_content, encoding="utf-8")
orig_root = bs.REPO_ROOT
try:
bs.REPO_ROOT = tmp_path
bs._load_repo_dotenv()
finally:
bs.REPO_ROOT = orig_root
def test_sets_env_var_from_dotenv(self, tmp_path):
"""Basic key=value is loaded into os.environ."""
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_sets_port_from_dotenv(self, tmp_path):
"""HERMES_WEBUI_PORT is loaded as a string (caller does int() conversion)."""
self._run(tmp_path, "HERMES_WEBUI_PORT=18787\n")
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
def test_ignores_comment_lines(self, tmp_path):
"""Lines starting with # are not loaded."""
os.environ.pop("HERMES_WEBUI_HOST", None)
self._run(tmp_path, "# HERMES_WEBUI_HOST=should-be-ignored\n")
assert os.environ.get("HERMES_WEBUI_HOST") is None
def test_ignores_blank_lines(self, tmp_path):
"""Blank lines are silently skipped without error."""
self._run(tmp_path, "\n\nHERMES_WEBUI_PORT=9000\n\n")
assert os.environ.get("HERMES_WEBUI_PORT") == "9000"
def test_strips_double_quoted_values(self, tmp_path):
"""Values wrapped in double quotes are stripped."""
self._run(tmp_path, 'HERMES_WEBUI_HOST="0.0.0.0"\n')
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_strips_single_quoted_values(self, tmp_path):
"""Values wrapped in single quotes are stripped."""
self._run(tmp_path, "HERMES_WEBUI_HOST='0.0.0.0'\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_noop_when_no_dotenv(self, tmp_path):
"""No .env file — function returns silently without error."""
import bootstrap as bs
orig = bs.REPO_ROOT
try:
bs.REPO_ROOT = tmp_path # tmp_path has no .env
bs._load_repo_dotenv() # must not raise
finally:
bs.REPO_ROOT = orig
def test_noop_when_dotenv_unreadable(self, tmp_path, capsys):
"""Unreadable .env prints a warning to stderr — does not crash."""
import bootstrap as bs
env_path = tmp_path / ".env"
env_path.write_text("HERMES_WEBUI_PORT=9999\n")
orig = bs.REPO_ROOT
try:
bs.REPO_ROOT = tmp_path
with patch("pathlib.Path.read_text", side_effect=PermissionError("no access")):
bs._load_repo_dotenv() # must not raise
finally:
bs.REPO_ROOT = orig
captured = capsys.readouterr()
assert "bootstrap" in captured.err.lower() or "warning" in captured.err.lower() or \
"could not load" in captured.err.lower(), (
"_load_repo_dotenv() should print a warning to stderr on read failure"
)
def test_overwrites_existing_env_var(self, tmp_path):
"""Unconditional overwrite matches shell source semantics."""
os.environ["HERMES_WEBUI_HOST"] = "127.0.0.1"
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_does_not_set_empty_values(self, tmp_path):
"""A key whose value is empty after stripping is not set to a non-empty string."""
os.environ.pop("HERMES_EMPTY_KEY", None)
self._run(tmp_path, 'HERMES_EMPTY_KEY=""\n')
# The current implementation sets key to "" (empty string) — verify it is
# not set to a non-empty string, which would be clearly wrong.
val = os.environ.get("HERMES_EMPTY_KEY")
assert val != "something-wrong", "Empty-value key must not be set to a non-empty string"
# Specifically: empty string or absent are both acceptable behaviours.
assert val in (None, ""), f"Unexpected value for empty-quoted key: {val!r}"
def test_multiple_keys_all_loaded(self, tmp_path):
"""Multiple key=value pairs in one file are all loaded."""
content = "HERMES_WEBUI_HOST=0.0.0.0\nHERMES_WEBUI_PORT=18787\n"
self._run(tmp_path, content)
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
def test_value_with_equals_sign_preserved(self, tmp_path):
"""Values containing '=' (e.g. base64) are preserved correctly."""
self._run(tmp_path, "MY_KEY=abc=def==\n")
assert os.environ.get("MY_KEY") == "abc=def=="
def test_export_prefix_stripped(self, tmp_path):
"""'export FOO=bar' lines are parsed correctly — export prefix is stripped."""
self._run(tmp_path, "export HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0", (
"'export KEY=value' lines must set KEY, not 'export KEY'"
)
# ---------------------------------------------------------------------------
# Structural tests — confirm the fix is in place
# ---------------------------------------------------------------------------
class TestBootstrapStructure:
def test_load_repo_dotenv_function_exists(self):
"""bootstrap.py must export _load_repo_dotenv()."""
import bootstrap as bs
assert callable(getattr(bs, "_load_repo_dotenv", None)), (
"bootstrap.py must define _load_repo_dotenv() so that "
"python3 bootstrap.py loads REPO_ROOT/.env before reading env defaults"
)
def test_dotenv_loaded_before_default_host_port(self):
"""_load_repo_dotenv() call must appear before DEFAULT_HOST/DEFAULT_PORT in source."""
src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
load_pos = src.find("_load_repo_dotenv()")
host_pos = src.find("DEFAULT_HOST")
port_pos = src.find("DEFAULT_PORT")
assert load_pos != -1, "_load_repo_dotenv() call not found in bootstrap.py"
assert load_pos < host_pos, (
"_load_repo_dotenv() must be called before DEFAULT_HOST assignment "
"so that HERMES_WEBUI_HOST from .env is picked up"
)
assert load_pos < port_pos, (
"_load_repo_dotenv() must be called before DEFAULT_PORT assignment "
"so that HERMES_WEBUI_PORT from .env is picked up"
)
def test_start_sh_and_bootstrap_equivalent_env_loading(self):
"""start.sh sources .env before bootstrap.py; bootstrap.py must now do the same."""
start_sh = (REPO_ROOT / "start.sh").read_text(encoding="utf-8")
bootstrap_src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
# start.sh sources .env
assert "source" in start_sh and ".env" in start_sh, (
"start.sh should still source .env (regression guard)"
)
# bootstrap.py now loads it too
assert "_load_repo_dotenv" in bootstrap_src, (
"bootstrap.py must load .env so direct invocation matches start.sh behaviour"
)

View File

@@ -0,0 +1,189 @@
"""
Bug batch fixes — April 2026.
Covers:
- #594: .app-dialog and .file-rename-input have light theme overrides in style.css
- #576: workspace panel localStorage restore is gated on session.workspace presence (boot.js)
- #585: get_available_models() calls reload_config() before reading config cache
- #567: docker-compose.yml comment mentions macOS UID mismatch
- #590: _transcribeBlob already calls setComposerStatus('Transcribing…') — confirmed present
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
COMPOSE = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
# ── #594: light theme dialog overrides ───────────────────────────────────────
def test_594_app_dialog_has_light_mode_override():
"""style.css must have a light mode rule targeting .app-dialog background."""
assert ':root:not(.dark) .app-dialog{' in STYLE_CSS, (
"Missing light mode override for .app-dialog — dialogs appear dark on light theme"
)
def test_594_app_dialog_input_has_light_mode_override():
"""style.css must have a light mode rule for .app-dialog-input."""
assert ":root:not(.dark) .app-dialog-input{" in STYLE_CSS, (
"Missing light mode override for .app-dialog-input"
)
def test_594_app_dialog_btn_has_light_mode_override():
"""style.css must have a light mode rule for .app-dialog-btn."""
assert ":root:not(.dark) .app-dialog-btn{" in STYLE_CSS, (
"Missing light mode override for .app-dialog-btn"
)
def test_594_app_dialog_close_has_light_mode_override():
"""style.css must have a light mode rule for .app-dialog-close."""
assert ":root:not(.dark) .app-dialog-close{" in STYLE_CSS, (
"Missing light mode override for .app-dialog-close"
)
def test_594_file_rename_input_has_light_mode_override():
"""style.css must have a light mode rule for .file-rename-input."""
assert ":root:not(.dark) .file-rename-input{" in STYLE_CSS, (
"Missing light mode override for .file-rename-input"
)
# ── dark-mode user bubble semantics ──────────────────────────────────────────
def test_dark_user_bubbles_use_dark_tinted_surface():
"""Dark mode should keep user bubbles dark, with skin only tinting the bubble."""
assert "--user-bubble-bg: var(--accent-bg-strong);" in STYLE_CSS, (
"Dark mode user bubbles should use the dark accent tint, not the full bright accent fill"
)
assert "--user-bubble-border: var(--accent-bg-strong);" in STYLE_CSS, (
"Dark mode user bubble borders should match the quieter thinking-card border intensity"
)
assert "--user-bubble-text: var(--text);" in STYLE_CSS, (
"Dark mode user bubble text should inherit the theme text color"
)
def test_dark_user_bubbles_do_not_need_per_skin_text_hacks():
"""Dark-mode user bubble contrast should not rely on per-skin text overrides."""
assert re.search(r':root\.dark\[data-skin="[^"]+"\]\s*\{\s*--user-bubble-text:', STYLE_CSS) is None, (
"Dark-mode user bubble contrast should come from shared theme tokens, not per-skin text hacks"
)
def test_user_bubbles_define_selection_tokens_for_both_modes():
"""User bubbles need dedicated selection colors so selected text remains readable."""
assert "--user-selection-bg: rgba(0,0,0,.22);" in STYLE_CSS, (
"Light-mode user bubbles should define a darker selection fill for contrast"
)
assert "--user-selection-bg: rgba(255,255,255,.18);" in STYLE_CSS, (
"Dark-mode user bubbles should define a lighter selection fill for contrast"
)
assert "--user-selection-text: #fff;" in STYLE_CSS, (
"Light-mode user bubble selection should preserve readable text color"
)
def test_user_bubble_selection_is_scoped_to_user_message_body():
"""Selection override must apply only to user bubbles, including nested markdown nodes."""
assert '.msg-row[data-role="user"] .msg-body::selection,' in STYLE_CSS, (
"Missing selection override on the user message bubble"
)
assert '.msg-row[data-role="user"] .msg-body *::selection {' in STYLE_CSS, (
"Nested elements inside user messages must inherit the same selection colors"
)
# ── #576: workspace panel snap fix ───────────────────────────────────────────
def test_576_panel_restore_gated_on_workspace():
"""boot.js: localStorage panel restore must be gated on session.workspace."""
# The guard must appear: session.workspace check before _workspacePanelMode='browse'
# Panel pref key takes priority over runtime key (toolbar close must not clear preference)
assert "S.session&&S.session.workspace&&panelPref" in BOOT_JS, (
"Workspace panel localStorage restore must be gated on S.session.workspace "
"to prevent snap-open-then-closed on sessions without a workspace (#576)"
)
assert "'hermes-webui-workspace-panel-pref'" in BOOT_JS, (
"Panel restore must check the preference key so toolbar close does not clear it"
)
def test_576_restore_happens_after_load_session():
"""boot.js: loadSession() must come before the panel restore guard."""
load_pos = BOOT_JS.find("await loadSession(saved)")
restore_pos = BOOT_JS.find("panelPref")
assert load_pos != -1, "loadSession call not found in boot.js"
assert restore_pos != -1, "workspace panel restore guard not found"
assert load_pos < restore_pos, (
"loadSession() must run before the panel restore guard "
"so S.session.workspace is known at restore time"
)
# ── #585: get_available_models reloads config ─────────────────────────────────
def test_585_get_available_models_calls_reload_config():
"""api/config.py: get_available_models() must do a mtime-based reload check."""
config_src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
fn_start = config_src.find("def get_available_models()")
assert fn_start != -1, "get_available_models not found"
fn_body_end = config_src.find('"""', config_src.find('"""', fn_start + 30) + 3) + 3
# Must check mtime before reading config
mtime_pos = config_src.find("_current_mtime", fn_body_end)
active_prov_pos = config_src.find("active_provider = None", fn_body_end)
assert mtime_pos != -1, (
"get_available_models() must check config file mtime before reading cache (#585)"
)
assert mtime_pos < active_prov_pos, (
"mtime check must come before active_provider = None in get_available_models()"
)
# ── #567: docker-compose UID note ─────────────────────────────────────────────
def test_567_compose_mentions_macos_uid():
"""docker-compose.yml must mention macOS UID / id -u to help macOS users."""
assert "macOS" in COMPOSE or "macos" in COMPOSE.lower(), (
"docker-compose.yml should mention macOS UID issue (#567)"
)
assert "id -u" in COMPOSE, (
"docker-compose.yml should tell users to run 'id -u' to find their UID (#567)"
)
# ── #590: transcription spinner already present ───────────────────────────────
def test_590_transcribing_status_shown_before_fetch():
"""boot.js: setComposerStatus('Transcribing…') must fire before the fetch call."""
transcribe_fn_start = BOOT_JS.find("async function _transcribeBlob(")
assert transcribe_fn_start != -1, "_transcribeBlob not found in boot.js"
fn_body = BOOT_JS[transcribe_fn_start:transcribe_fn_start + 600]
status_pos = fn_body.find("setComposerStatus('Transcribing")
fetch_pos = fn_body.find("await fetch(")
assert status_pos != -1, (
"setComposerStatus('Transcribing…') must be called before the fetch in _transcribeBlob"
)
assert fetch_pos != -1, "await fetch not found in _transcribeBlob"
assert status_pos < fetch_pos, (
"setComposerStatus('Transcribing…') must appear before 'await fetch' "
"so the UI shows a spinner immediately on stop (#590)"
)
def test_590_recording_stops_before_transcribe():
"""boot.js: _setRecording(false) must fire in onstop before _transcribeBlob."""
onstop_start = BOOT_JS.find("mediaRecorder.onstop")
assert onstop_start != -1, "mediaRecorder.onstop not found"
onstop_body = BOOT_JS[onstop_start:onstop_start + 400]
rec_pos = onstop_body.find("_setRecording(false)")
blob_pos = onstop_body.find("_transcribeBlob(")
assert rec_pos != -1 and blob_pos != -1
assert rec_pos < blob_pos, (
"_setRecording(false) must come before _transcribeBlob so mic icon clears immediately"
)

View File

@@ -0,0 +1,381 @@
"""Tests for #815 — BYOK/custom provider models missing from WebUI model dropdown.
Root causes fixed:
1. active_provider alias not normalized in get_available_models()
('z.ai' -> 'zai', 'x.ai' -> 'xai', 'google' -> 'gemini', etc.)
causing the provider to fall to the 'else/unknown' branch with no models.
2. /api/models/live didn't normalize the provider query param, so
provider_model_ids() received the un-aliased form and returned [].
3. /api/models/live returned empty for provider='custom' even when
custom_providers entries exist in config.yaml — the live enrichment
step never added those models.
"""
import pathlib
import re
import sys
import unittest.mock as mock
import pytest
REPO = pathlib.Path(__file__).parent.parent
sys.path.insert(0, str(REPO))
sys.path.insert(0, str(REPO.parent / ".hermes" / "hermes-agent"))
def read(rel):
return (REPO / rel).read_text(encoding="utf-8")
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the TTL model cache before AND after every test.
``get_available_models()`` caches its result keyed on config.yaml mtime.
Tests in this file repoint ``_get_config_path`` to a tmp_path, populate
the cache there, then let monkeypatch restore the original path. The
cache, keyed on the tmp_path's mtime, then poisons downstream tests
(e.g. test_model_resolver) which see stale data and never hit their
mocks. Clearing the cache around each test breaks that linkage.
"""
import api.config as c
try:
c.invalidate_models_cache()
except Exception:
pass
yield
try:
c.invalidate_models_cache()
except Exception:
pass
# ── api/config.py — active_provider normalization ─────────────────────────────
class TestActiveProviderNormalization:
"""get_available_models() must normalize active_provider aliases before lookup."""
def _run(self, tmp_path, provider_str, monkeypatch):
"""Return get_available_models() output for a given provider string."""
import api.config as c
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
f"model:\n provider: {provider_str}\n default: test-model\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
# Patch list_available_providers to avoid real network calls
fake_prov = mock.MagicMock()
fake_prov.return_value = []
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "list_available_providers", fake_prov)
except Exception:
pass
result = c.get_available_models()
c.reload_config()
return result
def test_z_dot_ai_normalized_to_zai(self, tmp_path, monkeypatch):
result = self._run(tmp_path, "z.ai", monkeypatch)
# active_provider returned to browser must be canonical 'zai' or
# at minimum must not be 'z.ai' (which would miss the _PROVIDER_MODELS lookup)
ap = result.get("active_provider", "")
assert ap in ("zai", ""), f"active_provider should be 'zai', got {ap!r}"
def test_x_dot_ai_normalized_to_xai(self, tmp_path, monkeypatch):
result = self._run(tmp_path, "x.ai", monkeypatch)
ap = result.get("active_provider", "")
assert ap in ("xai", ""), f"active_provider should be 'xai', got {ap!r}"
def test_google_normalized_to_gemini(self, tmp_path, monkeypatch):
result = self._run(tmp_path, "google", monkeypatch)
ap = result.get("active_provider", "")
assert ap in ("gemini", ""), f"active_provider should be 'gemini', got {ap!r}"
def test_normalization_code_present(self):
"""Source-level check: config.py must call _PROVIDER_ALIASES for active_provider."""
src = read("api/config.py")
# Must alias-normalize active_provider before the group-builder runs
assert "_PROVIDER_ALIASES" in src, (
"api/config.py must import _PROVIDER_ALIASES to normalize active_provider"
)
# The normalization must happen before the group builder loop
alias_pos = src.index("_PROVIDER_ALIASES")
group_builder_pos = src.index("for pid in sorted(detected_providers)")
assert alias_pos < group_builder_pos, (
"active_provider normalization must occur before the group-builder loop"
)
# ── api/routes.py — /api/models/live provider normalization ───────────────────
class TestLiveModelsProviderNormalization:
"""_handle_live_models must normalize the provider query param."""
def test_live_models_normalizes_provider_alias(self):
src = read("api/routes.py")
# Find _handle_live_models function
m = re.search(
r"def _handle_live_models\(.*?\ndef ",
src,
re.DOTALL,
)
assert m, "_handle_live_models not found"
fn = m.group(0)
assert "_resolve_provider_alias" in fn, (
"_handle_live_models must normalize provider via "
"api.config._resolve_provider_alias so 'z.ai' -> 'zai' "
"before calling provider_model_ids()"
)
def test_live_models_normalization_before_provider_model_ids(self):
"""Normalization call must appear before the provider_model_ids call site."""
src = read("api/routes.py")
alias_match = re.search(
r"provider\s*=\s*_resolve_provider_alias\(provider\)",
src,
)
pmi_call_match = re.search(
r"ids\s*=\s*_pmi\(provider\)",
src,
)
assert alias_match, "_resolve_provider_alias call not found in routes.py"
assert pmi_call_match, "ids = _pmi(provider) call not found"
assert alias_match.start() < pmi_call_match.start(), (
"alias normalization must occur before ids = _pmi(provider)"
)
def test_alias_resolver_works_without_hermes_cli(self):
"""Normalization must work even when hermes_cli is not importable —
CI and installs without the agent cloned alongside the WebUI.
The WebUI ships its own _PROVIDER_ALIASES table; the agent's table
is merged only when available."""
import api.config as c
# Core CLI aliases from #815's bug report
assert c._resolve_provider_alias('z.ai') == 'zai'
assert c._resolve_provider_alias('x.ai') == 'xai'
assert c._resolve_provider_alias('google') == 'gemini'
assert c._resolve_provider_alias('grok') == 'xai'
# Case / whitespace insensitive
assert c._resolve_provider_alias(' Z.AI ') == 'zai'
# Canonical names pass through unchanged
assert c._resolve_provider_alias('openrouter') == 'openrouter'
assert c._resolve_provider_alias('anthropic') == 'anthropic'
assert c._resolve_provider_alias('custom') == 'custom'
# Empty / None pass through
assert c._resolve_provider_alias('') == ''
assert c._resolve_provider_alias(None) is None
# ── api/routes.py — /api/models/live custom_providers fallback ────────────────
class TestLiveModelsCustomProviderFallback:
"""When provider='custom' and provider_model_ids() returns [],
/api/models/live must fall back to custom_providers entries from config.yaml."""
def test_custom_fallback_code_present(self):
src = read("api/routes.py")
m = re.search(
r"def _handle_live_models\(.*?\ndef ",
src,
re.DOTALL,
)
assert m, "_handle_live_models not found"
fn = m.group(0)
assert "custom_providers" in fn, (
"_handle_live_models must read custom_providers from config "
"as fallback when provider='custom' and provider_model_ids() returns []"
)
assert 'provider == "custom"' in fn or "provider=='custom'" in fn, (
"_handle_live_models must check provider == 'custom' before fallback"
)
def test_custom_fallback_returns_configured_models(self, tmp_path, monkeypatch):
"""End-to-end: /api/models/live?provider=custom returns custom_providers models."""
import api.config as c
import api.routes as r
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
"model:\n provider: custom\n default: my-byok-model\n"
"custom_providers:\n"
" - model: my-byok-model\n"
" api_base: https://my-llm.example.com/v1\n"
" api_key: sk-test\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
# Mock handler and parsed URL
handler = mock.MagicMock()
responses = []
def fake_j(h, data, **kw):
responses.append(data)
return True
monkeypatch.setattr(r, "j", fake_j)
from urllib.parse import urlparse
parsed = mock.MagicMock()
parsed.query = "provider=custom"
# Mock provider_model_ids to return [] (simulating no live endpoint)
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "provider_model_ids", lambda p: [])
except Exception:
pass
r._handle_live_models(handler, parsed)
assert responses, "handler must produce a response"
resp = responses[-1]
assert "models" in resp
model_ids = [m["id"] for m in resp.get("models", [])]
assert "my-byok-model" in model_ids, (
f"custom_providers model 'my-byok-model' must appear in live response; "
f"got {model_ids}"
)
# ── Regression: known-good providers still work ───────────────────────────────
class TestKnownProvidersUnaffected:
"""Normalization must not break providers whose names are already canonical."""
def test_openrouter_unaffected(self):
src = read("api/config.py")
# _PROVIDER_ALIASES lookup: 'openrouter' -> 'openrouter' (no change)
assert "openrouter" in src, "openrouter must still exist in config"
def test_anthropic_unaffected(self):
src = read("api/config.py")
assert "anthropic" in src
def test_custom_unaffected(self):
"""'custom' is not in _PROVIDER_ALIASES so normalization is a no-op."""
try:
from hermes_cli.models import _PROVIDER_ALIASES
assert "custom" not in _PROVIDER_ALIASES, (
"'custom' must not be aliased to anything — it's a special sentinel"
)
except ImportError:
pass # hermes-agent not available in this env — skip
# ── Source-level: active_provider returned to browser is canonical ─────────────
class TestProviderIdInGroupResponse:
"""get_available_models() must include provider_id on every group so the JS
_fetchLiveModels can match optgroups exactly rather than by substring."""
def test_groups_include_provider_id(self, tmp_path, monkeypatch):
import api.config as c
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
"model:\n provider: zai\n default: glm-5\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "list_available_providers", lambda: [
{"id": "zai", "authenticated": True}
])
import hermes_cli.auth as ha
monkeypatch.setattr(ha, "get_auth_status", lambda p: {"key_source": "env"})
except Exception:
pass
result = c.get_available_models()
c.reload_config()
for g in result.get("groups", []):
assert "provider_id" in g, (
f"group {g.get('provider')!r} missing provider_id — "
"JS _fetchLiveModels needs it to match optgroups exactly"
)
def test_provider_id_in_static_ui_js_optgroup(self):
src = read("static/ui.js")
assert "og.dataset.provider" in src, (
"populateModelDropdown must set og.dataset.provider from g.provider_id "
"so _fetchLiveModels can match by exact provider_id"
)
def test_fetch_live_models_prefers_data_provider_match(self):
src = read("static/ui.js")
# Live model optgroup matching was extracted to _addLiveModelsToSelect (#872)
m = re.search(r'function _addLiveModelsToSelect\b.*?\n\}', src, re.DOTALL)
if not m:
m = re.search(r'function _fetchLiveModels\b.*?\n\}', src, re.DOTALL)
assert m, "_addLiveModelsToSelect or _fetchLiveModels not found"
fn = m.group(0)
assert 'og.dataset.provider' in fn, (
"_addLiveModelsToSelect must check og.dataset.provider===provider before "
"falling back to label substring match"
)
# The data-provider check must come before the label.includes check
dp_pos = fn.index('og.dataset.provider')
label_pos = fn.index('og.label')
assert dp_pos < label_pos, (
"data-provider exact match must be attempted before label substring match"
)
# ── Opus-identified edge case: 'ollama' normalizes to 'custom' ────────────────
class TestOllamaAliasEdgeCase:
"""Opus review found: 'ollama' -> 'custom' via _PROVIDER_ALIASES.
This is better behaviour (custom_providers fallback catches it) but worth
documenting and not regressing."""
def test_ollama_not_in_provider_aliases_as_ollama(self):
"""'ollama' maps to 'custom' in _PROVIDER_ALIASES — verify this is the
intended behavior post-normalization (not a silent breakage)."""
try:
from hermes_cli.models import _PROVIDER_ALIASES
# 'ollama' -> 'custom' means ollama users hit the custom_providers path
# This is fine — ollama models appear via base_url auto-detection (step 3)
# in get_available_models, not via _PROVIDER_MODELS lookup.
ollama_target = _PROVIDER_ALIASES.get("ollama", "ollama")
# Acceptable outcomes: either unchanged (not in aliases) or 'custom'/'ollama-cloud'
assert ollama_target in ("ollama", "custom", "ollama-cloud"), (
f"Unexpected ollama alias: {ollama_target}"
)
except ImportError:
pass # hermes-agent not available
class TestGetAvailableModelsReturnsCanonicalProvider:
"""get_available_models() must return normalized active_provider in its response
so the browser sends the right value to /api/models/live."""
def test_active_provider_in_response_is_normalized(self, tmp_path, monkeypatch):
import api.config as c
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
"model:\n provider: z.ai\n default: glm-5\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "list_available_providers", lambda: [])
except Exception:
pass
result = c.get_available_models()
c.reload_config()
ap = result.get("active_provider", "")
# The browser will pass this value to /api/models/live?provider=<ap>
# It must be 'zai' so optgroup matching works in _fetchLiveModels
assert ap != "z.ai", (
"active_provider 'z.ai' must be normalized to 'zai' before being "
"returned to the browser (browser passes it back to /api/models/live)"
)

View File

@@ -0,0 +1,122 @@
"""
Unit tests for cancel/interrupt functionality.
Tests the integration between cancel_stream() and agent.interrupt().
"""
import pytest
import queue
import threading
from unittest.mock import Mock
from api.streaming import cancel_stream
from api.config import AGENT_INSTANCES, STREAMS, CANCEL_FLAGS
class TestCancelInterrupt:
"""Test suite for cancel/interrupt functionality"""
def setup_method(self):
"""Clean up before each test"""
AGENT_INSTANCES.clear()
STREAMS.clear()
CANCEL_FLAGS.clear()
def teardown_method(self):
"""Clean up after each test"""
AGENT_INSTANCES.clear()
STREAMS.clear()
CANCEL_FLAGS.clear()
def test_cancel_calls_agent_interrupt(self):
"""Verify that cancel_stream() calls agent.interrupt() when agent exists"""
# Setup
stream_id = "test_stream_123"
mock_agent = Mock()
mock_agent.interrupt = Mock()
STREAMS[stream_id] = queue.Queue()
CANCEL_FLAGS[stream_id] = threading.Event()
AGENT_INSTANCES[stream_id] = mock_agent
# Execute
result = cancel_stream(stream_id)
# Assert
assert result is True
mock_agent.interrupt.assert_called_once_with("Cancelled by user")
# CANCEL_FLAGS is eagerly popped after cancel (#776 fix) so the flag
# is no longer in the dict — verify the pop happened instead
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop CANCEL_FLAGS after signalling"
def test_cancel_handles_interrupt_exception(self):
"""Verify that cancel_stream() handles interrupt() exceptions gracefully"""
stream_id = "test_stream_456"
mock_agent = Mock()
mock_agent.interrupt = Mock(side_effect=RuntimeError("Agent error"))
STREAMS[stream_id] = queue.Queue()
CANCEL_FLAGS[stream_id] = threading.Event()
AGENT_INSTANCES[stream_id] = mock_agent
# Should not raise exception
result = cancel_stream(stream_id)
# Assert
assert result is True
mock_agent.interrupt.assert_called_once()
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop CANCEL_FLAGS even on interrupt exception"
def test_cancel_before_agent_ready(self):
"""Test cancel when agent not yet stored in AGENT_INSTANCES (race condition)"""
stream_id = "test_stream_789"
STREAMS[stream_id] = queue.Queue()
CANCEL_FLAGS[stream_id] = threading.Event()
# Note: AGENT_INSTANCES[stream_id] not set (simulating race condition)
# Should succeed even without agent
result = cancel_stream(stream_id)
# Assert
assert result is True
# CANCEL_FLAGS is eagerly popped; the agent thread checks the event
# object it already has a reference to — pop doesn't clear the event
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop CANCEL_FLAGS even without an agent"
# Agent will check this flag (it holds a reference to the event object)
def test_cancel_nonexistent_stream(self):
"""Test cancel for a stream that doesn't exist"""
result = cancel_stream("nonexistent_stream")
assert result is False
def test_cancel_sets_cancel_event(self):
"""Verify that cancel_stream() sets the cancel_event flag"""
stream_id = "test_stream_event"
STREAMS[stream_id] = queue.Queue()
cancel_event = threading.Event()
CANCEL_FLAGS[stream_id] = cancel_event
result = cancel_stream(stream_id)
assert result is True
assert cancel_event.is_set()
def test_cancel_puts_sentinel_in_queue(self):
"""Verify that cancel_stream() puts cancel sentinel in queue"""
stream_id = "test_stream_queue"
q = queue.Queue()
STREAMS[stream_id] = q
CANCEL_FLAGS[stream_id] = threading.Event()
result = cancel_stream(stream_id)
assert result is True
# Check that cancel message was queued
assert not q.empty()
event_type, data = q.get_nowait()
assert event_type == 'cancel'
assert data['message'] == 'Cancelled by user'

View File

@@ -0,0 +1,111 @@
from collections import Counter
from pathlib import Path
import re
REPO = Path(__file__).resolve().parent.parent
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def extract_locale_block(src: str, locale_key: str) -> str:
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
assert start_match, f"{locale_key} locale block not found"
start = start_match.end() - 1 # "{"
depth = 0
in_single = False
in_double = False
in_backtick = False
escape = False
for i in range(start, len(src)):
ch = src[i]
if escape:
escape = False
continue
if in_single:
if ch == "\\":
escape = True
elif ch == "'":
in_single = False
continue
if in_double:
if ch == "\\":
escape = True
elif ch == '"':
in_double = False
continue
if in_backtick:
if ch == "\\":
escape = True
elif ch == "`":
in_backtick = False
continue
if ch == "'":
in_single = True
continue
if ch == '"':
in_double = True
continue
if ch == "`":
in_backtick = True
continue
if ch == "{":
depth += 1
continue
if ch == "}":
depth -= 1
if depth == 0:
return src[start + 1 : i]
raise AssertionError(f"{locale_key} locale block braces are not balanced")
def test_chinese_locale_block_exists():
src = read(REPO / "static" / "i18n.js")
assert "\n zh: {" in src
assert "_lang: 'zh'" in src
assert "_speech: 'zh-CN'" in src
def test_chinese_locale_includes_representative_translations():
src = read(REPO / "static" / "i18n.js")
expected = [
"settings_title: '\\u8bbe\\u7f6e'",
"login_title: '\\u767b\\u5f55'",
"approval_heading: '需要审批'",
"tab_tasks: '任务'",
"tab_profiles: '配置'",
"session_time_bucket_today: '今天'",
"onboarding_title: '欢迎使用 Hermes Web UI'",
"onboarding_complete: '引导完成'",
]
for entry in expected:
assert entry in src
def test_chinese_locale_covers_english_keys():
src = read(REPO / "static" / "i18n.js")
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
en_keys = set(key_pattern.findall(extract_locale_block(src, "en")))
zh_keys = set(key_pattern.findall(extract_locale_block(src, "zh")))
missing = sorted(en_keys - zh_keys)
assert not missing, f"Chinese locale missing keys: {missing}"
def test_chinese_locale_has_no_duplicate_keys():
src = read(REPO / "static" / "i18n.js")
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
keys = key_pattern.findall(extract_locale_block(src, "zh"))
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
assert not duplicates, f"Chinese locale has duplicate keys: {duplicates}"

View File

@@ -0,0 +1,179 @@
"""Tests for clarify prompt unblocking and HTTP endpoints."""
import json
import uuid
import urllib.request
import urllib.error
import urllib.parse
import pytest
from tests._pytest_port import BASE
try:
from api.clarify import (
register_gateway_notify,
unregister_gateway_notify,
resolve_clarify,
clear_pending,
_gateway_queues,
_gateway_notify_cbs,
_lock,
_ClarifyEntry,
submit_pending,
)
CLARIFY_AVAILABLE = True
except ImportError:
CLARIFY_AVAILABLE = False
pytestmark = pytest.mark.skipif(
not CLARIFY_AVAILABLE,
reason="api.clarify not available in this environment",
)
def get(path):
url = BASE + path
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
def post(path, body=None):
url = BASE + path
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
class TestClarifyUnblocking:
"""Unit tests for clarify queue resolution."""
def test_resolve_clarify_sets_event(self):
sid = f"unit-clarify-{uuid.uuid4().hex[:8]}"
entry = _ClarifyEntry({"question": "Pick one", "choices_offered": ["a", "b"]})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
resolved = resolve_clarify(sid, "a", resolve_all=False)
assert resolved == 1
assert entry.event.is_set()
assert entry.result == "a"
def test_register_and_fire_notify_cb(self):
sid = f"unit-notify-{uuid.uuid4().hex[:8]}"
fired = []
register_gateway_notify(sid, lambda d: fired.append(d))
with _lock:
cb = _gateway_notify_cbs.get(sid)
assert cb is not None
data = {"question": "What now?", "choices_offered": ["x", "y"]}
cb(data)
assert fired == [data]
unregister_gateway_notify(sid)
def test_clear_pending_unblocks_waiters(self):
sid = f"unit-clear-{uuid.uuid4().hex[:8]}"
entry = _ClarifyEntry({"question": "Wait", "choices_offered": []})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
cleared = clear_pending(sid)
assert cleared == 1
assert entry.event.is_set()
with _lock:
assert sid not in _gateway_queues
def test_submit_pending_registers_entry(self):
sid = f"unit-submit-{uuid.uuid4().hex[:8]}"
data = {"question": "Pick", "choices_offered": ["one", "two"], "session_id": sid}
entry = submit_pending(sid, data)
assert entry.data["question"] == data["question"]
assert entry.data["choices_offered"] == data["choices_offered"]
assert entry.data["session_id"] == data["session_id"]
with _lock:
assert sid in _gateway_queues
clear_pending(sid)
def test_submit_pending_adds_timeout_metadata(self):
sid = f"unit-timeout-{uuid.uuid4().hex[:8]}"
entry = submit_pending(sid, {"question": "Wait", "choices_offered": []})
assert isinstance(entry.data["requested_at"], (int, float))
assert entry.data["timeout_seconds"] == 120
assert entry.data["expires_at"] == pytest.approx(
entry.data["requested_at"] + 120,
abs=0.1,
)
clear_pending(sid)
class TestClarifyModuleExports:
def test_register_gateway_notify_exported(self):
import api.clarify as ap
assert hasattr(ap, "register_gateway_notify")
def test_unregister_gateway_notify_exported(self):
import api.clarify as ap
assert hasattr(ap, "unregister_gateway_notify")
def test_resolve_clarify_exported(self):
import api.clarify as ap
assert hasattr(ap, "resolve_clarify")
def test_clarify_entry_exported(self):
import api.clarify as ap
assert hasattr(ap, "_ClarifyEntry")
class TestClarifyHTTPEndpoints:
"""Regression tests for /api/clarify/respond against the live test server."""
def test_respond_returns_ok_no_pending(self):
sid = f"http-no-pending-{uuid.uuid4().hex[:8]}"
result, status = post("/api/clarify/respond", {
"session_id": sid,
"response": "Use option A",
})
assert status == 200
assert result["ok"] is True
def test_respond_requires_session_id(self):
result, status = post("/api/clarify/respond", {"response": "Hello"})
assert status == 400
def test_respond_requires_response(self):
sid = f"http-no-response-{uuid.uuid4().hex[:8]}"
result, status = post("/api/clarify/respond", {"session_id": sid})
assert status == 400
def test_respond_clears_injected_pending(self):
sid = f"http-clear-{uuid.uuid4().hex[:8]}"
question = urllib.parse.quote("Pick the better option")
choices = urllib.parse.quote("A")
inject = get(
f"/api/clarify/inject_test?session_id={urllib.parse.quote(sid)}"
f"&question={question}&choices={choices}"
)
assert inject["ok"] is True
data = get(f"/api/clarify/pending?session_id={urllib.parse.quote(sid)}")
assert data["pending"] is not None
result, status = post("/api/clarify/respond", {
"session_id": sid,
"response": "B",
})
assert status == 200
assert result["ok"] is True
data2 = get(f"/api/clarify/pending?session_id={urllib.parse.quote(sid)}")
assert data2["pending"] is None

View File

@@ -0,0 +1,69 @@
"""Tests for #838 — slash command dropdown keyboard navigation keeps the
selected item in view."""
import os
import re
_SRC = os.path.join(os.path.dirname(__file__), "..")
def _read(name):
return open(os.path.join(_SRC, name), encoding="utf-8").read()
class TestNavigateCmdDropdownScroll:
"""navigateCmdDropdown must scroll the newly selected item into view so
keyboard navigation on a long list doesn't leave the highlight below the
visible area of the dropdown."""
def test_navigate_calls_scroll_into_view(self):
js = _read("static/commands.js")
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
assert m, "navigateCmdDropdown not found"
fn = m.group(0)
assert 'scrollIntoView' in fn, (
"navigateCmdDropdown must call scrollIntoView on the newly "
"selected item so ↓/↑ keeps the highlight visible (#838)"
)
def test_scroll_uses_nearest_block_alignment(self):
"""`{block:'nearest'}` is the correct option: scrolls only when
needed, minimum distance — won't jump the list around on every
arrow-key press when the item is already in view."""
js = _read("static/commands.js")
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
assert m
fn = m.group(0)
assert "block:'nearest'" in fn or 'block: "nearest"' in fn, (
"scrollIntoView should use {block:'nearest'} to scroll the "
"minimum amount needed"
)
def test_scroll_after_selected_class_update(self):
"""The scroll call must come AFTER adding the .selected class so
the correct item is targeted."""
js = _read("static/commands.js")
m = re.search(r'function navigateCmdDropdown\(.*?\n\}', js, re.DOTALL)
assert m
fn = m.group(0)
selected_pos = fn.find("classList.add('selected')")
scroll_pos = fn.find("scrollIntoView")
assert selected_pos != -1 and scroll_pos != -1
assert selected_pos < scroll_pos, (
"scrollIntoView must run after classList.add('selected') so it "
"scrolls the newly-highlighted item into view"
)
def test_cmd_dropdown_is_scroll_container(self):
"""Regression guard: the .cmd-dropdown must have overflow-y:auto
(or similar) so scrollIntoView finds it as the scroll ancestor
rather than bubbling up to the viewport."""
css = _read("static/style.css")
m = re.search(r'\.cmd-dropdown\s*\{[^}]+\}', css)
assert m, ".cmd-dropdown rule not found"
block = m.group(0)
assert 'overflow-y:auto' in block or 'overflow-y: auto' in block or \
'overflow:auto' in block or 'overflow: auto' in block, (
".cmd-dropdown must have overflow-y:auto so scrollIntoView "
"scrolls within the dropdown, not the whole page"
)

View File

@@ -0,0 +1,167 @@
"""Regression tests for idle-state fallback on /queue, /interrupt, /steer.
When the agent is idle (S.busy=false, S.activeStreamId=null), these commands
previously showed an error toast instead of sending the message. They now
fall through to a direct send() call, matching CLI behaviour:
- /queue msg → send when idle, queue when busy
- /interrupt msg → send when idle, cancel+requeue when busy+streaming
- /steer msg → send when idle, inject mid-turn when busy+streaming
"""
import re
import pathlib
COMMANDS_JS = (pathlib.Path(__file__).parent.parent / "static" / "commands.js").read_text(encoding="utf-8")
# ─────────────────────────────────────────────────────────────────────────────
# Source-level structural checks
# ─────────────────────────────────────────────────────────────────────────────
class TestIdleFallbackStructure:
"""Each handler must contain an idle-path that calls send() instead of
showing an error toast."""
def _get_function_body(self, fn_name: str, window: int = 800) -> str:
idx = COMMANDS_JS.find(f"async function {fn_name}(")
assert idx >= 0, f"{fn_name} not found in commands.js"
return COMMANDS_JS[idx: idx + window]
# /queue ──────────────────────────────────────────────────────────────────
def test_queue_idle_path_calls_send(self):
"""cmdQueue must call send() when !S.busy (idle)."""
body = self._get_function_body("cmdQueue")
assert "send()" in body, (
"cmdQueue must call send() when idle instead of showing an error toast"
)
def test_queue_idle_path_sets_input_value(self):
"""cmdQueue must populate the message input before calling send()."""
body = self._get_function_body("cmdQueue")
assert "inp.value=msg" in body or "inp.value = msg" in body, (
"cmdQueue must set input value before calling send()"
)
def test_queue_idle_path_not_toast_only(self):
"""cmdQueue must NOT show cmd_queue_not_busy toast as its only idle action."""
body = self._get_function_body("cmdQueue")
# The old code returned after a toast; new code returns after send().
# The toast key may still be in the file for other reasons but must not
# be the only thing that happens when !S.busy.
idle_branch_start = body.find("if(!S.busy)")
assert idle_branch_start >= 0, "cmdQueue must have an if(!S.busy) branch"
idle_branch = body[idle_branch_start: idle_branch_start + 300]
assert "send()" in idle_branch, (
"cmdQueue's idle branch must call send(), not just show a toast"
)
# /interrupt ──────────────────────────────────────────────────────────────
def test_interrupt_idle_path_calls_send(self):
"""cmdInterrupt must call send() when idle (!S.busy || !S.activeStreamId)."""
body = self._get_function_body("cmdInterrupt")
assert "send()" in body, (
"cmdInterrupt must call send() when idle instead of showing an error toast"
)
def test_interrupt_idle_path_sets_input_value(self):
body = self._get_function_body("cmdInterrupt")
assert "inp.value=msg" in body or "inp.value = msg" in body
def test_interrupt_idle_branch_exists(self):
body = self._get_function_body("cmdInterrupt")
# Either !S.busy||!S.activeStreamId or !S.busy block with send()
has_idle = "!S.busy||!S.activeStreamId" in body or "!S.busy" in body
assert has_idle, "cmdInterrupt must have an idle guard"
idle_start = body.find("!S.busy")
assert "send()" in body[idle_start: idle_start + 350], (
"cmdInterrupt idle branch must call send()"
)
# /steer ──────────────────────────────────────────────────────────────────
def test_steer_idle_path_calls_send(self):
"""cmdSteer must call send() when idle (!S.busy || !S.activeStreamId)."""
body = self._get_function_body("cmdSteer")
assert "send()" in body, (
"cmdSteer must call send() when idle instead of showing an error toast"
)
def test_steer_idle_path_sets_input_value(self):
body = self._get_function_body("cmdSteer")
assert "inp.value=msg" in body or "inp.value = msg" in body
def test_steer_idle_branch_before_trySteer(self):
"""The idle fallback must appear BEFORE the _trySteer call so steer text
is sent normally when there is nothing to steer."""
body = self._get_function_body("cmdSteer")
idle_idx = body.find("!S.busy")
steer_idx = body.find("_trySteer")
assert idle_idx >= 0, "cmdSteer must have an idle guard"
assert steer_idx >= 0, "cmdSteer must call _trySteer for active sessions"
assert idle_idx < steer_idx, (
"Idle fallback must come before _trySteer — otherwise steer text is "
"sent to the endpoint even when there is no active stream"
)
# Old error paths removed ─────────────────────────────────────────────────
def test_queue_no_longer_toasts_only_when_idle(self):
"""The old `showToast(t('cmd_queue_not_busy')); return` should not be the
sole handler for the idle case — that was the bug."""
body = self._get_function_body("cmdQueue")
idle_idx = body.find("if(!S.busy)")
assert idle_idx >= 0
idle_block = body[idle_idx: idle_idx + 250]
# send() must appear in the idle block
assert "send()" in idle_block, (
"cmdQueue idle block must contain send(), not just a toast+return"
)
def test_steer_no_longer_calls_no_active_task_toast_when_idle(self):
"""cmdSteer must not show 'no_active_task' toast as its response to being
called while idle — that wording implied steer cancels a task, which it
does not."""
body = self._get_function_body("cmdSteer")
idle_idx = body.find("!S.busy")
assert idle_idx >= 0
idle_block = body[idle_idx: idle_idx + 250]
assert "no_active_task" not in idle_block, (
"cmdSteer idle path must not show 'no_active_task' — steer doesn't "
"stop tasks, and when idle it should send normally"
)
class TestBusyPathsStillWork:
"""Active-session paths must be unchanged — guards are preserved."""
def _get_function_body(self, fn_name: str, window: int = 800) -> str:
idx = COMMANDS_JS.find(f"async function {fn_name}(")
assert idx >= 0
return COMMANDS_JS[idx: idx + window]
def test_queue_still_queues_when_busy(self):
"""When S.busy, cmdQueue must still call queueSessionMessage."""
body = self._get_function_body("cmdQueue")
assert "queueSessionMessage" in body, (
"cmdQueue must still queue messages when S.busy is true"
)
def test_interrupt_still_cancels_when_busy(self):
"""When S.busy && S.activeStreamId, cmdInterrupt must still call cancelStream."""
body = self._get_function_body("cmdInterrupt", window=1200)
assert "cancelStream" in body
def test_steer_still_calls_trySteer_when_busy(self):
"""When S.busy && S.activeStreamId, cmdSteer must still call _trySteer."""
body = self._get_function_body("cmdSteer")
assert "_trySteer" in body
def test_stop_command_unchanged(self):
"""cmdStop still uses no_active_task toast — that's correct for /stop."""
idx = COMMANDS_JS.find("async function cmdStop(")
body = COMMANDS_JS[idx: idx + 400]
assert "no_active_task" in body, (
"/stop should still show 'no active task' when idle — stopping nothing is an error"
)

View File

@@ -0,0 +1,84 @@
"""Tests for GET /api/commands -- exposes hermes-agent COMMAND_REGISTRY."""
import json
import urllib.request
import pytest
from tests.conftest import TEST_BASE, requires_agent_modules
def _get(path):
"""GET helper -- returns parsed JSON or raises HTTPError."""
with urllib.request.urlopen(TEST_BASE + path, timeout=10) as r:
return json.loads(r.read())
@requires_agent_modules
def test_commands_endpoint_returns_list():
"""GET /api/commands returns a JSON object with a 'commands' list."""
body = _get('/api/commands')
assert 'commands' in body
assert isinstance(body['commands'], list)
assert len(body['commands']) > 0
@requires_agent_modules
def test_commands_endpoint_includes_help():
"""The 'help' command must always be present (it's not cli_only)."""
body = _get('/api/commands')
names = {c['name'] for c in body['commands']}
assert 'help' in names
@requires_agent_modules
def test_commands_endpoint_command_shape():
"""Each command entry has the required fields."""
body = _get('/api/commands')
cmd = next(c for c in body['commands'] if c['name'] == 'help')
required = {
'name', 'description', 'category', 'aliases',
'args_hint', 'subcommands', 'cli_only', 'gateway_only',
}
assert set(cmd.keys()) >= required
assert isinstance(cmd['aliases'], list)
assert isinstance(cmd['subcommands'], list)
assert isinstance(cmd['cli_only'], bool)
assert isinstance(cmd['gateway_only'], bool)
@requires_agent_modules
def test_commands_endpoint_excludes_gateway_only_and_never_expose():
"""gateway_only commands and the _NEVER_EXPOSE set are filtered out."""
body = _get('/api/commands')
names = {c['name'] for c in body['commands']}
# /sethome, /restart, /update are gateway_only; /commands is in _NEVER_EXPOSE
for name in ('sethome', 'restart', 'update', 'commands'):
assert name not in names, f"{name} must be excluded from /api/commands"
@requires_agent_modules
def test_commands_endpoint_keeps_new_with_reset_alias():
"""The 'new' command stays exposed and carries its 'reset' alias."""
body = _get('/api/commands')
new_cmd = next(c for c in body['commands'] if c['name'] == 'new')
assert 'reset' in new_cmd['aliases']
def test_list_commands_returns_empty_for_empty_registry():
"""list_commands(_registry=[]) returns [] -- the same path as when
hermes_cli is missing (the empty-or-missing case)."""
from api.commands import list_commands
assert list_commands(_registry=[]) == []
def test_list_commands_degrades_when_agent_missing(monkeypatch):
"""If hermes_cli.commands is not importable, list_commands() returns []
via the ImportError path. Verified by stubbing sys.modules; test cleanup
is handled by monkeypatch + the fact that we don't reload api.commands."""
import sys
monkeypatch.setitem(sys.modules, 'hermes_cli.commands', None)
# NOTE: we do NOT reload api.commands. The lazy import inside
# list_commands() will re-attempt the import on each call and hit
# the stubbed-None module, raising ImportError, taking the fallback path.
from api.commands import list_commands
assert list_commands() == []

View File

@@ -0,0 +1,595 @@
"""Regression tests for credential_pool provider detection in /api/models."""
import json
import sys
import types
import api.config as config
import api.profiles as profiles
_AMBIENT_SOURCES = {"gh_cli", "gh auth token"}
def _install_fake_hermes_cli(monkeypatch, *, with_load_pool: bool = False, pool_data: dict | None = None):
"""Stub hermes_cli modules so tests are deterministic and offline.
When *with_load_pool* is True, also stubs hermes_cli.credential_pool with a
suppression-aware load_pool() implementation that mirrors upstream behaviour:
entries whose source/label/key_source signals ambient gh-cli auth are filtered out.
"""
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models.list_available_providers = lambda: []
fake_models.provider_model_ids = lambda pid: (
["gpt-oss:20b", "qwen3:30b-a3b"] if pid == "ollama-cloud" else []
)
fake_auth = types.ModuleType("hermes_cli.auth")
fake_auth.get_auth_status = lambda _pid: {}
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
# Always remove the real agent.credential_pool so get_available_models() takes
# the ImportError fallback path and reads from the monkeypatched auth store,
# not the live ~/.hermes/auth.json via the real venv module.
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
monkeypatch.delitem(sys.modules, "agent", raising=False)
if with_load_pool:
_pool_data = pool_data or {}
class _FakeEntry:
"""Minimal PooledCredential stand-in with attribute access (matching the real class)."""
def __init__(self, d):
self.source = d.get("source", "manual")
self.label = d.get("label", "")
self.key_source = d.get("key_source", "")
self.id = d.get("id", "")
class _FakePool:
def __init__(self, entries_list):
self._entries = entries_list
def entries(self):
return self._entries
def _fake_load_pool(pid):
# Return ALL entries without filtering — mirrors the real load_pool()
# which does NOT suppress ambient gh-cli tokens on its own.
# Ambient-source filtering is the webui's responsibility.
raw = _pool_data.get(pid, [])
return _FakePool([_FakeEntry(e) for e in raw])
fake_cp = types.ModuleType("agent.credential_pool")
fake_cp.load_pool = _fake_load_pool
monkeypatch.setitem(sys.modules, "agent.credential_pool", fake_cp)
def _call_get_available_models(monkeypatch, tmp_path, auth_payload, *, with_load_pool: bool = False):
"""Call get_available_models() with auth.json pinned to a temp Hermes home."""
_install_fake_hermes_cli(
monkeypatch,
with_load_pool=with_load_pool,
pool_data=auth_payload.get("credential_pool", {}),
)
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {}
try:
# Pin mtime to avoid reload_config() clobbering our in-memory cfg patch.
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
config.invalidate_models_cache()
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config.invalidate_models_cache()
def _group_by_provider(result):
return {g["provider"]: g["models"] for g in result.get("groups", [])}
def test_ollama_cloud_manual_credential_shows_group(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"ollama-cloud": [
{
"id": "abc123",
"label": "ollama-manual",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://ollama.com/v1",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "Ollama Cloud" in groups, f"Expected Ollama Cloud in {list(groups)}"
model_ids = [m["id"] for m in groups["Ollama Cloud"]]
assert model_ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], model_ids
def test_copilot_gh_cli_only_credential_hidden(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "def456",
"label": "gh auth token",
"source": "gh_cli",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" not in groups, (
"GitHub Copilot should be hidden when only ambient gh auth token is present; "
f"got {list(groups)}"
)
def test_copilot_mixed_credential_pool_remains_visible(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "def456",
"label": "gh auth token",
"source": "gh_cli",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
},
{
"id": "ghi789",
"label": "explicit-copilot",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
},
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
assert "@copilot:gpt-5.4" in model_ids, model_ids
assert "@copilot:claude-opus-4.6" in model_ids, model_ids
def test_copilot_empty_field_entries_are_treated_as_explicit(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "jkl012",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
def test_copilot_oauth_credential_is_visible(monkeypatch, tmp_path):
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "mno345",
"label": "github-oauth",
"source": "oauth",
"auth_type": "oauth",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
# --- load_pool path (suppression-aware) ---
def test_load_pool_copilot_ambient_only_remains_hidden(monkeypatch, tmp_path):
"""load_pool path: copilot with only ambient gh-cli entries is suppressed."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "lp001",
"label": "gh auth token",
"source": "gh_cli",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
groups = _group_by_provider(result)
assert "GitHub Copilot" not in groups, (
"GitHub Copilot must be hidden when load_pool returns no usable entries; "
f"got {list(groups)}"
)
def test_load_pool_copilot_ambient_key_source_only_remains_hidden(monkeypatch, tmp_path):
"""load_pool path: key_source-only ambient markers must also be suppressed."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "lp001b",
"label": "copilot-token",
"source": "manual",
"key_source": "gh auth token",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
groups = _group_by_provider(result)
assert "GitHub Copilot" not in groups, (
"GitHub Copilot must stay hidden when load_pool entries only differ by key_source ambient markers; "
f"got {list(groups)}"
)
def test_load_pool_alias_provider_key_is_resolved(monkeypatch, tmp_path):
"""load_pool path: aliased pool keys should resolve to canonical provider ids."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"google": [
{
"id": "gp001",
"label": "explicit-gemini",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://generativelanguage.googleapis.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
groups = _group_by_provider(result)
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
assert "Google" not in groups, f"Aliased provider key should not render under raw alias name: {list(groups)}"
def test_load_pool_explicit_credential_shows_provider(monkeypatch, tmp_path):
"""load_pool path: provider with at least one explicit entry is visible."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "lp002",
"label": "gh auth token",
"source": "gh_cli",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
},
{
"id": "lp003",
"label": "explicit-pat",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
},
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups, (
f"GitHub Copilot must appear when load_pool has at least one usable entry; got {list(groups)}"
)
# --- _apply_provider_prefix helper ---
def test_apply_provider_prefix_ollama_cloud_non_active():
"""Bare ollama-cloud model ids get @ollama-cloud: prefix when not active."""
from api.config import _apply_provider_prefix
raw = [{"id": "gpt-oss:20b", "label": "gpt-oss:20b"}, {"id": "qwen3:30b-a3b", "label": "qwen3:30b-a3b"}]
result = _apply_provider_prefix(raw, "ollama-cloud", "openai-codex")
ids = [m["id"] for m in result]
assert ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], ids
def test_apply_provider_prefix_copilot_non_active():
"""Bare copilot model ids get @copilot: prefix when not active."""
from api.config import _apply_provider_prefix
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}, {"id": "claude-opus-4.6", "label": "Claude Opus 4.6"}]
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
ids = [m["id"] for m in result]
assert ids == ["@copilot:gpt-5.4", "@copilot:claude-opus-4.6"], ids
def test_apply_provider_prefix_no_double_prefix():
"""Already-prefixed or provider/model ids are not double-prefixed."""
from api.config import _apply_provider_prefix
raw = [
{"id": "@copilot:gpt-5.4", "label": "already prefixed"},
{"id": "openai/gpt-5.4", "label": "slash form"},
{"id": "bare-model", "label": "bare"},
]
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
ids = [m["id"] for m in result]
assert ids == ["@copilot:gpt-5.4", "openai/gpt-5.4", "@copilot:bare-model"], ids
def test_apply_provider_prefix_active_provider_no_prefix():
"""No prefix is added when the provider is already the active one."""
from api.config import _apply_provider_prefix
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}]
result = _apply_provider_prefix(raw, "openai-codex", "openai-codex")
ids = [m["id"] for m in result]
assert ids == ["gpt-5.4"], ids
def test_copilot_mixed_pool_prefixed_models(monkeypatch, tmp_path):
"""Copilot with mixed pool and non-active provider has @copilot: prefixed model ids."""
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"copilot": [
{
"id": "lp010",
"label": "explicit-copilot",
"source": "manual",
"auth_type": "api_key",
"base_url": "https://api.githubcopilot.com",
}
]
},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
assert "GitHub Copilot" in groups
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
assert all(mid.startswith("@copilot:") for mid in model_ids), model_ids
def test_auth_store_active_provider_alias_is_resolved(monkeypatch, tmp_path):
"""active_provider read from auth.json must be alias-normalized.
Regression: previously the alias table was applied only to config.yaml's
active_provider, so an aliased name in auth.json (e.g. 'google') would
not match the canonical pid ('gemini') and the prefixing logic would
add an unwanted '@gemini:' prefix to the active provider's models.
"""
auth_payload = {
"version": 1,
"providers": {},
# Aliased name: 'google' → 'gemini' per _PROVIDER_ALIASES.
"active_provider": "google",
"credential_pool": {},
}
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
groups = _group_by_provider(result)
# Gemini should appear under its canonical display name and its model
# ids should NOT be prefixed (it's the active provider).
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
model_ids = [m["id"] for m in groups["Gemini"]]
assert model_ids, "Gemini group should have models"
assert not any(mid.startswith("@") for mid in model_ids), (
f"Active provider models must not be prefixed; got {model_ids}"
)
def test_ollama_cloud_empty_catalog_skips_group(monkeypatch, tmp_path):
"""When hermes_cli returns no models for ollama-cloud, the group is omitted.
Matches the named-custom and unknown-provider branches: we don't invent a
catalog we can't enumerate. The logger.warning in the except branch keeps
diagnostics available for operators.
"""
_install_fake_hermes_cli(monkeypatch)
# Override the stub to return empty for ollama-cloud.
import sys as _sys
_sys.modules["hermes_cli.models"].provider_model_ids = lambda pid: []
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"ollama-cloud": [
{
"id": "oc-empty",
"label": "ollama-manual",
"source": "manual",
"auth_type": "api_key",
}
]
},
}
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {}
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
try:
result = config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
groups = _group_by_provider(result)
assert "Ollama Cloud" not in groups, (
f"Ollama Cloud group should be skipped when catalog is empty; got {list(groups)}"
)
# --- _format_ollama_label helper ---
def test_format_ollama_label_simple():
from api.config import _format_ollama_label
assert _format_ollama_label("kimi-k2.5") == "Kimi K2.5"
def test_format_ollama_label_with_variant():
from api.config import _format_ollama_label
assert _format_ollama_label("qwen3-vl:235b-instruct") == "Qwen3 VL (235B Instruct)"
def test_format_ollama_label_short_acronym():
from api.config import _format_ollama_label
assert _format_ollama_label("glm-5.1") == "GLM 5.1"
def test_format_ollama_label_gpt_oss_with_size():
from api.config import _format_ollama_label
assert _format_ollama_label("gpt-oss:20b") == "GPT OSS (20B)"
def test_format_ollama_label_empty_string():
from api.config import _format_ollama_label
assert _format_ollama_label("") == ""
def test_format_ollama_label_no_variant():
from api.config import _format_ollama_label
assert _format_ollama_label("nemotron-3-super") == "Nemotron 3 Super"
# --- Fallback-path (ImportError branch) alias resolution ---
def test_fallback_path_resolves_alias_when_load_pool_unavailable(monkeypatch, tmp_path):
"""When agent.credential_pool can't be imported, the manual-inspection
branch must still canonicalize pool keys so aliased names (e.g. 'google')
end up under their canonical provider id ('gemini')."""
_install_fake_hermes_cli(monkeypatch)
# Ensure agent.credential_pool is not importable so the fallback branch runs.
monkeypatch.setitem(sys.modules, "agent.credential_pool", None)
auth_payload = {
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {
"google": [
{
"id": "gp-fallback",
"label": "explicit-gemini",
"source": "manual",
"auth_type": "api_key",
}
]
},
}
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = {}
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
try:
result = config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
groups = _group_by_provider(result)
assert "Gemini" in groups, (
f"Fallback path must resolve 'google' -> 'gemini'; got {list(groups)}"
)
assert "Google" not in groups, (
f"Raw alias name must not leak when fallback path runs; got {list(groups)}"
)

Some files were not shown because too many files have changed in this diff Show More