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

* 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.

* docs(dashboard): document machine-level multi-profile management

- web-dashboard.md: 'Managing multiple profiles' section (switcher, URL
  deep-links, unified launch, --isolated, scoped Chat, what stays
  per-profile) + --isolated in the options table
- profiles.md: 'From the dashboard' subsection + set-as-active vs
  switcher clarification
- cli-commands.md: --isolated flag + profile-alias launch example

* fix(dashboard): address profile-unification review findings

Review findings (dev review on PR #44007):

1. HIGH — stale page state on profile switch: pages load data on mount
   and didn't consume the profile scope, so a page opened under profile A
   kept showing A's state while writes silently targeted the newly
   selected B. Fixed structurally: ProfileKeyedRoutes wraps the routed
   page tree and keys it by the selected profile, remounting every page
   (fresh state + refetch) on switch. ChatPage keeps its own remount
   (channel keyed on scopedProfile).

2. HIGH — /api/model/auxiliary read was unscoped while /api/model/set
   wrote scoped (Models page could show default's aux pins while editing
   worker's). Endpoint now takes profile + _profile_scope, added to
   PROFILE_SCOPED_PREFIXES, HTTPException re-raise so ghost profiles 404
   instead of 500. Regression test asserts read/write symmetry with
   differing worker/default aux config.

3. MEDIUM — tools post-setup spawned unscoped from the profile-aware
   drawer. Now spawns 'hermes -p <profile> tools post-setup <key>'
   (same mechanism as hub installs); drawer threads its profile prop.
   Most hooks install machine-level artifacts where the scope is inert,
   but hooks reading config/env now see the drawer's HERMES_HOME.

4. LOW — ty warnings: env Optional asserts before subscript/membership,
   fastapi import replaced with web_server.HTTPException re-use.

298 tests green across the four affected suites; tsc -b + vite build
green; aux scoping E2E-verified with real imports.

* fix(dashboard): address second profile-unification review (gille)

1. BLOCKER — profile scope dropped on sidebar navigation: ProfileProvider
   derived the selection from the current URL, and nav links are bare
   paths, so clicking Config from /skills?profile=worker silently reset
   the write target. State is now the source of truth; an effect
   re-asserts ?profile= onto the new location after every navigation
   (URL stays a synchronized projection for deep links/refresh), and an
   incoming URL param (e.g. 'Manage skills & tools' links) still wins.

2. BLOCKER — /api/model/options unscoped while model/set wrote scoped:
   the picker context (current model/provider, custom providers,
   per-profile .env auth state) now loads inside _profile_scope; added
   to PROFILE_SCOPED_PREFIXES. Test: a worker-only current-model pin
   appears in the scoped payload and not the unscoped one.

3. BLOCKER — MCP test-server probe escaped the scope after the config
   read: the probe now re-enters _profile_scope inside the worker thread
   so env-placeholder expansion resolves against the selected profile's
   .env. Known limit (documented): the probe's dedicated MCP event-loop
   thread doesn't inherit the contextvar (OAuth token paths). Test
   asserts get_hermes_home() inside the probe == the worker profile dir.

4. BLOCKER — broad excepts swallowed unknown-profile 404s: /api/model/info
   degraded to 200-with-empty-model-info and /api/mcp/catalog to a
   silently-empty catalog. Both re-raise HTTPException; 404 regression
   tests added for info/options/catalog.

Polish: scope banner clears the fixed mobile header (mt-14 lg:mt-0);
--open-profile hidden via argparse.SUPPRESS (internal re-exec flag);
attach-path test now asserts the opened ?profile= URL.

(Stale-page-state + /api/model/auxiliary findings from this review were
already fixed in 92bcd1568 — the review ran against e600f6951.)

35 tests in the two new suites + 274 in the adjacent ones, all green;
tsc -b + vite build green; scoping E2E-verified with real imports.

* docs(dashboard)+fix: self-review pass — Profiles page section, REST profile-param tip, body-beats-query precedence

Docs:
- web-dashboard.md: add the missing 'Profiles' subsection to Pages
  (cards, create/builder, manage-skills jump, set-as-active vs switcher
  distinction, editors); REST API section gets a profile-scoped-endpoints
  tip documenting ?profile= / body profile / 404 semantics / /api/pty
- (profiles.md + cli-commands.md were already updated in e600f6951)

Precedence fix: scoped endpoints taking BOTH a query param and a body
field now resolve body.profile first. The SPA's fetchJSON injects the
query param from the GLOBAL switcher; an explicit body.profile (e.g.
Profile Builder flows writing into a specific new profile) is the more
specific intent and must not be overridden by whatever the sidebar
happens to be set to. Matches the documented 'explicit beats global'
contract in api.ts.

Verified: 304 tests green across the four suites; tsc -b + vite build
green; docusaurus build green (only pre-existing broken-link warnings,
none from this PR's pages).
This commit is contained in:
Teknium 2026-06-11 03:29:33 -07:00 committed by GitHub
parent 85503dceca
commit 875aa8f162
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1429 additions and 320 deletions

View file

@ -10220,6 +10220,21 @@ def _report_dashboard_status() -> int:
return len(pids)
def _dashboard_listening(host: str, port: int) -> bool:
"""True when something is accepting TCP connections at host:port.
Any listener counts even a 401 response proves a dashboard is up.
Used by the unified profile-launch routing to decide attach-vs-start.
"""
import socket
try:
with socket.create_connection((host or "127.0.0.1", port), timeout=1.5):
return True
except OSError:
return False
def cmd_dashboard(args):
"""Start the web UI server, or (with --stop/--status) manage running ones."""
# --status: report running dashboards and exit, no deps needed.
@ -10240,6 +10255,65 @@ def cmd_dashboard(args):
remaining = _find_stale_dashboard_pids()
sys.exit(1 if remaining else 0)
# ── Unified profile launch routing ────────────────────────────────
# The dashboard is a MACHINE management surface: it can read/write any
# profile via the per-request ?profile= scoping. Running one dashboard
# per profile just fragments that (port collisions, N processes, and a
# "which dashboard am I on?" guessing game). So when a NAMED profile
# launches the dashboard (`worker dashboard` → HERMES_HOME points into
# profiles/), default to the machine dashboard:
# - already running → open the browser at ?profile=<name> and exit
# - not running → re-exec as the machine dashboard (pinned to the
# default profile so _apply_profile_override can't re-route through
# the sticky active_profile file) with the launching profile
# preselected in the UI's switcher.
# `--isolated` opts out and preserves the old per-profile behavior.
try:
from hermes_cli.profiles import get_active_profile_name
_launch_profile = get_active_profile_name()
except Exception:
_launch_profile = "default"
if (
_launch_profile not in ("default", "custom")
and not getattr(args, "isolated", False)
and not getattr(args, "open_profile", "")
):
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
if _dashboard_listening(args.host, args.port):
print(f"Machine dashboard already running on port {args.port}.")
print(f" Managing profile '{_launch_profile}': {url}")
if not args.no_open:
try:
import webbrowser
webbrowser.open(url)
except Exception:
pass
sys.exit(0)
print(
f"Routing to the machine dashboard (profile '{_launch_profile}' "
f"preselected). Use --isolated for a dedicated per-profile server."
)
reexec_argv = [
sys.executable, "-m", "hermes_cli.main",
"-p", "default",
"dashboard",
"--port", str(args.port),
"--host", args.host,
"--open-profile", _launch_profile,
]
if args.no_open:
reexec_argv.append("--no-open")
if getattr(args, "insecure", False):
reexec_argv.append("--insecure")
if getattr(args, "skip_build", False):
reexec_argv.append("--skip-build")
env = os.environ.copy()
# Drop the profile HERMES_HOME so the child binds the machine root.
env.pop("HERMES_HOME", None)
os.execvpe(sys.executable, reexec_argv, env)
# Attach gui.log early so dashboard startup/build failures are captured in
# the same logs directory as every other Hermes surface.
try:
@ -10313,6 +10387,7 @@ def cmd_dashboard(args):
port=args.port,
open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
initial_profile=getattr(args, "open_profile", "") or "",
)

View file

@ -45,6 +45,26 @@ def build_dashboard_parser(
"where npm may not be available. Pre-build with: cd web && npm run build"
),
)
dashboard_parser.add_argument(
"--isolated",
action="store_true",
help=(
"When launched from a named profile (e.g. `worker dashboard`), run "
"a dedicated dashboard server scoped to that profile instead of "
"routing to the machine dashboard. Default behavior is unified: "
"profile launches attach to (or start) ONE machine-level dashboard "
"and preselect the profile in the UI's profile switcher."
),
)
# Internal flag set by the unified-launch re-exec (cmd_dashboard) to
# preselect the launching profile in the SPA switcher. Hidden from
# --help: users get this behavior automatically via `<profile> dashboard`.
dashboard_parser.add_argument(
"--open-profile",
dest="open_profile",
default="",
help=argparse.SUPPRESS,
)
# Lifecycle flags — mutually exclusive with each other and with the
# start-a-server flags above (if both are passed, --stop / --status win
# because they exit before the server is started). The dashboard has

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,130 @@
"""Tests for the unified profile→machine dashboard launch routing.
`<profile> dashboard` routes to ONE machine-level dashboard instead of
spawning a per-profile server: attach (open browser at ?profile=) when one
is already listening, else re-exec as the machine dashboard with the
launching profile preselected. `--isolated` opts out.
"""
import sys
import types
import pytest
@pytest.fixture
def main_mod():
import hermes_cli.main as main_mod
return main_mod
def _args(**kw):
defaults = dict(
status=False, stop=False, host="127.0.0.1", port=9119,
no_open=True, insecure=False, skip_build=False,
isolated=False, open_profile="",
)
defaults.update(kw)
return types.SimpleNamespace(**defaults)
class TestUnifiedDashboardRouting:
def test_profile_launch_attaches_to_running_dashboard(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
with pytest.raises(SystemExit) as exc:
main_mod.cmd_dashboard(_args())
assert exc.value.code == 0
assert execs == [] # attached, never re-exec'd
def test_profile_launch_attach_opens_scoped_url(self, main_mod, monkeypatch):
"""The attach path must open the browser at ?profile=<name> — that
URL is the entire point of attaching (preselects the switcher)."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
opened = []
import webbrowser
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
with pytest.raises(SystemExit) as exc:
main_mod.cmd_dashboard(_args(no_open=False))
assert exc.value.code == 0
assert opened == ["http://127.0.0.1:9119/?profile=worker_x"]
def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False)
execs = []
def fake_exec(exe, argv, env):
execs.append((exe, argv, env))
raise SystemExit(0) # execvpe never returns
monkeypatch.setattr(main_mod.os, "execvpe", fake_exec)
with pytest.raises(SystemExit):
main_mod.cmd_dashboard(_args())
assert len(execs) == 1
exe, argv, env = execs[0]
assert exe == sys.executable
# Pinned to the default profile + launching profile preselected.
assert "-p" in argv and argv[argv.index("-p") + 1] == "default"
assert "--open-profile" in argv
assert argv[argv.index("--open-profile") + 1] == "worker_x"
# Profile HERMES_HOME dropped so the child binds the machine root.
assert "HERMES_HOME" not in env
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or True,
)
# With --isolated the routing block is skipped entirely; the command
# proceeds to dependency checks. Make the first post-routing step
# bail so the test doesn't actually start a server.
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(isolated=True))
assert listening_calls == []
def test_default_profile_launch_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or True,
)
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args())
assert listening_calls == []
def test_reexec_child_does_not_reroute(self, main_mod, monkeypatch):
"""The re-exec'd child carries --open-profile; the guard must treat
that as 'already routed' and never re-exec again (no exec loop)."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
assert execs == []

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)

View file

@ -0,0 +1,385 @@
"""Regression tests for the machine-dashboard multi-profile unification.
The dashboard is ONE machine-level management surface: config, env, MCP,
model, and chat-PTY endpoints accept an optional ``profile`` so the global
profile switcher can target any profile's HERMES_HOME. These tests pin:
reads/writes land in the REQUESTED profile, the dashboard's own profile
stays untouched, and the chat PTY env is scoped via HERMES_HOME.
"""
import pytest
import yaml
@pytest.fixture
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
"""Isolated default home + one named profile, each with config + .env."""
from hermes_constants import get_hermes_home
from hermes_cli import profiles
default_home = get_hermes_home()
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker_beta"
for home in (default_home, worker_home):
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
(worker_home / ".env").write_text("", encoding="utf-8")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker_beta": worker_home}
@pytest.fixture
def client(monkeypatch, isolated_profiles):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def _cfg(home):
return yaml.safe_load((home / "config.yaml").read_text()) or {}
class TestProfileScopedConfig:
def test_config_put_lands_in_target_profile_only(self, client, isolated_profiles):
resp = client.put(
"/api/config",
json={"config": {"timezone": "Mars/Olympus"}, "profile": "worker_beta"},
)
assert resp.status_code == 200
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Mars/Olympus"
assert _cfg(isolated_profiles["default"]).get("timezone") != "Mars/Olympus"
def test_config_get_reads_target_profile(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"timezone: Venus/Cloud\n", encoding="utf-8"
)
resp = client.get("/api/config", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert resp.json().get("timezone") == "Venus/Cloud"
# Unscoped read sees the dashboard's own config.
resp = client.get("/api/config")
assert resp.json().get("timezone") != "Venus/Cloud"
def test_config_query_param_equivalent_to_body(self, client, isolated_profiles):
"""The SPA's fetchJSON injects ?profile= — must scope like body.profile."""
resp = client.put(
"/api/config?profile=worker_beta",
json={"config": {"timezone": "Pluto/Far"}},
)
assert resp.status_code == 200
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Pluto/Far"
assert _cfg(isolated_profiles["default"]).get("timezone") != "Pluto/Far"
def test_config_raw_round_trip_scoped(self, client, isolated_profiles):
resp = client.put(
"/api/config/raw",
json={"yaml_text": "timezone: Io/Volcano\n", "profile": "worker_beta"},
)
assert resp.status_code == 200
resp = client.get("/api/config/raw", params={"profile": "worker_beta"})
assert "Io/Volcano" in resp.json()["yaml"]
resp = client.get("/api/config/raw")
assert "Io/Volcano" not in resp.json()["yaml"]
def test_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/config", params={"profile": "ghost"})
assert resp.status_code == 404
class TestProfileScopedEnv:
def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles):
resp = client.put(
"/api/env",
json={"key": "FAL_KEY", "value": "test-fal-123", "profile": "worker_beta"},
)
assert resp.status_code == 200
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
assert "test-fal-123" in worker_env
default_env_path = isolated_profiles["default"] / ".env"
if default_env_path.exists():
assert "test-fal-123" not in default_env_path.read_text()
def test_env_list_reads_target_profile(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / ".env").write_text(
"FAL_KEY=worker-only-value\n", encoding="utf-8"
)
resp = client.get("/api/env", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert resp.json()["FAL_KEY"]["is_set"] is True
resp = client.get("/api/env")
assert resp.json()["FAL_KEY"]["is_set"] is False
def test_env_delete_scoped(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / ".env").write_text(
"FAL_KEY=doomed\n", encoding="utf-8"
)
resp = client.request(
"DELETE",
"/api/env",
json={"key": "FAL_KEY", "profile": "worker_beta"},
)
assert resp.status_code == 200
assert "doomed" not in (isolated_profiles["worker_beta"] / ".env").read_text()
class TestProfileScopedMcp:
def test_mcp_add_and_list_scoped(self, client, isolated_profiles):
resp = client.post(
"/api/mcp/servers",
json={"name": "scoped-srv", "url": "http://localhost:1234/sse",
"profile": "worker_beta"},
)
assert resp.status_code == 200
worker_cfg = _cfg(isolated_profiles["worker_beta"])
assert "scoped-srv" in worker_cfg.get("mcp_servers", {})
assert "scoped-srv" not in _cfg(isolated_profiles["default"]).get("mcp_servers", {})
listing = client.get("/api/mcp/servers", params={"profile": "worker_beta"}).json()
assert any(s["name"] == "scoped-srv" for s in listing["servers"])
listing = client.get("/api/mcp/servers").json()
assert not any(s["name"] == "scoped-srv" for s in listing["servers"])
def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8"
)
resp = client.put(
"/api/mcp/servers/srv1/enabled",
json={"enabled": False, "profile": "worker_beta"},
)
assert resp.status_code == 200
worker_cfg = _cfg(isolated_profiles["worker_beta"])
assert worker_cfg["mcp_servers"]["srv1"]["enabled"] is False
def test_mcp_probe_runs_inside_profile_scope(
self, client, isolated_profiles, monkeypatch
):
"""The test-server probe must execute with the selected profile's
scope active so env-placeholder expansion reads the profile's .env,
matching the config the server was saved into."""
import hermes_cli.mcp_config as mcp_config
from hermes_constants import get_hermes_home
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"mcp_servers:\n probe-srv:\n url: http://x/sse\n",
encoding="utf-8",
)
seen = {}
def fake_probe(name, config, connect_timeout=30):
seen["home"] = str(get_hermes_home())
return [("tool-a", "desc")]
monkeypatch.setattr(mcp_config, "_probe_single_server", fake_probe)
resp = client.post(
"/api/mcp/servers/probe-srv/test", params={"profile": "worker_beta"}
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
assert seen["home"] == str(isolated_profiles["worker_beta"])
def test_mcp_remove_scoped(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"mcp_servers:\n srv2:\n url: http://x/sse\n", encoding="utf-8"
)
# Removing from the DASHBOARD's profile must 404 (srv2 lives in worker).
resp = client.delete("/api/mcp/servers/srv2")
assert resp.status_code == 404
resp = client.delete("/api/mcp/servers/srv2", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert "srv2" not in _cfg(isolated_profiles["worker_beta"]).get("mcp_servers", {})
class TestProfileScopedModel:
def test_model_set_main_scoped(self, client, isolated_profiles):
resp = client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "openrouter",
"model": "test/model-1",
"confirm_expensive_model": True,
"profile": "worker_beta",
},
)
assert resp.status_code == 200
worker_cfg = _cfg(isolated_profiles["worker_beta"])
model_cfg = worker_cfg.get("model", {})
assert isinstance(model_cfg, dict)
assert model_cfg.get("provider") == "openrouter"
default_model = _cfg(isolated_profiles["default"]).get("model", {})
if isinstance(default_model, dict):
assert default_model.get("default") != "test/model-1"
def test_auxiliary_read_scoped_matches_write_target(
self, client, isolated_profiles
):
"""Reads and writes must scope symmetrically: an aux pin written to
the worker profile must show up ONLY in the worker-scoped read.
(Regression: /api/model/auxiliary used to read unscoped while
/api/model/set wrote scoped the Models page displayed the
dashboard profile's pins while editing the selected profile's.)"""
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"auxiliary:\n vision:\n provider: openrouter\n"
" model: worker/vision-pin\n",
encoding="utf-8",
)
resp = client.get("/api/model/auxiliary", params={"profile": "worker_beta"})
assert resp.status_code == 200
vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision")
assert vision["model"] == "worker/vision-pin"
# Unscoped read = the dashboard's own profile, which has no pin.
resp = client.get("/api/model/auxiliary")
assert resp.status_code == 200
vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision")
assert vision["model"] != "worker/vision-pin"
def test_auxiliary_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/model/auxiliary", params={"profile": "ghost"})
assert resp.status_code == 404
def test_model_options_scoped_to_profile(self, client, isolated_profiles):
"""The Models picker must read the SAME profile model/set writes —
current model/provider in the payload come from the scoped config."""
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"model:\n provider: openrouter\n default: worker/current-pin\n",
encoding="utf-8",
)
resp = client.get("/api/model/options", params={"profile": "worker_beta"})
assert resp.status_code == 200
body = resp.json()
# The payload carries the current selection somewhere stable; assert
# the worker pin appears in the scoped response and not the unscoped.
assert "worker/current-pin" in resp.text
resp = client.get("/api/model/options")
assert resp.status_code == 200
assert "worker/current-pin" not in resp.text
assert isinstance(body, dict)
def test_model_options_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/model/options", params={"profile": "ghost"})
assert resp.status_code == 404
def test_model_info_unknown_profile_404(self, client, isolated_profiles):
"""Regression: the broad except used to convert the 404 into a 200
with empty model info ("no model set" silently wrong)."""
resp = client.get("/api/model/info", params={"profile": "ghost"})
assert resp.status_code == 404
def test_mcp_catalog_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/mcp/catalog", params={"profile": "ghost"})
assert resp.status_code == 404
class TestProfileScopedPostSetup:
def test_post_setup_spawns_with_profile_flag(
self, client, isolated_profiles, monkeypatch
):
"""Post-setup runs in a -p scoped subprocess so hooks that read
config / write per-profile state see the same HERMES_HOME the rest
of the drawer's writes targeted."""
import hermes_cli.web_server as web_server
calls = []
class _FakeProc:
pid = 777
monkeypatch.setattr(
web_server,
"_spawn_hermes_action",
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
)
monkeypatch.setattr(
"hermes_cli.tools_config.valid_post_setup_keys",
lambda: {"agent_browser"},
)
resp = client.post(
"/api/tools/toolsets/browser/post-setup",
json={"key": "agent_browser", "profile": "worker_beta"},
)
assert resp.status_code == 200
assert calls == [
["-p", "worker_beta", "tools", "post-setup", "agent_browser"]
]
def test_post_setup_without_profile_keeps_legacy_argv(
self, client, isolated_profiles, monkeypatch
):
import hermes_cli.web_server as web_server
calls = []
class _FakeProc:
pid = 777
monkeypatch.setattr(
web_server,
"_spawn_hermes_action",
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
)
monkeypatch.setattr(
"hermes_cli.tools_config.valid_post_setup_keys",
lambda: {"agent_browser"},
)
resp = client.post(
"/api/tools/toolsets/browser/post-setup",
json={"key": "agent_browser"},
)
assert resp.status_code == 200
assert calls == [["tools", "post-setup", "agent_browser"]]
class TestProfileScopedChatPty:
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
assert env is not None
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
# Scoped chat must NOT attach to the dashboard's in-memory gateway.
assert "HERMES_TUI_GATEWAY_URL" not in env
def test_chat_argv_unscoped_keeps_legacy_env(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
argv, cwd, env = web_server._resolve_chat_argv()
assert env is not None
assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"])
def test_chat_argv_unknown_profile_raises(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
# Reuse the HTTPException class web_server itself raises — avoids a
# direct fastapi import (unresolvable in the ty lint environment).
with pytest.raises(web_server.HTTPException) as exc:
web_server._resolve_chat_argv(profile="ghost")
assert exc.value.status_code == 404

View file

@ -64,6 +64,10 @@ import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { AuthWidget } from "@/components/AuthWidget";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { ProfileProvider } from "@/contexts/ProfileProvider";
import { useProfileScope } from "@/contexts/useProfileScope";
import { ProfileSwitcher } from "@/components/ProfileSwitcher";
import { ProfileScopeBanner } from "@/components/ProfileScopeBanner";
import { useSystemActions } from "@/contexts/useSystemActions";
import type { SystemAction } from "@/contexts/system-actions-context";
import ConfigPage from "@/pages/ConfigPage";
@ -474,6 +478,7 @@ export default function App() {
}, []);
return (
<ProfileProvider>
<div
data-layout-variant={layoutVariant}
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
@ -528,6 +533,7 @@ export default function App() {
)}
<PluginSlot name="header-banner" />
<ProfileScopeBanner />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
<div className="flex min-h-0 min-w-0 flex-1">
@ -602,6 +608,8 @@ export default function App() {
</Button>
</div>
<ProfileSwitcher collapsed={isDesktopCollapsed} />
<nav
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
aria-label={t.app.navigation}
@ -727,17 +735,19 @@ export default function App() {
"min-h-0 flex flex-1 flex-col",
)}
>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
}
/>
</Routes>
<ProfileKeyedRoutes>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
}
/>
</Routes>
</ProfileKeyedRoutes>
{embeddedChat &&
!chatOverriddenByPlugin &&
@ -775,9 +785,25 @@ export default function App() {
<PluginSlot name="overlay" />
</div>
</ProfileProvider>
);
}
/**
* Remounts the entire routed page tree when the global management profile
* changes. Pages load their data on mount; without this, a page opened
* under profile A would keep showing A's state while writes (via the
* fetchJSON ?profile= injection) silently targeted the newly selected
* profile B the exact stale-target footgun the switcher exists to kill.
* Keying by profile resets every page's local state so it refetches under
* the new scope. The persistent ChatPage host below handles its own
* remount (channel keyed on scopedProfile).
*/
function ProfileKeyedRoutes({ children }: { children: ReactNode }) {
const { profile } = useProfileScope();
return <div key={profile || "__own__"} className="contents">{children}</div>;
}
function SidebarNavLink({
closeMobile,
collapsed,

View file

@ -0,0 +1,30 @@
import { Users } from "lucide-react";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
/**
* App-wide amber banner shown while the global switcher targets a profile
* OTHER than the dashboard's own every management write (config, keys,
* skills, MCPs, model) and new Chat sessions land in that profile.
*/
export function ProfileScopeBanner() {
const { profile, currentProfile } = useProfileScope();
const { t } = useI18n();
if (!profile || profile === currentProfile) return null;
return (
// mt-14 on mobile clears the fixed lg:hidden header (h-14, z-40) so the
// scope banner — the main safety signal for scoped writes — is never
// hidden behind it; lg:mt-0 restores desktop flow.
<div className="mt-14 lg:mt-0 flex items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-1.5 text-xs text-amber-300">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{(
t.app.managingProfileBanner ??
"Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile."
).replace("{name}", profile)}
</span>
</div>
);
}

View file

@ -0,0 +1,67 @@
import { Users } from "lucide-react";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* The machine dashboard's single write-target selector.
*
* Rendered in the sidebar above the nav. Every management page (Config,
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
* fetchJSON ?profile= injection. Hidden when only one profile exists.
*/
export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
const { t } = useI18n();
if (profiles.length < 2) return null;
const managed = profile || currentProfile || "default";
const isOther = !!profile && profile !== currentProfile;
return (
<div
className={cn(
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
collapsed && "lg:justify-center lg:px-0",
)}
title={t.app.managingProfile ?? "Managing profile"}
>
<Users
className={cn(
"h-3.5 w-3.5 shrink-0",
isOther ? "text-amber-300" : "text-text-tertiary",
)}
/>
<select
aria-label={t.app.managingProfile ?? "Managing profile"}
className={cn(
"h-7 w-full min-w-0 rounded-none border bg-background px-1 text-xs",
isOther
? "border-amber-500/50 text-amber-300"
: "border-border text-text-secondary",
collapsed && "lg:hidden",
)}
value={profile}
onChange={(e) => setProfile(e.target.value)}
>
<option value="">
{(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
"{name}",
currentProfile || "default",
)}
</option>
{profiles
.filter((name) => name !== currentProfile)
.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{collapsed && (
<span className="sr-only">{managed}</span>
)}
</div>
);
}

View file

@ -198,7 +198,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
setPostSetupLog([]);
setPostSetupKey(provider.post_setup);
try {
await api.runToolsetPostSetup(toolset.name, provider.post_setup);
await api.runToolsetPostSetup(toolset.name, provider.post_setup, profile);
// Bump the trigger so the poll effect (re)starts tailing the log.
setPostSetupTrigger((n) => n + 1);
} catch (e) {

View file

@ -0,0 +1,115 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { api, setManagementProfile } from "@/lib/api";
import { ProfileContext } from "@/contexts/profile-context";
/**
* Machine-level management-profile scope.
*
* One switcher (rendered in the sidebar) decides which profile every
* management page reads/writes. React STATE is the source of truth; the
* URL (`?profile=<name>`) is a synchronized projection of it so deep links
* land scoped and refresh survives. The selection is mirrored into the api
* module so `fetchJSON` transparently appends it to the profile-scoped
* endpoint families. "" = the dashboard's own profile.
*
* Why state-first instead of URL-first: sidebar nav links are bare paths
* (`/config`, `/skills`). A URL-derived scope would silently reset to the
* dashboard's own profile on every nav click the switcher would LOOK
* global while normal navigation dropped the write target. With state as
* truth, the effect below re-asserts `?profile=` onto the new location
* after each navigation, so the scope survives nav and stays deep-linkable.
*
* This exists because "Set as active" on the Profiles page only flips the
* sticky active_profile file (future CLI/gateway runs) it cannot retarget
* the running dashboard. The switcher is the dashboard's own, visible,
* write-target selector.
*/
export function ProfileProvider({ children }: { children: ReactNode }) {
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [profiles, setProfiles] = useState<string[]>([]);
const [currentProfile, setCurrentProfile] = useState("default");
// Initial value comes from the URL (deep link / refresh / unified-launch
// preselect); afterwards state leads and the URL follows.
const [profile, setProfileState] = useState(
() => searchParams.get("profile") ?? "",
);
// Mirror into the api module synchronously on every render where it
// changed, so fetches fired by child effects in the same commit see it.
setManagementProfile(profile);
// A profile param arriving via in-app navigation (e.g. the Profiles
// page's "Manage skills & tools" linking to /skills?profile=X) must win
// over current state — it's an explicit scope request.
const urlProfile = searchParams.get("profile");
useEffect(() => {
if (urlProfile !== null && urlProfile !== profile) {
setManagementProfile(urlProfile);
setProfileState(urlProfile);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlProfile]);
// Re-assert ?profile= after navigations that dropped it (bare nav links).
// Runs on every pathname/profile change; no-ops when already in sync.
useEffect(() => {
const inUrl = searchParams.get("profile") ?? "";
if ((profile || "") === inUrl) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (profile) next.set("profile", profile);
else next.delete("profile");
return next;
},
{ replace: true },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, profile]);
useEffect(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles.map((p) => p.name)))
.catch(() => {});
api
.getActiveProfile()
.then((info) => setCurrentProfile(info.current || "default"))
.catch(() => {});
}, []);
const setProfile = useCallback(
(name: string) => {
setManagementProfile(name);
setProfileState(name);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const value = useMemo(
() => ({ profile, currentProfile, profiles, setProfile }),
[profile, currentProfile, profiles, setProfile],
);
return (
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
);
}

View file

@ -0,0 +1,19 @@
import { createContext } from "react";
export interface ProfileContextValue {
/** Profile every management surface reads/writes ("" = the dashboard
* process's own profile). */
profile: string;
/** The profile the dashboard process itself runs under. */
currentProfile: string;
/** Known profile names (includes "default"). */
profiles: string[];
setProfile: (name: string) => void;
}
export const ProfileContext = createContext<ProfileContextValue>({
profile: "",
currentProfile: "default",
profiles: [],
setProfile: () => {},
});

View file

@ -0,0 +1,6 @@
import { useContext } from "react";
import { ProfileContext } from "@/contexts/profile-context";
export function useProfileScope() {
return useContext(ProfileContext);
}

View file

@ -93,6 +93,10 @@ export const en: Translations = {
statusOverview: "Status overview",
system: "System",
webUi: "Web UI",
managingProfile: "Managing profile",
currentProfileOption: "this dashboard ({name})",
managingProfileBanner:
"Managing profile \u201c{name}\u201d \u2014 config, keys, skills, MCPs, model, and new chats apply to that profile.",
},
status: {

View file

@ -110,6 +110,10 @@ export interface Translations {
statusOverview: string;
system: string;
webUi: string;
/** Optional — fall back to English literals until translated. */
managingProfile?: string;
currentProfileOption?: string;
managingProfileBanner?: string;
};
// ── Status page ──

View file

@ -41,11 +41,54 @@ function setSessionHeader(headers: Headers, token: string): void {
}
}
// ── Global management-profile scope ──────────────────────────────────
// The dashboard is a machine-level management surface: one header switcher
// (ProfileProvider in App.tsx) decides which profile the management pages
// read/write, and fetchJSON transparently appends ?profile=<name> to the
// profile-scoped endpoint families below. "" = the dashboard process's own
// profile (legacy behavior). Calls that already carry an explicit profile
// (e.g. ProfileBuilder writes) are left untouched — explicit beats global.
let _managementProfile = "";
export function setManagementProfile(name: string): void {
_managementProfile = (name || "").trim();
}
export function getManagementProfile(): string {
return _managementProfile;
}
// Endpoint families that honor ?profile= on the backend (web_server.py
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
// channels, cron (which has its own per-job profile params), profiles
// themselves — is machine-global or self-scoped and must NOT be rewritten.
const PROFILE_SCOPED_PREFIXES = [
"/api/skills",
"/api/tools/toolsets",
"/api/config",
"/api/env",
"/api/mcp",
"/api/model/info",
"/api/model/set",
"/api/model/auxiliary",
"/api/model/options",
];
function withManagementProfile(url: string): string {
if (!_managementProfile) return url;
if (url.includes("profile=")) return url; // explicit param wins
const path = url.split("?")[0];
if (!PROFILE_SCOPED_PREFIXES.some((p) => path.startsWith(p))) return url;
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}profile=${encodeURIComponent(_managementProfile)}`;
}
export async function fetchJSON<T>(
url: string,
init?: RequestInit,
options?: FetchJSONOptions,
): Promise<T> {
url = withManagementProfile(url);
// Inject the session token into all /api/ requests.
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;
@ -595,13 +638,13 @@ export const api = {
body: JSON.stringify({ env, profile: profile || undefined }),
},
),
runToolsetPostSetup: (name: string, key: string) =>
runToolsetPostSetup: (name: string, key: string, profile?: string) =>
fetchJSON<ActionResponse & { key: string }>(
`/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
body: JSON.stringify({ key, profile: profile || undefined }),
},
),

View file

@ -37,11 +37,13 @@ import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";
function buildWsUrl(
authParam: [string, string],
resume: string | null,
channel: string,
profile: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
// ``authParam`` is ``["token", <session>]`` in loopback mode and
@ -49,6 +51,10 @@ function buildWsUrl(
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
if (resume) qs.set("resume", resume);
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
// selected profile, so the conversation runs with that profile's model,
// skills, memory, and sessions (see web_server._resolve_chat_argv).
if (profile) qs.set("profile", profile);
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
}
@ -173,7 +179,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// treat the current resume target as part of the PTY identity and rebuild the
// terminal session when it changes.
const resumeParam = searchParams.get("resume");
const channel = useMemo(() => generateChannelId(), [resumeParam]);
// Profile-scoped chat: spawn the PTY under the globally selected
// management profile. Changing it remounts the terminal (key below /
// effect dep) so the user explicitly starts a fresh scoped session.
const { profile: scopedProfile } = useProfileScope();
const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]);
useEffect(() => {
if (!resumeParam) return;
@ -576,7 +586,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
void (async () => {
const authParam = await buildWsAuthParam();
if (unmounting) return;
const url = buildWsUrl(authParam, resumeParam, channel);
const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile);
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
@ -714,7 +724,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
copyResetRef.current = null;
}
};
}, [channel, resumeParam]);
}, [channel, resumeParam, scopedProfile]);
// When the user returns to the chat tab (isActive: false → true), the
// terminal host just transitioned from display:none to display:flex.

