* 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>
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>
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
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)
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>
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>
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.
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.
- 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
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.
* 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>
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>
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>
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>
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>
* 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>
* 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>