When running tests without hermes-agent, 24 tests that depend on cron,
skills, approval, or agent backend modules now skip cleanly instead of
failing with 500 errors.
Detection: conftest.py checks if the agent dir exists and if cron.jobs
and tools.skills_tool are importable. When not available, an explicit
list of 24 test names is auto-marked with pytest.mark.skip.
Result:
- Without agent: 400 passed, 24 skipped, 0 failed
- With agent: all 424 tests run normally (skip logic is a no-op)
A warning banner prints at collection time:
"hermes-agent not found — 24 agent-dependent tests will be skipped"
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- routes.py: reject glob wildcards (* ? [ ]) in skill name param to
prevent rglob wildcard injection when serving linked files
- panels.js: replace inline onclick+esc() with data-* attributes and
addEventListener for skill tag removal and linked-file clicks;
esc() is HTML-safe but not JS-safe -- apostrophes in names caused
JS syntax errors and _cronSelectedSkills array corruption
- ui.js: fix _fmtTokens(null/undefined) returning 'null'/'undefined'
by guarding with (!n||n<0) -> '0'; add data-role attribute to msg-row
elements so usage badge correctly targets the last assistant row
instead of the last row regardless of speaker
- tests: rename test_sprint24.py -> test_sprint23.py (wrong sprint #);
add 3 new tests: path traversal rejection, wildcard name rejection,
cron create with skills; strengthen existing tests to assert field
presence explicitly (was using .get(field, 0)==0 which never caught
a missing field)
Track A: Token/cost display
- Read agent usage attrs (session_prompt_tokens, session_completion_tokens,
session_estimated_cost_usd) after run_conversation in streaming.py
- Add input_tokens, output_tokens, estimated_cost fields to Session model
- Include usage in done SSE event payload
- Store usage on S.lastUsage in messages.js done handler
- Render usage badge below last assistant message (input/output/cost)
Track B: Subagent delegation cards
- Add subagent_progress to toolIcon map with shuffle emoji
- Special-case subagent_progress in buildToolCard: "Subagent" label,
strip double emoji from preview, add tool-card-subagent CSS class
- Indented border-left styling for subagent cards
- Clean delegate_task display name
Track C: Skill picker in cron create form
- Add skill search input + tag chips to cron create form HTML
- Skill picker JS in panels.js: search/filter, click-to-add tags,
remove tag chips, pre-fetch skill list on form open
- submitCronCreate sends skills array in POST body
- Skill picker dropdown + tag CSS
Track D: Skill linked files viewer
- Add file query param to /api/skills/content endpoint
- Serve linked files from skill directory with path traversal protection
- Ensure linked_files key always present in skill content response
- Render linked files section below SKILL.md content in preview panel
- openSkillFile function for viewing individual linked files
Track E: Bug fixes and code quality
- Expand Session.__init__ and compact() to readable multi-line format
- Remove inline import json as _j2 inside loop in streaming.py
- Fix tool_calls: capture args from assistant messages, skip unresolved names
- Store args snapshot in persisted tool_calls for reload display
6 new tests. Total: 421 (409 passing).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: _DEFAULT_HERMES_HOME was evaluated at module import time from
os.getenv('HERMES_HOME'). HERMES_HOME is a MUTABLE env var -- init_profile_state()
at server startup calls _set_hermes_home() which writes to os.environ['HERMES_HOME'].
If the sticky active_profile file pointed to e.g. 'webui', HERMES_HOME was set to
~/.hermes/profiles/webui BEFORE api/profiles.py imported. So _DEFAULT_HERMES_HOME
resolved to ~/.hermes/profiles/webui. Then switch_profile('webui') computed:
home = ~/.hermes/profiles/webui / 'profiles' / 'webui'
= ~/.hermes/profiles/webui/profiles/webui -- doesn't exist -> 404 ValueError
Fix: replace the one-liner assignment with _resolve_base_hermes_home() which:
1. Checks HERMES_BASE_HOME env var (explicit override)
2. Checks HERMES_HOME -- but if it looks like a profiles/ subdir (parent.name ==
'profiles'), walks up two levels to the actual base
3. Falls back to Path.home() / '.hermes'
This means the server can start with HERMES_HOME pointing to any profile and
_DEFAULT_HERMES_HOME will still correctly point to ~/.hermes.
Also fix: api() helper in workspace.js was throwing new Error(await res.text())
which surfaced raw JSON to the UI: 'Switch failed: {"error":"Profile X does not exist."}'
Now parses the JSON and extracts j.error so the toast shows clean human-readable text.
Regression tests added in test_sprint23.py:
- test_profile_switch_base_home_not_subdir: static analysis verifying the resolver
- test_api_helper_returns_clean_error_message: verifies api() parses JSON errors
- test_profile_switch_resolve_base_home_logic: verifies the profiles/ subdir detection
Tests: 426 passed, 0 failed.
BUG-1 (critical): api/profiles.py _DEFAULT_HERMES_HOME used Path.home()/.hermes
hardcoded, ignoring the HERMES_HOME env var. conftest.py sets HERMES_HOME to a
test-isolated state dir -- but profiles.py bypassed it and read/wrote real ~/.hermes
during every test run (active_profile file, .env loading). Fixed by reading
os.getenv('HERMES_HOME', ...) at module load time.
BUG-7 (medium): api/workspace.py load_workspaces() fell back to the global
workspaces.json for ALL profiles when their profile-local file didn't exist yet.
New named profiles silently inherited the default profile's workspace list instead
of starting clean. Fixed: the global file fallback now only applies to the default
profile (migration path); named profiles start with a fresh default entry.
BUG-4 (high): test_sessions_list_includes_profile had a vacuous 'if matching:'
guard -- if the session wasn't found the assert was silently skipped and the test
passed. Fixed with hard assert. Also changed to use /api/session?session_id=
directly instead of scanning /api/sessions (which filters out empty Untitled
sessions with 0 messages, causing the test to always see an empty match list).
BUG-5 / test ordering regression: test_profile_switch_returns_default_model_and_workspace
failed with 409 because test_chat_stream_opens_successfully (runs earlier in the
suite) starts a real LLM stream that stays alive in STREAMS. Added a wait loop
(up to 30s) polling /health active_streams before attempting the profile switch.
BUG-8 (low): Removed dead import _profile_default_workspace in switch_profile()
-- was imported but never used (get_last_workspace() already delegates to it).
Also: test_profile_active_endpoint hardcoded assert data['name'] == 'default'
which fails if a prior run left a non-default active_profile on disk. Changed
to assert name is a non-empty string (the endpoint contract), not a specific value.
Tests: 423 passed, 0 failed.
Fix five coherence bugs in profile switching:
1. Model picker ignored profile default (localStorage stale key)
2. Workspace list was global (not profile-scoped)
3. DEFAULT_WORKSPACE was a boot-time singleton
4. Session list showed all profiles (no filtering)
5. switchToProfile() didn't refresh workspaces or sessions
Backend: workspace storage is now profile-local for named profiles,
switch_profile() returns default_model and default_workspace.
Frontend: switchToProfile() clears stale model pref, refreshes
workspace list and session list, sessions.js filters by active profile
with 'Show N from other profiles' toggle.
8 new tests. 400 pass / 23 fail (identical to baseline).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rfind('@keyframes') searched backward from 'send-pop-in' but with both
keyframes on the same CSS line, it landed on mic-pulse instead.
Fix: use find('@keyframes send-pop-in') directly (forward search) via
a shared _extract_keyframe() helper. Same fix applied to both
test_send_pop_in_uses_scale and test_send_pop_in_uses_opacity.
- index.html: btnSend hidden by default (display:none), icon-only (upward
arrow SVG, no text label), title attribute for accessibility
- style.css: new send-btn design — 34px circle, blue fill (#7cb9ff),
subtle glow box-shadow, scale() hover/active for tactile feel,
.send-btn.visible with @keyframes send-pop-in (scale+opacity spring
using cubic-bezier(.34,1.56,.64,1) for a satisfying pop). Mobile
override updated to preserve circle dimensions.
- ui.js: updateSendBtn() — shows button with pop-in animation when
textarea has content OR files are attached and agent is not busy;
hides instantly when content is cleared. Hooked into setBusy() and
renderTray() so button state tracks all content sources correctly.
- boot.js: input event listener calls updateSendBtn() on every keystroke.
- messages.js: autoResize() calls updateSendBtn() so button disappears
immediately after send clears the textarea.
- tests/test_sprint21.py: 33 tests covering HTML structure, CSS design
(circle shape, colors, animations, keyframes), JS logic (updateSendBtn,
setBusy, renderTray, autoResize integration), and regressions
(363 total, all pass).
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
Tracebacks exposed file paths, module names, and potentially secret
values from local variables. Now logged server-side only; clients
receive a generic error message.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three security issues found during review:
1. password_hash exposed via GET /api/settings
load_settings() returned all fields including the stored hash.
Fix: strip password_hash from the response in routes.py.
2. password_hash directly settable via POST /api/settings
'password_hash' was in _SETTINGS_ALLOWED_KEYS, so an attacker
could POST {password_hash: 'X'} to hijack auth without knowing
the current password.
Fix: exclude password_hash from _SETTINGS_ALLOWED_KEYS.
(Use _set_password for the legitimate hash-and-store path.)
3. Security headers missing from /api/auth/login and /api/auth/logout
These endpoints built their responses manually (bypassing j()),
so they omitted X-Content-Type-Options etc.
Fix: call _security_headers() before end_headers() on both.
Tests updated: renamed test to assert key absent (not just None),
added new test verifying direct password_hash POST is blocked.
Auth system (off by default, zero friction for localhost):
- New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt),
signed HMAC session cookies (24h TTL), auth middleware
- Enable via HERMES_WEBUI_PASSWORD env var or Settings panel
- Minimal dark-themed login page at /login (self-contained HTML)
- POST /api/auth/login, /api/auth/logout, GET /api/auth/status
- Settings panel: "Access Password" field + "Sign Out" button
- password_hash added to settings.json (null = auth disabled)
Security hardening:
- Security headers on all responses: X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: same-origin
- POST body size limit: 20MB cap in read_body() to prevent DoS
Closes#23. 9 new tests. Total: 304 passed, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes#26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
Session projects: named groups for organizing sessions. Project filter
bar with chips between search and session list. Create/rename/delete
projects, assign sessions via folder icon dropdown. Stored in
projects.json, project_id on Session model. 5 new API endpoints.
Code block copy button: every code block gets a Copy button in the
language header (or top-right for plain blocks). Clipboard API with
"Copied!" feedback.
Tool card expand/collapse: messages with 2+ tool cards get an
"Expand all / Collapse all" toggle above the card group.
13 new tests (237 total), all passing. No regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>