View file

@ -25,7 +25,6 @@ import {
AlertTriangle,
Sparkles,
Loader2,
Users,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
@ -36,9 +35,8 @@ import type {
SkillHubInstalledEntry,
SkillHubPreview,
SkillHubScan,
ProfileInfo,
} from "@/lib/api";
import { useSearchParams } from "react-router-dom";
import { useProfileScope } from "@/contexts/useProfileScope";
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
@ -137,51 +135,15 @@ export default function SkillsPage() {
const { setAfterTitle, setEnd } = usePageHeader();
// ── Profile scoping ──
// The dashboard process runs under ONE profile, but skills/toolsets are
// per-profile state. Without an explicit selector, users who "activated"
// a profile on the Profiles page (which only affects FUTURE CLI/gateway
// runs) toggled skills here and silently wrote into the dashboard's own
// profile. The selector makes the write target explicit and deep-linkable
// via /skills?profile=<name>.
const [searchParams, setSearchParams] = useSearchParams();
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [currentProfile, setCurrentProfile] = useState<string>("");
const urlProfile = searchParams.get("profile") ?? "";
// "" = the dashboard's own profile (legacy behavior).
const selectedProfile = urlProfile;
const setSelectedProfile = useCallback(
(name: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
// The profile actually being managed, for display purposes.
const managedProfile = selectedProfile || currentProfile || "default";
const managingOtherProfile =
!!selectedProfile && selectedProfile !== currentProfile;
useEffect(() => {
// Profile list + the dashboard's own profile, for the selector. Failure
// leaves the selector hidden — the page still works profile-unscoped.
api
.getProfiles()
.then((res) => setProfiles(res.profiles))
.catch(() => {});
api
.getActiveProfile()
.then((info) => setCurrentProfile(info.current || "default"))
.catch(() => setCurrentProfile("default"));
}, []);
// The write target comes from the GLOBAL profile switcher (sidebar) via
// ProfileContext — one selector for the whole dashboard, deep-linkable
// as ?profile=<name>. This page just consumes it: the fetchJSON layer
// appends the param automatically; we still pass it explicitly where the
// call signature supports it (clearer, and robust if a caller bypasses
// the auto-injection).
const {
profile: selectedProfile,
} = useProfileScope();
useEffect(() => {
// Promise-chain shape: setState fires only inside async callbacks so the
@ -298,33 +260,6 @@ export default function SkillsPage() {
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
{profiles.length > 1 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
<select
aria-label={t.skills.profileSelector ?? "Profile"}
className="h-6 rounded-none border border-border bg-background px-1 text-xs text-foreground"
value={selectedProfile}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setSelectedProfile(e.target.value)
}
>
<option value="">
{(t.skills.currentProfile ?? "current ({name})").replace(
"{name}",
currentProfile || "default",
)}
</option>
{profiles
.filter((p) => p.name !== currentProfile)
.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
</span>
)}
</span>,
);
setEnd(
@ -361,10 +296,6 @@ export default function SkillsPage() {
setEnd,
skills.length,
t,
profiles,
selectedProfile,
currentProfile,
setSelectedProfile,
]);
const filteredToolsets = useMemo(() => {
@ -391,18 +322,6 @@ export default function SkillsPage() {
<PluginSlot name="skills:top" />
<Toast toast={toast} />
{managingOtherProfile && (
<div className="flex items-center gap-2 border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{(
t.skills.managingProfile ??
"Managing profile “{name}” — toggles apply to that profile, not this dashboards."
).replace("{name}", managedProfile)}
</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-0">

View file

@ -1350,6 +1350,7 @@ Launch the web dashboard — a browser-based UI for managing configuration, API
| `--host` | `127.0.0.1` | Bind address |
| `--no-open` | — | Don't auto-open the browser |
| `--insecure` | off | Allow binding to non-localhost hosts. Exposes dashboard credentials on the network; use only behind trusted network controls. |
| `--isolated` | off | When launched from a named profile (`worker dashboard`), run a dedicated per-profile server instead of routing to the machine dashboard. |
| `--stop` | — | Stop running `hermes dashboard` processes and exit. |
| `--status` | — | List running `hermes dashboard` processes and exit. |
@ -1359,6 +1360,10 @@ hermes dashboard
# Custom port, no browser
hermes dashboard --port 8080 --no-open
# From a profile alias — routes to the machine dashboard with the
# profile preselected in the sidebar switcher (attach if running)
worker dashboard
```
## `hermes profile`

View file

@ -28,6 +28,7 @@ This starts a local web server and opens `http://127.0.0.1:9119` in your browser
| `--host` | `127.0.0.1` | Bind address |
| `--no-open` | — | Don't auto-open the browser |
| `--insecure` | off | Allow binding to non-localhost hosts (**DANGEROUS** — exposes API keys on the network; pair with a firewall and strong auth) |
| `--isolated` | off | When launched from a named profile (`worker dashboard`), run a dedicated per-profile server instead of routing to the machine dashboard |
```bash
# Custom port
@ -40,6 +41,43 @@ hermes dashboard --host 0.0.0.0
hermes dashboard --no-open
```
## Managing multiple profiles
The dashboard is a **machine-level** management surface: one server manages
every [profile](../profiles.md) on the machine. A profile switcher in the
sidebar (visible whenever more than one profile exists) decides which
profile the management pages read and write — Config, API Keys, Skills,
MCP, Models, and the Chat tab all follow it. While a profile other than
the dashboard's own is selected, an amber banner names the managed profile
so the write target is never ambiguous.
The selection lives in the URL (`?profile=<name>`), so deep links like
`http://127.0.0.1:9119/skills?profile=worker` land with the switcher
preselected and survive refresh.
Launching the dashboard from a profile alias routes to the machine
dashboard instead of starting a second server:
```bash
worker dashboard
# → already running: opens the browser at ?profile=worker
# → not running: starts the machine dashboard with "worker" preselected
```
Pass `--isolated` to opt out and run a dedicated server scoped to that
profile (the pre-unification behavior — useful if you deliberately expose
different profiles' dashboards with different auth).
The **Chat** tab follows the switcher too: a scoped chat spawns its PTY
child with the selected profile's `HERMES_HOME`, so the conversation runs
with that profile's model, skills, memory, and session history. Switching
profiles starts a fresh terminal session.
What stays per-profile and is *not* absorbed by the switcher: gateway
processes (manage them via `hermes -p <name> gateway …`), each profile's
session database, and cron schedulers (the Cron page already aggregates
across profiles with its own filter).
## Prerequisites
The default `hermes-agent` install does not ship the HTTP stack or PTY helper — those are optional extras. The **web dashboard** needs FastAPI and Uvicorn (`web` extra). The **Chat** tab also needs `ptyprocess` to spawn the embedded TUI behind a pseudo-terminal (`pty` extra on POSIX). Install both with:
@ -234,6 +272,17 @@ Create and manage scheduled cron jobs that run agent prompts on a recurring sche
- **Trigger now** — immediately execute a job outside its normal schedule
- **Delete** — permanently remove a cron job
### Profiles
Create and manage [profiles](../profiles.md) — isolated Hermes instances with their own config, skills, and sessions.
- **Profile cards** — each shows its model/provider, skill count, gateway state, description, and badges (active, default, alias)
- **Create** — name + optional clone-from-default / clone-everything / no-bundled-skills, description, and model; the dedicated Profile Builder page (`/profiles/new`) offers the full flow (model, MCPs, skills)
- **Manage skills & tools** — jumps to the Skills page scoped to that profile (sets the sidebar profile switcher)
- **Set as active** — flips the sticky default that **future CLI/gateway runs** pick up (same as `hermes profile use`). This does *not* change what the dashboard manages — that's the profile switcher's job
- **Edit model / description / SOUL** — inline editors writing into that profile
- **Rename / Delete** — named profiles only
### Skills
Browse, search, and toggle installed skills and toolsets, and install new ones from the hub. Skills are loaded from `~/.hermes/skills/` and grouped by category.
@ -349,6 +398,16 @@ This re-reads `~/.hermes/.env` into the running process's environment. Useful wh
The web dashboard exposes a REST API that the frontend consumes. You can also call these endpoints directly for automation:
:::tip Profile-scoped endpoints
The management endpoint families — `/api/config`, `/api/env`, `/api/skills`,
`/api/tools/toolsets`, `/api/mcp`, and `/api/model/{info,options,auxiliary,set}`
accept an optional `?profile=<name>` query parameter (or `"profile"` in the
JSON body for writes) that scopes the read/write to that profile's
`HERMES_HOME`. Omitted = the dashboard's own profile. Unknown profile names
return `404`. The `/api/pty` WebSocket accepts the same parameter to spawn
a chat under the selected profile.
:::
### GET /api/status
Returns agent version, gateway status, platform states, and active session count.

View file

@ -199,6 +199,20 @@ If you want this profile to work in a specific project by default, also set its
coder config set terminal.cwd /absolute/path/to/project
```
### From the dashboard
The [web dashboard](features/web-dashboard.md#managing-multiple-profiles)
is a machine-level surface that can manage **any** profile's config, API
keys, skills, MCPs, and model via the profile switcher in its sidebar — no
per-profile dashboard needed. `coder dashboard` routes to the machine
dashboard with the `coder` profile preselected. The dashboard's Chat tab
also follows the switcher, spawning a conversation under the selected
profile's home.
Note: "Set as active" on the dashboard's Profiles page is the sticky
default for **future CLI/gateway runs** (same as `hermes profile use`) —
to edit a profile from the dashboard, use the switcher instead.
## Updating
`hermes update` pulls code once (shared) and syncs new bundled skills to **all** profiles automatically: