feat(dashboard): unify multi-profile management — one machine dashboard, global profile switcher

The dashboard becomes a machine-level management surface with one
write-target selector, replacing per-profile dashboard fragmentation.

Backend:
- profile param (query or body) on /api/config (get/put/raw), /api/env
  (get/put/delete/reveal), /api/mcp/servers (list/add/remove/test/enabled),
  /api/mcp/catalog (list/install), /api/model/info, /api/model/set —
  all scoped through the existing _profile_scope() context manager
- model/set restructured: expensive-model warning (await) runs before the
  scope; the config write runs sync inside the scope in a worker thread
- MCP catalog installs + git-bootstrap entries spawn 'hermes -p <profile>'
- chat PTY: ?profile= on /api/pty points the child's HERMES_HOME at the
  profile dir (its own gateway subprocess, config/skills/memory/state.db
  all profile-bound); in-process gateway attach skipped when scoped

CLI launch unification:
- '<profile> dashboard' routes to the machine dashboard: attach (open
  browser at ?profile=) when one is listening, else re-exec pinned to the
  default profile with --open-profile preselecting the launcher
- --isolated preserves the old dedicated per-profile server behavior
- start_server(initial_profile=...) appends ?profile= to the auto-open URL

Frontend:
- ProfileProvider + sidebar ProfileSwitcher: ONE global selector, URL-
  persisted (?profile=), mirrored into fetchJSON which auto-appends the
  param to the scoped endpoint families (explicit params win)
- app-wide amber banner names the managed profile
- SkillsPage's page-local selector (from the skills-scoping PR) folded
  into the global context — single source of truth
- ChatPage threads the scope into the PTY WS URL; switching profiles
  remounts the terminal into a fresh scoped session

Omitted profile keeps legacy behavior everywhere.
This commit is contained in:
Teknium
2026-06-10 22:00:06 -07:00
parent 3e74f75e41
commit f02302738d
17 changed files with 1044 additions and 291 deletions
+8 -8
View File
@@ -4441,7 +4441,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
from starlette.websockets import WebSocketDisconnect
@@ -4454,7 +4454,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
from starlette.websockets import WebSocketDisconnect
@@ -4467,7 +4467,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (
lambda resume=None, sidecar_url=None, profile=None: (
["/bin/sh", "-c", "printf hermes-ws-ok"],
None,
None,
@@ -4497,7 +4497,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
with self.client.websocket_connect(self._url()) as conn:
conn.send_bytes(b"round-trip-payload\n")
@@ -4530,7 +4530,7 @@ class TestPtyWebSocket:
self.ws_module,
"_resolve_chat_argv",
# sleep gives the test time to push the resize before the child reads the ioctl.
lambda resume=None, sidecar_url=None: (
lambda resume=None, sidecar_url=None, profile=None: (
[sys.executable, "-c", winsize_script],
None,
None,
@@ -4566,7 +4566,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
# Patch PtyBridge.spawn at the web_server module's binding.
import hermes_cli.web_server as ws_mod
@@ -4581,7 +4581,7 @@ class TestPtyWebSocket:
def test_resume_parameter_is_forwarded_to_argv(self, monkeypatch):
captured: dict = {}
def fake_resolve(resume=None, sidecar_url=None):
def fake_resolve(resume=None, sidecar_url=None, profile=None):
captured["resume"] = resume
return (["/bin/sh", "-c", "printf resume-arg-ok"], None, None)
@@ -4601,7 +4601,7 @@ class TestPtyWebSocket:
same channel — which is how tool events reach the dashboard sidebar."""
captured: dict = {}
def fake_resolve(resume=None, sidecar_url=None):
def fake_resolve(resume=None, sidecar_url=None, profile=None):
captured["sidecar_url"] = sidecar_url
return (["/bin/sh", "-c", "printf sidecar-ok"], None, None)