Commit Graph

62 Commits

Author SHA1 Message Date
Nathan Esquenazi
fffdc34fdb Merge pull request #83 from nesquena/feat/context-usage-indicator
feat: context usage indicator in composer footer
2026-04-04 14:26:23 -07:00
Nathan Esquenazi
c1db709ef3 fix: model-aware context window estimation instead of hardcoded 128k
Agent review: hardcoded 128000 is wrong for Claude (200k), Gemini (1M),
and smaller models (8k-32k). Added a lookup table keyed by model name
substring covering major families with 128k fallback. TODO comment
for fetching exact values from server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:26:13 -07:00
Nathan Esquenazi
4b55f08961 Merge pull request #82 from nesquena/feat/workspace-git-detection
feat: workspace git detection with branch/status badge
2026-04-04 14:25:17 -07:00
Nathan Esquenazi
b60c4fd498 Merge pull request #80 from nesquena/feat/collapsible-date-groups
feat: collapsible date groups in session sidebar
2026-04-04 14:24:13 -07:00
Nathan Esquenazi
a2243f4c4f fix: remove unused ordered variable, add hoisting note
Agent review feedback: ordered array was constructed but never iterated
(the new code uses groups[] instead). Removed the dead variable.
Added comment noting function hoisting for _renderOneSession.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:24:04 -07:00
Nathan Esquenazi
516062bd41 feat: context usage indicator in composer footer
Shows a compact bar + label in the composer footer after the first
response, displaying input/output token counts, context window fill
percentage, and estimated cost. Bar turns yellow >50% and red >75%.

Updates on every response completion via the existing usage data from
the done SSE event. Hidden until first response (no usage data yet).

Inspired by PR #75 (@MartinNielsenDev).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:11:28 -07:00
Nathan Esquenazi
d8e6079a2c feat: workspace git detection with branch/status badge
When the workspace root is a git repo, a badge in the panel header
shows the current branch name, dirty file count, and ahead/behind
status. Updates on every root directory load.

Backend:
- git_info_for_workspace() in api/workspace.py runs lightweight git
  commands (rev-parse, status --porcelain, rev-list) with 3s timeout
- New GET /api/git-info endpoint returns branch, dirty count, modified,
  untracked, ahead, behind

Frontend:
- _refreshGitBadge() in workspace.js fetches git info on root load
- Git badge element in panel header shows branch + status
- Badge turns gold when workspace has uncommitted changes

Inspired by PR #75 (@MartinNielsenDev).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:08:25 -07:00
Nathan Esquenazi
c0769c50a2 perf: rAF-throttled token streaming for smoother rendering
Token events from SSE now buffer and render at most once per animation
frame via requestAnimationFrame, instead of calling renderMd() and
writing to the DOM on every single token event.

Before: ~100 tokens/sec = ~100 DOM writes/sec (causes jank on heavy output)
After:  ~100 tokens/sec batched to ~60 DOM writes/sec (one per frame)

The change is a small wrapper: _scheduleRender() gates rendering behind
a rAF flag so multiple tokens arriving between frames are batched into
a single renderMd() + scrollIfPinned() call.

Inspired by PR #75 (@MartinNielsenDev).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:05:51 -07:00
Nathan Esquenazi
42590fceb3 feat: collapsible date groups in session sidebar
Date group headers (Pinned, Today, Yesterday, Earlier) are now clickable
to collapse/expand their session lists. Collapsed state persists to
localStorage across page reloads.

- Refactored renderSessionListFromCache to group sessions first, then
  render groups with collapsible wrappers
- Extracted _renderOneSession() helper for reuse within group bodies
- Chevron indicator rotates -90deg when collapsed
- Pinned group header keeps its gold color

