From c778c1eb0c92ea2c4cd4a48e3efa211840f4b279 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 19:29:24 +0000 Subject: [PATCH 01/11] 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. --- api/profiles.py | 41 +++++++++++++++++++++++-- static/workspace.js | 8 ++++- tests/test_sprint23.py | 69 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 943abff..f74cd91 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -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: diff --git a/static/workspace.js b/static/workspace.js index 94b6847..13eb439 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -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(); } diff --git a/tests/test_sprint23.py b/tests/test_sprint23.py index 8a7041c..deedda3 100644 --- a/tests/test_sprint23.py +++ b/tests/test_sprint23.py @@ -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" + ) From d4ab01c152dbe8acef4da2f102815088dba98d75 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 19:38:33 +0000 Subject: [PATCH 02/11] 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. --- static/index.html | 20 ++++++++++---------- static/panels.js | 20 +++++++++++++++++++- static/sessions.js | 4 +++- static/style.css | 2 +- static/ui.js | 7 ------- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/static/index.html b/static/index.html index a4f87ee..c9bd642 100644 --- a/static/index.html +++ b/static/index.html @@ -145,13 +145,16 @@ -
- 📁 -
-
Workspace
-
+
+
+ 📁 +
+
Workspace
+
+
+
- +
GPT-5.4 Mini
-
-
📁 test-workspace ▾
-
-
+ diff --git a/static/panels.js b/static/panels.js index 4825f59..1ad076c 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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(){ @@ -683,6 +683,24 @@ async function switchToProfile(name) { // Refresh workspace list (now profile-local) _workspaceList = null; await loadWorkspaceList(); + + // Apply the profile's default workspace to the current session + if (data.default_workspace) { + if (S.session) { + // Update existing session's workspace to the profile default + 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 (_) {} + } + // Store as the profile default so the next new session picks it up + S._profileDefaultWorkspace = data.default_workspace; + } + // Reset profile filter and refresh session list _showAllProfiles = false; await renderSessionList(); diff --git a/static/sessions.js b/static/sessions.js index 154564f..dba98d5 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -13,7 +13,9 @@ 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, + // otherwise inherit from the current session (or let server pick the default) + const inheritWs=S._profileDefaultWorkspace||( S.session?S.session.workspace:null); 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; diff --git a/static/style.css b/static/style.css index 82d8daa..b13bfda 100644 --- a/static/style.css +++ b/static/style.css @@ -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;top:calc(100% + 4px);left:0;right:0;min-width:200px;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.open{display:block;} .ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;} .ws-opt:hover{background:rgba(255,255,255,.07);} diff --git a/static/ui.js b/static/ui.js index fae9b7c..a4f8c07 100644 --- a/static/ui.js +++ b/static/ui.js @@ -336,13 +336,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'); From 3d8cf85ef29a8e690035b9b45f33b576477064c4 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 19:47:38 +0000 Subject: [PATCH 03/11] fix: profile default workspace reads terminal.cwd; dropdown opens upward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/workspace.py | 26 ++++++++++++++++++++------ static/style.css | 2 +- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/api/workspace.py b/api/workspace.py index 0db7b5b..de0ff05 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -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) diff --git a/static/style.css b/static/style.css index b13bfda..e5c39ed 100644 --- a/static/style.css +++ b/static/style.css @@ -345,7 +345,7 @@ /* ── Workspace dropdown (topbar) ── */ .ws-chip{user-select:none;} -.ws-dropdown{display:none;position:absolute;top:calc(100% + 4px);left:0;right:0;min-width:200px;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);} From f75e17c9121ce70e1604c9b7214fd7bab7d8c242 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 19:50:08 +0000 Subject: [PATCH 04/11] 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. --- api/models.py | 11 ++++++++++- static/sessions.js | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/api/models.py b/api/models.py index ad9d807..be6b68e 100644 --- a/api/models.py +++ b/api/models.py @@ -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'): diff --git a/static/sessions.js b/static/sessions.js index dba98d5..976c54e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -115,7 +115,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 From ad755e49e5bb807ed8c6075c2ffae73bf46c80e3 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 20:01:12 +0000 Subject: [PATCH 05/11] fix: workspace isolation, session filtering, and clean migration path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/workspace.py | 86 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/api/workspace.py b/api/workspace.py index de0ff05..757174b 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -85,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): From c71439d8ab57514106985a1296d6573989bd68e4 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 20:10:47 +0000 Subject: [PATCH 06/11] fix: model picker correctly updates on profile switch without flicker or raw injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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