Compare commits

..

12 Commits
v0.25 ... v0.26

Author SHA1 Message Date
Nathan Esquenazi
b03ddf78c9 Merge pull request #46 from nesquena/fix/profile-switch-default-home
fix: Profile system polish — 10 post-Sprint-23 fixes (v0.26)
2026-04-03 13:44:16 -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
733957cea1 docs: add Sprint 23 planning document (profile/workspace/model coherence spec) 2026-04-03 20:37:48 +00: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
4eae6c98f9 fix: cross-provider model pick causes Connection lost on non-OpenRouter profiles
Root cause: resolve_model_provider() had a branch:
  if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
      return bare, prefix, None

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

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

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

Tests: 426 passed, 0 failed.
2026-04-03 20:23:25 +00:00
Nathan Esquenazi
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
ad755e49e5 fix: workspace isolation, session filtering, and clean migration path
Three interrelated fixes:

1. api/workspace.py — clean workspace isolation with auto-migration
   _clean_workspace_list(): sanitizes any workspace list by:
   - Removing test artifacts (webui-mvp-test, test-workspace paths)
   - Removing paths that no longer exist on disk
   - Removing cross-profile leaks (paths under ~/.hermes/profiles/*)
   - Renaming 'default' workspace label to 'Home' (avoids confusion
     with the 'default' profile name)

   _migrate_global_workspaces(): one-time migration for upgrading users.
   Reads the legacy global workspaces.json, runs _clean_workspace_list,
   rewrites it cleaned. This runs automatically on first load after upgrade
   for the default profile only.

   load_workspaces(): now cleans every read and persists cleaned version
   if anything changed. Named profiles always start fresh (no global leak).
   Empty results fall back to 'Home' entry pointing at profile's workspace.
   Default label for auto-generated single-entry lists is 'Home', not 'default'.

2. api/models.py — legacy session profile backfill (already committed,
   this commit adds the sessions.js filter tightening counterpart)

3. static/sessions.js — strict profile filter
   Removed the '!s.profile' escape hatch from the profile filter.
   Server now backfills profile='default' on legacy sessions, so every
   session has an explicit tag. Filter is now exact:
     s.profile === S.activeProfile
   Named profiles see zero legacy clutter. Default profile sees its own
   sessions. 'All profiles' toggle still shows everything.

Migration story for users pulling this update:
- Existing sessions (profile=null) -> attributed to 'default' at read time
- Global workspaces.json -> cleaned of test artifacts and cross-profile paths
  on first server start after upgrade
- Named profile workspace files -> cleaned on first read, persisted clean
- No manual intervention needed

Tests: 426 passed, 0 failed.
2026-04-03 20:01:12 +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
15 changed files with 480 additions and 90 deletions

View File

@@ -5,6 +5,54 @@
---
## [v0.26] Profile System Polish -- 10 Post-Sprint-23 Fixes
*April 3, 2026 | 426 tests*
### Bug Fixes
- **Profile switch base dir bug.** When `HERMES_HOME` was mutated to a
`profiles/` subdir at startup, `switch_profile()` doubled the path
(e.g. `~/.hermes/profiles/X/profiles/X`). New `_resolve_base_hermes_home()`
detects profile subdirs and walks up to the actual base.
- **Cross-provider model routing.** Picking a model from a different provider
than the config's default now routes through OpenRouter instead of trying
a direct API call to a provider whose key may not exist.
- **Legacy sessions missing profile tag.** `all_sessions()` now backfills
`profile='default'` for pre-Sprint-22 sessions so the profile filter works.
- **Workspace list cleanup.** Stale paths, test artifacts, and cross-profile
entries are now cleaned on load. Legacy global workspace file migrated
once for the default profile.
- **API error messages.** `api()` helper now parses JSON error bodies and
surfaces the human-readable message instead of raw JSON.
- **Workspace dropdown moved to sidebar.** The workspace picker now opens
upward from the sidebar bottom instead of clipping behind the topbar.
### Features
- **Rate limit error display.** Rate limit errors (429) now show a distinct
card with a rate limit icon and hint, instead of the generic error message.
- **SSE `apperror`/`warning` events.** Server can send typed error events
that the frontend handles with appropriate UX (rate limit card, fallback
notice, etc.).
- **Smart model resolver.** `_findModelInDropdown()` handles name mismatches
between config model IDs and dropdown values (e.g. `claude-sonnet-4-6` vs
`anthropic/claude-sonnet-4.6`).
- **Profile switch starts new session.** When the current session has messages,
switching profiles automatically starts a fresh session to prevent
cross-profile tagging.
- **Per-profile toolsets.** Agent now reads `platform_toolsets.cli` from the
active profile's config at call time, not the boot-time snapshot.
- **Per-profile fallback model.** `fallback_model` config is read from the
active profile and passed to AIAgent.
### Architecture
- `api/profiles.py`: `_resolve_base_hermes_home()` replaces naive env var read.
- `api/workspace.py`: `_clean_workspace_list()`, `_migrate_global_workspaces()`.
- `api/streaming.py`: Per-profile toolsets and fallback model at call time.
- `api/models.py`: `all_sessions()` backfills `profile='default'`.
- `static/ui.js`: `_findModelInDropdown()`, `_applyModelToDropdown()`.
- `static/messages.js`: `apperror` and `warning` SSE event handlers.
---
## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence
*April 3, 2026 | 423 tests*
@@ -829,4 +877,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
---
*Last updated: v0.25, April 3, 2026 | Tests: 423*
*Last updated: v0.26, April 3, 2026 | Tests: 426*

View File

@@ -1,6 +1,6 @@
# Hermes Web UI -- Forward Sprint Plan
> Current state: v0.25 | 423 tests | Daily driver ready
> Current state: v0.26 | 426 tests | Daily driver ready
> This document plans the path from here to two targets:
>
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
@@ -663,5 +663,5 @@ and switchToProfile() didn't refresh workspaces or sessions.
---
*Last updated: April 3, 2026*
*Current version: v0.25 | 423 tests*
*Current version: v0.26 | 426 tests*
*Next sprint: Sprint 24 (Desktop Application)*

View File

@@ -373,15 +373,16 @@ def resolve_model_provider(model_id: str):
if '/' in model_id:
prefix, bare = model_id.split('/', 1)
# If prefix matches config provider, strip it and use that provider directly
# If prefix matches config provider exactly, strip it and use that provider directly.
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
if config_provider and prefix == config_provider:
return bare, config_provider, config_base_url
# If the config provider is openrouter (or unset/None), pass the full
# provider/model string through -- OpenRouter uses this as its model ID.
# Only strip the prefix and switch to a direct-API provider when the
# config is explicitly set to that direct provider.
if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
return bare, prefix, None
# If prefix does NOT match config provider, the user picked a cross-provider model
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
# In this case always route through openrouter with the full provider/model string.
# Never strip the prefix and try a direct-API call to a provider whose key may not exist.
if prefix in _PROVIDER_MODELS and prefix != config_provider:
return model_id, 'openrouter', None
return model_id, config_provider, config_base_url

View File

@@ -90,6 +90,11 @@ def all_sessions():
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)]
# Backfill: sessions created before Sprint 22 have no profile tag.
# Attribute them to 'default' so the client profile filter works correctly.
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
return result
except Exception:
pass # fall through to full scan
@@ -105,7 +110,11 @@ def all_sessions():
for s in SESSIONS.values():
if all(s.session_id != x.session_id for x in out): out.append(s)
out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True)
return [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
result = [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
return result
def title_from(messages, fallback='Untitled'):

View File

@@ -16,9 +16,44 @@ from pathlib import Path
# ── Module state ────────────────────────────────────────────────────────────
_active_profile = 'default'
_profile_lock = threading.Lock()
# Read from env var so test isolation (HERMES_HOME=TEST_STATE_DIR) is respected.
# Evaluated at import time; server restart picks up any env change.
_DEFAULT_HERMES_HOME = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
def _resolve_base_hermes_home() -> Path:
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
This is intentionally distinct from HERMES_HOME, which tracks the *active
profile's* home and changes on every profile switch. The base dir must
always point to the top-level .hermes regardless of which profile is active.
Resolution order:
1. HERMES_BASE_HOME env var (set explicitly, highest priority)
2. HERMES_HOME env var — but only if it does NOT look like a profile subdir
(i.e. its parent is not named 'profiles'). This handles test isolation
where HERMES_HOME is set to an isolated test state dir.
3. ~/.hermes (always-correct default)
The bug this prevents: if HERMES_HOME has already been mutated to
/home/user/.hermes/profiles/webui (by init_profile_state at startup),
reading it here would make _DEFAULT_HERMES_HOME point to that subdir,
causing switch_profile('webui') to look for
/home/user/.hermes/profiles/webui/profiles/webui — which doesn't exist.
"""
# Explicit override for tests or unusual setups
base_override = os.getenv('HERMES_BASE_HOME', '').strip()
if base_override:
return Path(base_override).expanduser()
hermes_home = os.getenv('HERMES_HOME', '').strip()
if hermes_home:
p = Path(hermes_home).expanduser()
# If HERMES_HOME points to a profiles/ subdir, walk up two levels to the base
if p.parent.name == 'profiles':
return p.parent.parent
# Otherwise trust it (e.g. test isolation sets HERMES_HOME to TEST_STATE_DIR)
return p
return Path.home() / '.hermes'
_DEFAULT_HERMES_HOME = _resolve_base_hermes_home()
def _read_active_profile_file() -> str:

View File

@@ -112,13 +112,38 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if AIAgent is None:
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model)
# Read per-profile config at call time (not module-level snapshot)
from api.config import get_config as _get_config
_cfg = _get_config()
# Per-profile toolsets (fall back to module-level CLI_TOOLSETS)
_pt = _cfg.get('platform_toolsets', {})
_toolsets = _pt.get('cli', CLI_TOOLSETS) if isinstance(_pt, dict) else CLI_TOOLSETS
# Fallback model from profile config (e.g. for rate-limit recovery)
_fallback = _cfg.get('fallback_model') or None
if _fallback:
# Resolve the fallback through our provider logic too
fb_model = _fallback.get('model', '')
fb_provider = _fallback.get('provider', '')
fb_base_url = _fallback.get('base_url')
_fallback_resolved = {
'model': fb_model,
'provider': fb_provider,
'base_url': fb_base_url,
}
else:
_fallback_resolved = None
agent = AIAgent(
model=resolved_model,
provider=resolved_provider,
base_url=resolved_base_url,
platform='cli',
quiet_mode=True,
enabled_toolsets=CLI_TOOLSETS,
enabled_toolsets=_toolsets,
fallback_model=_fallback_resolved,
session_id=session_id,
stream_delta_callback=on_token,
tool_progress_callback=on_tool,
@@ -203,7 +228,18 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
except Exception as e:
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)
put('error', {'message': str(e)})
err_str = str(e)
# Detect rate limit errors specifically so the client can show a helpful card
# rather than the generic "Connection lost" message
is_rate_limit = 'rate limit' in err_str.lower() or '429' in err_str or 'RateLimitError' in type(e).__name__
if is_rate_limit:
put('apperror', {
'message': err_str,
'type': 'rate_limit',
'hint': 'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.',
})
else:
put('apperror', {'message': err_str, 'type': 'error'})
finally:
_clear_thread_env() # TD1: always clear thread-local context
with STREAMS_LOCK:

View File

@@ -53,17 +53,31 @@ def _last_workspace_file() -> Path:
def _profile_default_workspace() -> str:
"""Read the profile's default workspace from its config.yaml.
Checks keys in priority order:
1. 'workspace' — explicit webui workspace key
2. 'default_workspace' — alternate explicit key
3. 'terminal.cwd' — hermes-agent terminal working dir (most common)
Falls back to the boot-time DEFAULT_WORKSPACE constant.
"""
try:
from api.profiles import get_active_hermes_home
from api.config import get_config
cfg = get_config()
ws = cfg.get('default_workspace')
if ws:
p = Path(ws).expanduser().resolve()
if p.is_dir():
return str(p)
# Explicit webui workspace keys first
for key in ('workspace', 'default_workspace'):
ws = cfg.get(key)
if ws:
p = Path(str(ws)).expanduser().resolve()
if p.is_dir():
return str(p)
# Fall through to terminal.cwd — the agent's configured working directory
terminal_cfg = cfg.get('terminal', {})
if isinstance(terminal_cfg, dict):
cwd = terminal_cfg.get('cwd', '')
if cwd and str(cwd) not in ('.', ''):
p = Path(str(cwd)).expanduser().resolve()
if p.is_dir():
return str(p)
except (ImportError, Exception):
pass
return str(_BOOT_DEFAULT_WORKSPACE)
@@ -71,26 +85,94 @@ def _profile_default_workspace() -> str:
# ── Public API ──────────────────────────────────────────────────────────────
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
confusion with the 'default' profile name).
Returns the cleaned list (may be empty).
"""
hermes_profiles = (Path.home() / '.hermes' / 'profiles').resolve()
result = []
for w in workspaces:
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)
try:
p.relative_to(hermes_profiles)
continue # it IS under profiles/ — remove it
except ValueError:
pass
# Rename confusing 'default' label to 'Home'
if name.lower() == 'default':
name = 'Home'
result.append({'path': str(p), 'name': name})
return result
def _migrate_global_workspaces() -> list:
"""Read the legacy global workspaces.json, clean it, and return the result.
This is the migration path for users upgrading from a pre-profile version:
their global file may contain cross-profile entries, test artifacts, and
stale paths accumulated over time. We clean it in-place and rewrite it.
"""
if not _GLOBAL_WS_FILE.exists():
return []
try:
raw = json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
cleaned = _clean_workspace_list(raw)
if len(cleaned) != len(raw):
# Rewrite the cleaned version so future reads are already clean
_GLOBAL_WS_FILE.write_text(
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
)
return cleaned
except Exception:
return []
def load_workspaces() -> list:
ws_file = _workspaces_file()
if ws_file.exists():
try:
return json.loads(ws_file.read_text(encoding='utf-8'))
raw = json.loads(ws_file.read_text(encoding='utf-8'))
cleaned = _clean_workspace_list(raw)
if len(cleaned) != len(raw):
# Persist the cleaned version so stale entries don't keep reappearing
try:
ws_file.write_text(
json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
)
except Exception:
pass
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
except Exception:
pass
# Fallback: for the DEFAULT profile only, migrate from the legacy global file.
# Named profiles should start with a clean list, not inherit another profile's workspaces.
# 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.
try:
from api.profiles import get_active_profile_name
is_default = get_active_profile_name() in ('default', None)
except ImportError:
is_default = True
if is_default and _GLOBAL_WS_FILE.exists():
try:
return json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
except Exception:
pass
return [{'path': _profile_default_workspace(), 'name': 'default'}]
if is_default:
migrated = _migrate_global_workspaces()
if migrated:
return migrated
# Fresh start: single entry from the profile's configured workspace, labeled "Home"
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
def save_workspaces(workspaces: list):

View File

@@ -13,7 +13,7 @@
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.25</div></div></div>
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.26</div></div></div>
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
@@ -145,13 +145,16 @@
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
</optgroup>
</select>
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
<span style="font-size:14px;opacity:.7">&#128193;</span>
<div style="min-width:0;flex:1">
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
<div style="position:relative">
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
<span style="font-size:14px;opacity:.7">&#128193;</span>
<div style="min-width:0;flex:1">
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
</div>
<span style="font-size:10px;color:var(--muted);flex-shrink:0">&#9662;</span>
</div>
<span style="font-size:10px;color:var(--muted);flex-shrink:0">&#9662;</span>
<div class="ws-dropdown" id="wsDropdown"></div>
</div>
<div class="sidebar-actions">
<button class="sm-btn" id="btnDownload" title="Download as Markdown">&#8595; Transcript</button>
@@ -174,10 +177,7 @@
<div class="profile-dropdown" id="profileDropdown"></div>
</div>
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
<div id="wsChipWrap" style="position:relative">
<div class="chip ws-chip" id="wsChip" onclick="toggleWsDropdown()" title="Switch workspace" style="cursor:pointer">&#128193; test-workspace &#9662;</div>
<div class="ws-dropdown" id="wsDropdown"></div>
</div>
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">&#128465; Clear</button>
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">&#9881;</button>
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">&#128193;</button>

View File

@@ -162,6 +162,46 @@ async function send(){
renderSessionList();setBusy(false);setStatus('');
});
source.addEventListener('apperror',e=>{
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
source.close();
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking();
try{
const d=JSON.parse(e.data);
const isRateLimit=d.type==='rate_limit';
const icon=isRateLimit?'⏱️':'⚠️';
const label=isRateLimit?'Rate limit reached':'Error';
const hint=d.hint?`\n\n*${d.hint}*`:'';
S.messages.push({role:'assistant',content:`**${icon} ${label}:** ${d.message}${hint}`});
}catch(_){
S.messages.push({role:'assistant',content:'**⚠️ Error:** An error occurred. Check server logs.'});
}
renderMessages();
}else if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');}
catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
}
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('');}
});
source.addEventListener('warning',e=>{
// Non-fatal warning from server (e.g. fallback activated, retrying)
if(!S.session||S.session.session_id!==activeSid) return;
try{
const d=JSON.parse(e.data);
// Show as a small inline notice, not a full error
setStatus(`⚠️ ${d.message||'Warning'}`);
// If it's a fallback notice, show it briefly then clear
if(d.type==='fallback') setTimeout(()=>setStatus(''),4000);
}catch(_){}
});
source.addEventListener('error',e=>{
source.close();
// Attempt one reconnect if the stream is still active server-side

View File

@@ -490,7 +490,7 @@ function closeWsDropdown(){
if(dd)dd.classList.remove('open');
}
document.addEventListener('click',e=>{
if(!e.target.closest('#wsChipWrap'))closeWsDropdown();
if(!e.target.closest('#sidebarWsDisplay') && !e.target.closest('#wsDropdown'))closeWsDropdown();
});
async function loadWorkspacesPanel(){
@@ -660,40 +660,74 @@ document.addEventListener('click', e => {
async function switchToProfile(name) {
if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; }
// Determine whether the current session has any messages.
// A session with messages is "in progress" and belongs to the current profile —
// we must not retag it. We'll start a fresh session for the new profile instead.
const sessionInProgress = S.session && S.messages && S.messages.length > 0;
try {
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
S.activeProfile = data.active || name;
// Clear stale model pref so profile default applies
// ── Model ──────────────────────────────────────────────────────────────
localStorage.removeItem('hermes-webui-model');
// Refresh model dropdown (profile may have different provider/models)
_skillsData = null;
await populateModelDropdown();
// Apply profile's default model if provided
if (data.default_model && $('modelSelect')) {
$('modelSelect').value = data.default_model;
if ($('modelSelect').value !== data.default_model) {
// Model not in list — add it
const opt = document.createElement('option');
opt.value = data.default_model;
opt.textContent = data.default_model.split('/').pop();
$('modelSelect').insertBefore(opt, $('modelSelect').firstChild);
$('modelSelect').value = data.default_model;
if (data.default_model) {
const sel = $('modelSelect');
const resolved = _applyModelToDropdown(data.default_model, sel);
const modelToUse = resolved || data.default_model;
S._pendingProfileModel = modelToUse;
// Only patch the in-memory session model if we're NOT about to replace the session
if (S.session && !sessionInProgress) {
S.session.model = modelToUse;
}
}
// Refresh workspace list (now profile-local)
// ── Workspace ──────────────────────────────────────────────────────────
_workspaceList = null;
await loadWorkspaceList();
// Reset profile filter and refresh session list
if (data.default_workspace) {
// Always store the profile default for new sessions
S._profileDefaultWorkspace = data.default_workspace;
if (S.session && !sessionInProgress) {
// Empty session (no messages yet) — safe to update it in place
try {
await api('/api/session/update', { method: 'POST', body: JSON.stringify({
session_id: S.session.session_id,
workspace: data.default_workspace,
model: S.session.model,
})});
S.session.workspace = data.default_workspace;
} catch (_) {}
}
}
// ── Session ────────────────────────────────────────────────────────────
_showAllProfiles = false;
await renderSessionList();
syncTopbar();
// Refresh visible sidebar panels
if (sessionInProgress) {
// The current session has messages and belongs to the previous profile.
// Start a new session for the new profile so nothing gets cross-tagged.
await newSession(false);
await renderSessionList();
showToast('Switched to profile: ' + name + ' — new conversation started');
} else {
// No messages yet — just refresh the list and topbar in place
await renderSessionList();
syncTopbar();
showToast('Switched to profile: ' + name);
}
// ── Sidebar panels ─────────────────────────────────────────────────────
if (_currentPanel === 'skills') await loadSkills();
if (_currentPanel === 'memory') await loadMemory();
if (_currentPanel === 'tasks') await loadCrons();
if (_currentPanel === 'profiles') await loadProfilesPanel();
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
showToast('Switched to profile: ' + name);
} catch (e) { showToast('Switch failed: ' + e.message); }
}

View File

@@ -13,7 +13,10 @@ async function newSession(flash){
MSG_QUEUE.length=0;updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
const inheritWs=S.session?S.session.workspace:null;
// Use profile default workspace for new sessions after a profile switch (one-shot),
// otherwise inherit from the current session (or let server pick the default)
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
S.session=data.session;S.messages=data.session.messages||[];
if(flash)S.session._flash=true;
@@ -113,7 +116,9 @@ function renderSessionListFromCache(){
const titleIds=new Set(titleMatches.map(s=>s.session_id));
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
// Filter by active profile (unless "All profiles" is toggled on)
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>!s.profile||s.profile===S.activeProfile);
// Server backfills profile='default' for legacy sessions, so every session has a profile.
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.profile===S.activeProfile);
// Filter by active project
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
// Filter archived unless toggle is on

View File

@@ -345,7 +345,7 @@
/* ── Workspace dropdown (topbar) ── */
.ws-chip{user-select:none;}
.ws-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:240px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
.ws-dropdown.open{display:block;}
.ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
.ws-opt:hover{background:rgba(255,255,255,.07);}

View File

@@ -7,6 +7,38 @@ const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={};
// ── Smart model resolver ────────────────────────────────────────────────────
// Finds the best matching option value in a <select> for a given model ID.
// Handles mismatches like 'claude-sonnet-4-6' vs 'anthropic/claude-sonnet-4.6'.
// Returns the matched option's value (already in the list), or null if no match.
function _findModelInDropdown(modelId, sel){
if(!modelId||!sel) return null;
const opts=Array.from(sel.options).map(o=>o.value);
// 1. Exact match
if(opts.includes(modelId)) return modelId;
// 2. Normalize: lowercase, strip namespace prefix, replace hyphens→dots
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/-/g,'.');
const target=norm(modelId);
const exact=opts.find(o=>norm(o)===target);
if(exact) return exact;
// 3. Prefix/substring: target starts with or contains a significant chunk
const base=target.replace(/\.\d+$/,''); // strip trailing version number
const partial=opts.find(o=>norm(o).startsWith(base)||norm(o).includes(base));
return partial||null;
}
// Set the model picker to the best match for modelId.
// Returns the resolved value that was actually set, or null if nothing matched.
function _applyModelToDropdown(modelId, sel){
if(!modelId||!sel) return null;
const resolved=_findModelInDropdown(modelId,sel);
if(resolved){
sel.value=resolved;
return resolved;
}
return null;
}
async function populateModelDropdown(){
const sel=$('modelSelect');
if(!sel) return;
@@ -30,15 +62,7 @@ async function populateModelDropdown(){
}
// Set default model from server if no localStorage preference
if(data.default_model && !localStorage.getItem('hermes-webui-model')){
sel.value=data.default_model;
// If the default isn't in the list, add it
if(sel.value!==data.default_model){
const opt=document.createElement('option');
opt.value=data.default_model;
opt.textContent=data.default_model.split('/').pop();
sel.insertBefore(opt,sel.firstChild);
sel.value=data.default_model;
}
_applyModelToDropdown(data.default_model, sel);
}
}catch(e){
// API unavailable -- keep the hardcoded HTML options as fallback
@@ -320,15 +344,23 @@ function syncTopbar(){
document.title=sessionTitle+' \u2014 Hermes';
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
$('topbarMeta').textContent=`${vis.length} messages`;
const m=S.session.model||'';
$('modelSelect').value=m; // set dropdown first so chip reads consistent value
// If session model isn't in the dropdown, add it dynamically
if(m && $('modelSelect').value!==m){
const opt=document.createElement('option');
opt.value=m;
opt.textContent=getModelLabel(m);
$('modelSelect').appendChild(opt);
$('modelSelect').value=m;
// If a profile switch just happened, apply its model rather than the session's stale value.
// S._pendingProfileModel is set by switchToProfile() and cleared here after one application.
const modelOverride=S._pendingProfileModel;
if(modelOverride){
S._pendingProfileModel=null;
_applyModelToDropdown(modelOverride,$('modelSelect'));
} else {
const m=S.session.model||'';
const applied=_applyModelToDropdown(m,$('modelSelect'));
// If the model isn't in the list at all, add it so the session value is preserved
if(!applied && m){
const opt=document.createElement('option');
opt.value=m;
opt.textContent=getModelLabel(m);
$('modelSelect').appendChild(opt);
$('modelSelect').value=m;
}
}
// Show Clear button only when session has messages
const clearBtn=$('btnClearConv');
@@ -336,13 +368,6 @@ function syncTopbar(){
const displayModel=$('modelSelect').value||m;
$('modelChip').textContent=getModelLabel(displayModel);
const ws=S.session.workspace||'';
$('wsChip').textContent=ws.split('/').slice(-2).join('/')||ws;
// Update workspace chip in topbar with friendly name from workspace list
const wsChipEl=$('wsChip');
if(wsChipEl){
const wsFriendly=getWorkspaceFriendlyName(ws);
wsChipEl.textContent='\u{1F4C1} '+wsFriendly+' \u25BE';
}
// Update sidebar workspace display
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');

View File

@@ -1,7 +1,13 @@
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)throw new Error(await res.text());
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;}
}
const ct=res.headers.get('content-type')||'';
return ct.includes('application/json')?res.json():res.text();
}

View File

@@ -122,3 +122,72 @@ def test_panels_js_clears_model_on_switch():
assert "localStorage.removeItem('hermes-webui-model')" in content
assert "loadWorkspaceList" in content
assert "renderSessionList" in content
# ── Regression: profile switch base dir bug (PR #44) ──────────────────────
def test_profile_switch_base_home_not_subdir():
"""_DEFAULT_HERMES_HOME must always be the base ~/.hermes root, not a
profile subdir. Regression: if HERMES_HOME was mutated to a profiles/
subdir at server startup, switch_profile() looked for
~/.hermes/profiles/X/profiles/X which never exists — returning 404.
We verify the fix is present via static analysis of profiles.py.
The live-switch variant is in test_profile_switch_returns_default_model_and_workspace.
"""
content = (REPO_ROOT / "api" / "profiles.py").read_text()
# The fix must define a resolver function that handles the profiles/ subdir case
assert "_resolve_base_hermes_home" in content, (
"profiles.py must define _resolve_base_hermes_home() to safely resolve "
"the base HERMES_HOME regardless of HERMES_HOME env var mutation"
)
assert "p.parent.name == 'profiles'" in content, (
"_resolve_base_hermes_home must detect when HERMES_HOME points to a "
"profiles/ subdir (e.g. ~/.hermes/profiles/webui) and walk up to base"
)
assert "p.parent.parent" in content, (
"_resolve_base_hermes_home must return p.parent.parent when HERMES_HOME "
"is a profiles/ subdir, giving back the actual ~/.hermes base"
)
# _DEFAULT_HERMES_HOME must be set from the resolver, not directly from env
assert "_DEFAULT_HERMES_HOME = _resolve_base_hermes_home()" in content, (
"_DEFAULT_HERMES_HOME must be assigned from _resolve_base_hermes_home(), "
"not directly from os.getenv('HERMES_HOME')"
)
def test_api_helper_returns_clean_error_message():
"""workspace.js api() helper must parse JSON error bodies and surface
the human-readable 'error' field, not raw JSON like
{'error': 'Profile X does not exist.'}.
Regression: api() did `throw new Error(await res.text())` which made
showToast display 'Switch failed: {"error":"Profile X does not exist."}' --
JSON noise the user shouldn't see.
"""
content = (REPO_ROOT / "static" / "workspace.js").read_text()
# Must parse the JSON error body
assert "JSON.parse(text)" in content, (
"api() must parse JSON error bodies -- raw res.text() leaks JSON to the UI"
)
# Must extract the .error field
assert "j.error" in content, (
"api() must extract j.error from parsed JSON error response"
)
def test_profile_switch_resolve_base_home_logic():
"""Static analysis: _resolve_base_hermes_home() must handle the case
where HERMES_HOME points to a profiles/ subdir by walking up to the base.
"""
content = (REPO_ROOT / "api" / "profiles.py").read_text()
assert "_resolve_base_hermes_home" in content, (
"profiles.py must define _resolve_base_hermes_home()"
)
assert "p.parent.name == 'profiles'" in content, (
"_resolve_base_hermes_home must detect and unwrap profiles/ subdir paths"
)
assert "p.parent.parent" in content, (
"_resolve_base_hermes_home must walk up two levels from a profiles/ subdir"
)