Inspired by PR #75 (@MartinNielsenDev).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:05:00 -07:00
nesquena-hermes
1a773597ac docs: v0.31 release -- UI polish + deployment hardening (#74)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 11:30:51 -07:00
nesquena-hermes
2b92fe0aa9 fix: overlay z-index clipping, CLI badge hover, workspace dropdown spacing (#71)
Fix five stacking/overflow bugs in static/style.css (no JS changes):

1. Profile dropdown overlaps chat messages
   .topbar lacked a stacking context -- added position:relative;z-index:10
   so the dropdown (z-index:200 child) always paints above .messages (z-index:0)

2. Workspace dropdown clipped by sidebar overflow:hidden
   .sidebar overflow:hidden was swallowing the upward-opening ws-dropdown.
   Changed to overflow:visible -- scroll is already on .session-list, not .sidebar.
   Added position:relative;z-index:10;overflow:visible to .sidebar-bottom.

3. Slash-command dropdown could render behind tool cards
   .composer-wrap had position:relative but no z-index.
   Added z-index:10 so cmd-dropdown always sits above .messages (z-index:0).

4. Skill picker dropdown clipped inside Settings modal
   .settings-panel had overflow-y:auto which clipped the absolute-positioned
   skill picker. Changed to overflow:visible + display:flex;flex-direction:column,
   moved overflow-y:auto to .settings-body, raised skill-picker-dropdown to z-index:1100.

5. CLI session badge blocks action buttons on hover
   Added .session-item.cli-session:hover::after { display:none } so the gold
   'cli' label hides on hover, making archive/delete/pin fully reachable.

6. Workspace dropdown name+path crowded on same line
   .ws-opt was a plain block with inline spans. Added flex-direction:column;gap:4px
   and display:block to each child so name and path stack cleanly on separate lines.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 09:34:43 -07:00
Nathan Esquenazi
6a4b20f3f2 fix: three UI glitches -- image error, CLI badge overlap, dropdown z-index
1. Image preview onerror fires on clearPreview (#68)
   clearPreview() set previewImg.src='' which triggered the stale onerror
   handler, showing 'Could not load image' on every refresh/message.
   Fix: null out onerror before clearing src.

2. CLI session badge covers delete button (#69)
   The ::after 'cli' label occupied the same space as the hover-revealed
   .session-actions overlay, making delete unreachable.
   Fix: add padding-right to .cli-session, use margin-left:auto to push
   badge right, add pointer-events:none so clicks pass through.

3. Tool cards visible through profile dropdown
   The .messages container had no stacking context, so tool cards could
   render above the profile dropdown (z-index:200).
   Fix: add position:relative;z-index:0 to .messages to establish a
   stacking context that keeps all children below overlays.

Closes #68, closes #69

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 09:09:31 -07:00
Nathan Esquenazi
d3b693524f docs: v0.30.1 release — CLI bridge fixes, README update
CHANGELOG: add v0.30.1 entry covering PRs #57-#61 (CLI session bridge
fixes: sidebar rendering, profile-aware state.db path, silent SQL error,
show/hide toggle in Settings.

README: add CLI session bridge, token/cost display, subagent cards,
/usage command, skills linked files, show CLI sessions toggle.

Version label: v0.30 -> v0.30.1 in index.html, SPRINTS, CHANGELOG footer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)
2026-04-03 21:11:52 -07:00
nesquena-hermes
66f95e08c2 feat: 'Show CLI sessions' toggle in Settings (#61)
Adds a server-side boolean setting (default: false) that controls whether
CLI sessions from state.db appear in the sidebar. Off by default so the
sidebar is clean until the user explicitly opts in.

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

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-03 21:06:23 -07:00
nesquena-hermes
15fde033c3 fix: wire up CLI session display in sidebar (3 frontend gaps) (#58)
The backend CLI session bridge (PR #56) was complete but the frontend
never connected to it:

1. css class never applied -- el.className never included 'cli-session'
   so the gold border and 'cli' badge CSS was dead code. Fixed: append
   ' cli-session' when s.is_cli_session is true.

2. import never triggered -- click handler always called loadSession()
   directly, never POST /api/session/import_cli. Fixed: for CLI sessions,
   call import_cli first (idempotent -- safe to call on every click),
   then fall through to loadSession() which now finds the imported copy.

3. profile filter silently hid CLI sessions -- filter required
   s.profile === S.activeProfile, but CLI sessions may have profile=null
   if the SQLite DB has no profile column. Fixed: CLI sessions always
   pass the filter (s.is_cli_session || s.profile === S.activeProfile).

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-03 20:55:29 -07:00
Nathan Esquenazi
f8ea02c14d merge: resolve conflicts with master (v0.29), bump to v0.30
Resolved CHANGELOG.md and SPRINTS.md conflicts: master added v0.29
(Sprint 23: Agentic Transparency), CLI bridge becomes v0.30.
Updated all version references to v0.30.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:42:11 -07:00
Nathan Esquenazi
122fe955b6 docs: v0.29 release notes for CLI session bridge, version bump
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:39:27 -07:00
Thad Reber
cabda6b77a feat: CLI session bridge - read CLI sessions from agent SQLite store
Read CLI sessions from the agent's state.db and surface them in the
WebUI sidebar alongside local sessions, with read-only display and
import-on-click to avoid data duplication.

Key changes:
- get_cli_sessions(): reads sessions list via parameterized SQL,
  wrapped in sqlite3 context manager (no connection leaks)
- get_cli_session_messages(): reads messages for a CLI session
  via parameterized SQL, also context-managed
- GET /api/sessions: merges WebUI + CLI sessions with dedup
  (WebUI takes priority on same session_id)
- GET /api/session: falls back to CLI store if not a WebUI session
- POST /api/session/import_cli: imports a CLI session into the
  WebUI store (idempotent, no duplicates on re-import)
- Imported sessions use get_last_workspace() for the workspace field
  (not a hardcoded string) and carry the active profile tag
- CSS: .cli-session with ::after 'cli' indicator (no theme changes)

Fixes review feedback:
- SQLite connections use 'with' context managers (no leaks)
- Workspace uses real path via get_last_workspace()
- Profile awareness via api.profiles.get_active_profile_name()
- Parameterized SQL queries throughout (no injection risk)
- Graceful fallback when sqlite3 or state.db is missing
2026-04-03 19:54:54 -07:00
Nathan Esquenazi
2fb2ddeaaa feat: token usage toggle (setting + /usage command) + timestamp fixes
Token usage display:
- Add 'show_token_usage' boolean to settings (default: false, off by default)
- Settings panel: checkbox 'Show token usage after responses'
- /usage slash command: instant toggle with toast feedback, persists to
  server, updates checkbox if settings panel is open, re-renders messages
- Boot: load show_token_usage alongside send_key on startup
- ui.js: gate usage badge on window._showTokenUsage flag

Timestamps:
- streaming.py: stamp 'timestamp' on every message that lacks one at
  conversation completion; old messages (no timestamp field) now get a
  wall-clock time the first time they're touched by a new turn
- messages.js: stamp _ts on the last assistant message at done-event time
  so the time shows immediately on the current turn before next reload
- Timestamps already render in the UI (Sprint 14): faint time on each
  role header line, full opacity on hover, full date in title tooltip
2026-04-03 19:11:36 -07:00
Nathan Esquenazi
b1d687ba22 feat: persist workspace tree expanded state across refreshes
Store expanded directory paths in localStorage keyed by workspace path
(key: 'hermes-webui-expanded:{workspacePath}'). On root load (loadDir('.')),
restore the saved set for the current workspace and pre-fetch dir contents
for any restored expanded directories so the tree renders fully on first
paint without requiring a second click to expand.

Saves on every expand/collapse toggle. Switching workspaces automatically
picks up that workspace's own saved state. Per-workspace (not per-session)
so the same tree state is shared across sessions using the same workspace,
which is the natural expectation.
2026-04-03 19:11:36 -07:00
Nathan Esquenazi
c1dcd73502 fix: security, correctness, and test hardening from review
- 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)
2026-04-03 19:11:36 -07:00
Nathan Esquenazi
df06c1cdca feat: Sprint 23 — agentic transparency + polish
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>
2026-04-03 18:33:49 -07:00
Nathan Esquenazi
94b080fa1e docs: v0.27 release notes, version bump for profile creation fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:00:46 -07:00
Nathan Esquenazi
5c9edfc7bf docs: v0.26 release notes, remove planning artifact, update versions
- Add v0.26 CHANGELOG entry (10 post-Sprint-23 fixes)
- Remove SPRINT_23_PLAN.md (planning artifact, not runtime docs)
- Bump version label to v0.26 in index.html
- Update SPRINTS header and footer to v0.26 / 426 tests
- Update CHANGELOG footer to v0.26 / 426 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:44:06 -07:00
Nathan Esquenazi
e61382ef71 fix: pass fallback_model to AIAgent; show rate-limit error inline instead of 'Connection lost'
Two fixes for Camanji rate limit UX:

1. api/streaming.py — pass fallback_model from profile config to AIAgent
   The agent already supports fallback_model (a dict with provider/model/base_url)
   for automatic rate-limit recovery, but streaming.py never read it from config
   or passed it to AIAgent.  Now reads get_config().get('fallback_model') at
   call time (not module-level snapshot) and passes it through.
   Also reads platform_toolsets.cli from the active profile's config at call
   time so profiles with custom toolset lists use the right tools.

   Camanji has fallback_model: {provider: openrouter, model: anthropic/claude-sonnet-4.6}
   so hitting the direct-Anthropic rate limit will now automatically retry via
   OpenRouter before giving up.

2. api/streaming.py + static/messages.js — show error inline, not 'Connection lost'
   Previously: agent threw -> put('error', msg) -> SSE connection closed ->
   browser's network-level 'error' event fired -> generic 'Connection lost'.
   The actual error message was invisible to the user.

   Fix: renamed server-side error event to 'apperror' (distinct from the SSE
   spec's network error event).  Added source.addEventListener('apperror', ...)
   in messages.js that renders the error as a styled assistant message:
     ⏱️ Rate limit reached: <full message>
     *Rate limit reached. Fallback model exhausted. Try again in a moment.*
   Also added source.addEventListener('warning', ...) for non-fatal notices
   (future use: fallback-activated status bar update).

Tests: 426 passed, 0 failed.
2026-04-03 20:34:52 +00:00
Nathan Esquenazi
da43a6a09a fix: switching profiles mid-conversation starts a new session instead of cross-tagging
A session with messages belongs to the profile it was created under. Switching
profiles while a conversation is in progress should not retag that session or
update its workspace/model in place — that would corrupt the session's context.

New behavior:
- Session has NO messages (empty): profile switch updates it in place (model,
  workspace). Works exactly as before — nothing was started yet.
- Session HAS messages (in progress): profile switch calls newSession() to
  start a fresh session tagged to the new profile. The old session is left
  untouched. Toast: 'Switched to profile: X — new conversation started'.
- Agent busy: blocked as before, no change.

Also: S._profileDefaultWorkspace is now consumed (set to null) inside
newSession() after the first use, so it doesn't keep forcing the same
workspace on every subsequent new session after a switch.
2026-04-03 20:27:50 +00:00
Nathan Esquenazi
c71439d8ab fix: model picker correctly updates on profile switch without flicker or raw injection
Root cause: three interacting bugs caused the model picker to show the wrong
model or flicker after a profile switch.

Bug 1 — syncTopbar() fought switchToProfile().
After switchToProfile() set the picker to the profile's model, syncTopbar()
was called (via renderSessionList -> loadSession, then explicitly at the end)
and overwrote it with S.session.model -- the old session's model.
Fix: added S._pendingProfileModel flag. switchToProfile() sets it;
syncTopbar() checks it first, applies the override, then clears it.
S.session.model is also updated to the resolved value so subsequent
syncTopbar() calls are consistent.

Bug 2 — Raw option injected at top of list for mismatched model IDs.
Profile configs store model IDs like 'claude-sonnet-4-6' (hermes-agent
format: hyphens, no namespace prefix) but the dropdown has
'anthropic/claude-sonnet-4.6' (OpenRouter format: dots, with prefix).
The old code did sel.value = id, found no match, then injected a new
<option> at the top of the list -- creating a lowercase duplicate that
didn't match any real provider group entry.
Fix: _findModelInDropdown() normalises both sides (strip prefix, hyphens->dots,
lowercase) and finds the best matching existing option. No new options are ever
injected for profile switching.

Bug 3 — populateModelDropdown() injected raw option on cold load.
Same issue: if default_model from /api/models didn't exactly match a dropdown
value, an extra option was added. Fixed by using _applyModelToDropdown()
which only selects existing options.

New helpers in ui.js:
  _findModelInDropdown(modelId, sel) -- smart fuzzy match, returns matched value
  _applyModelToDropdown(modelId, sel) -- sets picker, returns resolved value

Tests: 426 passed, 0 failed.
2026-04-03 20:10:47 +00:00
Nathan Esquenazi
f75e17c912 fix: legacy sessions (profile=null) leak into all profiles' session lists
Root cause: sessions created before Sprint 22 have no profile tag (profile=None).
The client filter was '!s.profile || s.profile === S.activeProfile' -- the
'!s.profile' guard made ALL 33 legacy sessions visible under every profile,
so switching to Camanji still showed the entire default session history.

Fix:
- api/models.py all_sessions(): backfill profile='default' on sessions with
  no profile tag before returning. This is in-memory only (no disk writes) --
  legacy sessions just get attributed to the default profile at read time.
  Applied to both the index-path and the full-scan fallback path.
- static/sessions.js: tighten the client filter to s.profile === S.activeProfile
  (remove the '!s.profile' escape hatch -- now redundant since server fills it).
  Every session now has an explicit profile, so the filter is precise.

Result: switching to Camanji shows only Camanji sessions. Default profile shows
legacy + default-tagged sessions. 'All profiles' toggle still shows everything.
S.activeProfile defaults to 'default' in the S object so first render is safe.

Tests: 426 passed, 0 failed.
2026-04-03 19:50:08 +00:00
Nathan Esquenazi
3d8cf85ef2 fix: profile default workspace reads terminal.cwd; dropdown opens upward
1. _profile_default_workspace() now checks terminal.cwd
   Profile config.yaml files don't have a 'workspace' or 'default_workspace' key
   — they store the working directory as terminal.cwd (the hermes-agent CLI
   setting). Added it as the third fallback after 'workspace' and
   'default_workspace', so switching to camanji correctly resolves
   ~/Camanji, webui resolves ~/webui-mvp, etc.

2. Workspace dropdown opens upward (bottom: calc(100% + 4px))
   The dropdown is now anchored at the bottom of the sidebar. Opening it
   downward (top: 100%) caused it to clip off screen. Flipped to open upward
   with an upward shadow so it expands into the session list area instead.

Tests: 426 passed, 0 failed.
2026-04-03 19:47:38 +00:00
Nathan Esquenazi
d4ab01c152 fix: workspace updates on profile switch; remove redundant topbar workspace chip
Two changes:

1. Workspace updates correctly on profile switch
   switchToProfile() now applies data.default_workspace from the switch
   response to the current session via /api/session/update, updates
   S.session.workspace in-memory, and stores S._profileDefaultWorkspace
   so the next new session also inherits the profile's workspace.
   newSession() in sessions.js picks up S._profileDefaultWorkspace when
   creating a new session after a profile switch.

2. Workspace chip removed from topbar
   The workspace was shown in two places: the topbar chip (wsChip) AND
   the sidebar bottom display (sidebarWsDisplay with name + full path).
   The topbar chip was redundant, cluttered the topbar, and pushed other
   chips (profile, model, clear, settings) off screen.
   Removed wsChip from the topbar entirely. The sidebar display is now
   the sole workspace UI, consistent and unambiguous.
   Moved wsDropdown to live inside the sidebar position:relative wrapper
   so it opens downward from sidebarWsDisplay. Updated the click-outside
   listener to close on clicks outside sidebarWsDisplay/wsDropdown.
   Removed stale wsChip update code from syncTopbar() in ui.js.

Tests: 426 passed, 0 failed.
2026-04-03 19:38:33 +00:00
Nathan Esquenazi
c778c1eb0c fix: profile switch fails with 'does not exist' when server starts on non-default profile
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.
2026-04-03 19:29:24 +00:00
Nathan Esquenazi
3520fa5643 feat: Sprint 23 -- profile/workspace/model coherence
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>
2026-04-03 11:46:15 -07:00
Nathan Esquenazi
571a5a40f1 fix(review): 3 issues found in agent review of PR #41
BUG-3 (high): /api/profile/delete missing RuntimeError catch. When
deleting the active profile while an agent was running, delete_profile_api()
called switch_profile('default') which raises RuntimeError('Cannot switch
profiles while agent is running'). This propagated to the 500 handler
giving the user 'Internal server error' with no context. Added the same
except RuntimeError -> 409 pattern that /api/profile/switch already uses.

INFO-1 (defense-in-depth): /api/profile/create had no server-side name
validation before delegating to hermes_cli.validate_profile_name. Added
server-side ^[a-z0-9][a-z0-9_-]{0,63}$ check, consistent with client-side
regex in submitProfileCreate(). Prevents path-traversal-ish names from
reaching hermes_cli even if the client-side guard is bypassed.

INFO-2 (defense-in-depth): clone_from parameter was passed directly to
hermes_cli with no validation. Applied the same name regex check to
clone_from before delegating.

BUG-11 (low): toggleProfileDropdown() and toggleWsDropdown() could both
be open simultaneously. Added cross-dropdown close calls: opening the
profile dropdown now closes the workspace dropdown, and vice versa.

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:50:21 -07:00
Nathan Esquenazi
a92c251ef8 docs: Sprint 21 release notes, version v0.23, Docker localhost binding
- CHANGELOG: add v0.23 Sprint 21 entry (mobile + Docker)
- SPRINTS: Sprint 21 marked COMPLETED, footer updated
- index.html: version label v0.22 -> v0.23
- docker-compose.yml: bind to 127.0.0.1 by default (SEC-1 fix)
- README: add security note about Docker port binding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:28:47 -07:00
Nathan Esquenazi
574cd2cf70 fix(review): 5 issues found in agent review of PR #40
BUG-1 (critical): CSS cascade — .sidebar{position:relative} and
.rightpanel{position:relative} at line 528/530 appeared after the
@media(max-width:640px) block and silently overrode the position:fixed
overlay behavior needed for the mobile slide-in. Wrapped both in
@media(min-width:641px) so they only apply on desktop.

BUG-2 (medium): mobileSwitchPanel() in boot.js always reopened the
sidebar overlay after closing it, with a stale comment saying 'close
after a moment' but no actual auto-close. For the 'chat' panel, the
content lives in the main area — reopening the sidebar obstructs it.
Fixed: only open sidebar for non-chat panels; chat tap closes sidebar.

BUG-3 (medium): Dockerfile was missing 'pip install -r requirements.txt'.
pyyaml (required by api/config.py) is not in the python:3.12-slim base
image — the container would fail at startup with ImportError.

SEC-2 (medium): No .dockerignore — COPY . /app included .git/, tests/,
and .env* in every image. Added .dockerignore excluding these.

NIT-3: docker-compose.yml used ${HERMES_HOME:-~/.hermes} but Docker
Compose does not shell-expand ~ in default values. Changed to
${HERMES_HOME:-${HOME}/.hermes}.

Tests: 415 passed, 0 failed (same as pre-fix).
2026-04-03 17:21:42 +00:00
Nathan Esquenazi
d278563e00 feat: Sprint 21 — mobile responsive layout + Docker support
Mobile responsive (Issue #21):
- Hamburger sidebar: slide-in overlay on mobile (<640px) with backdrop.
  Tap hamburger in topbar to open, tap outside to close. Full session
  list, project chips, all panel content accessible.
- Bottom navigation bar: 5-tab fixed bar (Chat, Tasks, Skills, Memory,
  Spaces) replaces sidebar nav tabs on mobile. iOS-style layout.
  Tapping a tab opens the sidebar overlay with that panel active.
- Right panel slide-over: Files button in topbar chips opens workspace
  panel as a slide-over from the right on mobile/tablet.
- Touch targets: all interactive elements get min 44x44px touch areas.
  Session items, approval buttons, composer buttons all sized for fingers.
- Composer positioned above bottom nav bar with proper spacing.
- Sidebar nav tabs and bottom section hidden on mobile (replaced by
  bottom nav + topbar chips).
- Clicking a session auto-closes the sidebar overlay.
- Desktop layout completely unchanged — all mobile elements are
  display:none by default, only shown inside @media(max-width:640px).

Docker (Issue #7):
- Dockerfile: python:3.12-slim, HERMES_WEBUI_HOST=0.0.0.0, port 8787.
- docker-compose.yml: named volume for state persistence, optional
  ~/.hermes mount for agent features, password env var documented.
- README: Docker quick start section with compose and manual commands.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:09:36 -07:00
Nathan Esquenazi
dcb21dfd37 feat: polish send button — hidden until content, icon-circle, pop-in animation
- 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).
2026-04-03 07:20:16 -07:00
Nathan Esquenazi
df3de7a543 docs: Sprint 20 release notes, version v0.22, SPRINTS update
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 07:19:26 -07:00
Nathan Esquenazi
46fdf3513f fix: mic appends to existing textarea text instead of replacing it
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).
2026-04-03 14:13:29 +00:00
Nathan Esquenazi
efb7293ae8 feat: add voice input mic button via Web Speech API
- 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).
2026-04-03 14:04:03 +00:00
Nathan Esquenazi
56526ce502 chore: update UI version to v0.21, CHANGELOG footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 06:22:29 -07:00
Nathan Esquenazi
e0a1ab8e03 fix(auth): blank password field no longer clears auth; add Disable Auth button
The previous logic treated a blank password field as intent to clear auth,
which meant saving any other setting (model, send key, etc.) would silently
disable password protection.

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

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

Server: no logic changes needed; _clear_password handling in save_settings()
is now only triggered by the explicit Disable Auth action.
2026-04-03 06:21:04 -07:00
Nathan Esquenazi
b8b62722ec feat: Sprint 19 — password auth, security headers, login page
Auth system (off by default, zero friction for localhost):
- New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt),
  signed HMAC session cookies (24h TTL), auth middleware
- Enable via HERMES_WEBUI_PASSWORD env var or Settings panel
- Minimal dark-themed login page at /login (self-contained HTML)
- POST /api/auth/login, /api/auth/logout, GET /api/auth/status
- Settings panel: "Access Password" field + "Sign Out" button
- password_hash added to settings.json (null = auth disabled)

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:53:26 -07:00
Nathan Esquenazi
d0aef93372 fix: apply review fixes, update version to v0.20, add Sprint 18 changelog
- Fix stale tree cache: clear _dirCache and _expandedDirs on root nav
- Fix clearPreview: prompt before discarding unsaved preview edits
- Update UI version label from v0.17.1 to v0.20
- Add Sprint 18 entry to CHANGELOG.md
- Update SPRINTS.md current state to v0.20

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:02:30 -07:00
Nathan Esquenazi
67324cc3bc feat: Sprint 18 — file preview auto-close, thinking display, workspace tree
- File preview auto-close: clearPreview() extracted as named function
  and called from loadDir(). Navigating directories (breadcrumbs, up
  button, folder clicks) now automatically closes the right panel
  file preview instead of leaving stale content visible.

- Thinking/reasoning display: assistant messages with structured content
  arrays containing type=thinking or type=reasoning blocks now render
  as collapsible gold-themed cards above the response text. Collapsed
  by default, click header to expand. Works with Claude extended thinking
  and o3 reasoning tokens when preserved in the message array.

- Workspace tree view (Issue #22): directories expand/collapse in-place
  with toggle arrows. Single-click toggles, double-click navigates
  (breadcrumb view). Subdirectory contents fetched lazily and cached.
  Indentation shows nesting depth. Empty directories show "(empty)".
  S._expandedDirs tracks open state, S._dirCache caches fetched entries.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 04:13:38 -07:00
Hermes
0be7ccde4c feat: safe HTML rendering in AI responses + active session gold style + Sprint 16 tests
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 &lt;strong&gt; 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.
2026-04-03 00:27:43 +00:00
Nathan Esquenazi
d2bcd2b2f7 feat: Sprint 16 — session sidebar visual polish
- Action buttons overlay: wrap pin/move/archive/dup/trash in a
  .session-actions container with position:absolute. Titles now use
  full available width. Actions appear on hover with gradient fade
  from the right edge. Overlay auto-hides during inline rename.

- SVG line icons: replace all emoji HTML entities with monochrome
  SVGs that inherit currentColor. Consistent across all platforms.

- Pin indicator: small gold star rendered inline only when pinned.
  Unpinned sessions get full title width (zero space reservation).

- Project border: sessions assigned to a project show a colored
  left border matching the project color, replacing the old
  always-visible blue folder button.

Fixes both BUGS.md items (title truncation + sticky folder button).
Tests: 214 passed, 23 pre-existing failures, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:49:24 -07:00
Nathan Esquenazi
e59eb8bb5b fix: project picker clipped, full-screen width bug, New Project shortcut
Five fixes to the Sprint 15 Move to Project picker:

1. CRITICAL: Picker was invisible (overflow:hidden clipping)
   Appended to document.body + positioned with fixed/getBoundingClientRect
   instead of inside .session-item (overflow:hidden). Flips above button
   when near bottom of viewport.

2. CRITICAL: Picker stretched full screen width
   position:fixed removed the containing block width constraint. Added
   max-width:220px; width:max-content to .project-picker.

3. UX: No way to create a project from the picker
   Added '+ New project': creates project and moves session in one click.

4. UX: Feature was undiscoverable
   Folder button shows persistently (blue, 60% opacity) when session
   has a project.

5. Minor: Event listener leak
   removeEventListener was missing from picker item onclick handlers.

Tests: 237 passed (7 pre-existing failures from unrelated logger bug).
2026-04-02 18:18:20 +00:00