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
+320 -189
View File
@@ -625,19 +625,23 @@ CONFIG_SCHEMA = _ordered_schema
class ConfigUpdate(BaseModel):
config: dict
profile: Optional[str] = None
class EnvVarUpdate(BaseModel):
key: str
value: str
profile: Optional[str] = None
class EnvVarDelete(BaseModel):
key: str
profile: Optional[str] = None
class EnvVarReveal(BaseModel):
key: str
profile: Optional[str] = None
class MessagingPlatformUpdate(BaseModel):
@@ -716,6 +720,7 @@ class ModelAssignment(BaseModel):
# the path that actually wires a local endpoint into resolution.
base_url: str = ""
confirm_expensive_model: bool = False
profile: Optional[str] = None
def _apply_main_model_assignment(
@@ -2495,8 +2500,9 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
@app.get("/api/config")
async def get_config():
config = _normalize_config_for_web(load_config())
async def get_config(profile: Optional[str] = None):
with _profile_scope(profile):
config = _normalize_config_for_web(load_config())
# Strip internal keys that the frontend shouldn't see or send back
return {k: v for k, v in config.items() if not k.startswith("_")}
@@ -2522,7 +2528,7 @@ _EMPTY_MODEL_INFO: dict = {
@app.get("/api/model/info")
def get_model_info():
def get_model_info(profile: Optional[str] = None):
"""Return resolved model metadata for the currently configured model.
Calls the same context-length resolution chain the agent uses, so the
@@ -2530,7 +2536,8 @@ def get_model_info():
Also returns model capabilities (vision, reasoning, tools) when available.
"""
try:
cfg = load_config()
with _profile_scope(profile):
cfg = load_config()
model_cfg = cfg.get("model", "")
# Extract model name and provider from the config
@@ -2772,7 +2779,7 @@ def get_auxiliary_models():
@app.post("/api/model/set")
async def set_model_assignment(body: ModelAssignment):
async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = None):
"""Assign a model to the main slot or an auxiliary task slot.
Writes to ``~/.hermes/config.yaml`` applies to **new** sessions only.
@@ -2789,8 +2796,10 @@ async def set_model_assignment(body: ModelAssignment):
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
try:
cfg = load_config()
# Expensive-model warning runs BEFORE the profile scope is entered:
# _profile_scope must never be held across an await (the RLock is
# reentrant per-thread, so a second coroutine interleaving on the
# event-loop thread could cross-restore the module globals).
if model and not body.confirm_expensive_model:
try:
from hermes_cli.model_cost_guard import expensive_model_warning
@@ -2815,125 +2824,13 @@ async def set_model_assignment(body: ModelAssignment):
"confirm_message": warning.message,
}
if scope == "main":
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
model_cfg = _apply_main_model_assignment(
cfg.get("model", {}), provider, model, base_url
)
cfg["model"] = model_cfg
def _apply_assignment():
with _profile_scope(profile or body.profile):
return _apply_model_assignment_sync(
scope, provider, model, task, base_url
)
# When switching the main provider to Nous, mirror the CLI's
# post-model-selection behaviour (hermes_cli/main.py
# prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults):
# auto-route any *unconfigured* tools through the Nous Tool Gateway.
# This is purely additive — apply_nous_managed_defaults skips every
# tool where the user already has a direct key (FIRECRAWL_API_KEY,
# FAL_KEY, etc.) or an explicit backend/provider in config, so it
# never overwrites a user's own setup. GUI users thus land on the
# gateway the same way CLI users do, without a separate prompt.
gateway_tools: list[str] = []
if provider.strip().lower() == "nous":
try:
from hermes_cli.nous_subscription import apply_nous_managed_defaults
from hermes_cli.tools_config import _get_platform_tools
enabled = _get_platform_tools(
cfg, "cli", include_default_mcp_servers=False
)
changed = apply_nous_managed_defaults(
cfg,
enabled_toolsets=enabled,
force_fresh=True,
)
gateway_tools = sorted(changed)
except Exception:
# Portal lookup hiccups / non-subscriber / non-nous gating
# must never block saving the model assignment.
_log.debug("apply_nous_managed_defaults skipped", exc_info=True)
save_config(cfg)
# Surface auxiliary slots still pinned to a *different* provider than
# the new main one. Switching the main model does NOT touch aux pins
# (they're independent, sticky per-task overrides — see
# auxiliary_client._resolve_auto). A user who switches main away from
# a now-unpaid provider (e.g. nous with $0 balance) keeps paying 402s
# on every background aux call until they reset those pins. We never
# auto-clear them — pinning aux to a cheaper/different model is a
# legitimate config — but we tell the caller so the UI can offer a
# "reset to main" nudge instead of silently burning credits.
new_provider = provider.strip().lower()
stale_aux: list[dict] = []
aux_cfg = cfg.get("auxiliary", {})
if isinstance(aux_cfg, dict):
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux_cfg.get(slot)
if not isinstance(slot_cfg, dict):
continue
slot_provider = str(slot_cfg.get("provider", "") or "").strip()
if (
slot_provider
and slot_provider.lower() not in {"auto", ""}
and slot_provider.lower() != new_provider
):
stale_aux.append({
"task": slot,
"provider": slot_provider,
"model": str(slot_cfg.get("model", "") or ""),
})
return {
"ok": True,
"scope": "main",
"provider": provider,
"model": model,
"base_url": model_cfg.get("base_url", ""),
"gateway_tools": gateway_tools,
"stale_aux": stale_aux,
}
# scope == "auxiliary"
aux = cfg.get("auxiliary")
if not isinstance(aux, dict):
aux = {}
if task == "__reset__":
# Reset every slot to provider="auto", model="" — keeps other fields intact.
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = "auto"
slot_cfg["model"] = ""
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {"ok": True, "scope": "auxiliary", "reset": True}
if not provider:
raise HTTPException(status_code=400, detail="provider required for auxiliary")
targets = [task] if task else list(_AUX_TASK_SLOTS)
for slot in targets:
if slot not in _AUX_TASK_SLOTS:
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = provider
slot_cfg["model"] = model
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {
"ok": True,
"scope": "auxiliary",
"tasks": targets,
"provider": provider,
"model": model,
}
return await asyncio.to_thread(_apply_assignment)
except HTTPException:
raise
except Exception:
@@ -2941,6 +2838,138 @@ async def set_model_assignment(body: ModelAssignment):
raise HTTPException(status_code=500, detail="Failed to save model assignment")
def _apply_model_assignment_sync(
scope: str, provider: str, model: str, task: str, base_url: str
):
"""Synchronous body of POST /api/model/set.
Runs inside ``_profile_scope`` (in a worker thread) so every
load_config/save_config lands in the requested profile. Raises
HTTPException for validation errors the async wrapper re-raises them.
"""
cfg = load_config()
if scope == "main":
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
model_cfg = _apply_main_model_assignment(
cfg.get("model", {}), provider, model, base_url
)
cfg["model"] = model_cfg
# When switching the main provider to Nous, mirror the CLI's
# post-model-selection behaviour (hermes_cli/main.py
# prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults):
# auto-route any *unconfigured* tools through the Nous Tool Gateway.
# This is purely additive — apply_nous_managed_defaults skips every
# tool where the user already has a direct key (FIRECRAWL_API_KEY,
# FAL_KEY, etc.) or an explicit backend/provider in config, so it
# never overwrites a user's own setup. GUI users thus land on the
# gateway the same way CLI users do, without a separate prompt.
gateway_tools: list[str] = []
if provider.strip().lower() == "nous":
try:
from hermes_cli.nous_subscription import apply_nous_managed_defaults
from hermes_cli.tools_config import _get_platform_tools
enabled = _get_platform_tools(
cfg, "cli", include_default_mcp_servers=False
)
changed = apply_nous_managed_defaults(
cfg,
enabled_toolsets=enabled,
force_fresh=True,
)
gateway_tools = sorted(changed)
except Exception:
# Portal lookup hiccups / non-subscriber / non-nous gating
# must never block saving the model assignment.
_log.debug("apply_nous_managed_defaults skipped", exc_info=True)
save_config(cfg)
# Surface auxiliary slots still pinned to a *different* provider than
# the new main one. Switching the main model does NOT touch aux pins
# (they're independent, sticky per-task overrides — see
# auxiliary_client._resolve_auto). A user who switches main away from
# a now-unpaid provider (e.g. nous with $0 balance) keeps paying 402s
# on every background aux call until they reset those pins. We never
# auto-clear them — pinning aux to a cheaper/different model is a
# legitimate config — but we tell the caller so the UI can offer a
# "reset to main" nudge instead of silently burning credits.
new_provider = provider.strip().lower()
stale_aux: list[dict] = []
aux_cfg = cfg.get("auxiliary", {})
if isinstance(aux_cfg, dict):
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux_cfg.get(slot)
if not isinstance(slot_cfg, dict):
continue
slot_provider = str(slot_cfg.get("provider", "") or "").strip()
if (
slot_provider
and slot_provider.lower() not in {"auto", ""}
and slot_provider.lower() != new_provider
):
stale_aux.append({
"task": slot,
"provider": slot_provider,
"model": str(slot_cfg.get("model", "") or ""),
})
return {
"ok": True,
"scope": "main",
"provider": provider,
"model": model,
"base_url": model_cfg.get("base_url", ""),
"gateway_tools": gateway_tools,
"stale_aux": stale_aux,
}
# scope == "auxiliary"
aux = cfg.get("auxiliary")
if not isinstance(aux, dict):
aux = {}
if task == "__reset__":
# Reset every slot to provider="auto", model="" — keeps other fields intact.
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = "auto"
slot_cfg["model"] = ""
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {"ok": True, "scope": "auxiliary", "reset": True}
if not provider:
raise HTTPException(status_code=400, detail="provider required for auxiliary")
targets = [task] if task else list(_AUX_TASK_SLOTS)
for slot in targets:
if slot not in _AUX_TASK_SLOTS:
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = provider
slot_cfg["model"] = model
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {
"ok": True,
"scope": "auxiliary",
"tasks": targets,
"provider": provider,
"model": model,
}
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
@@ -2996,18 +3025,22 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
@app.put("/api/config")
async def update_config(body: ConfigUpdate):
async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
try:
save_config(_denormalize_config_from_web(body.config))
with _profile_scope(profile or body.profile):
save_config(_denormalize_config_from_web(body.config))
return {"ok": True}
except HTTPException:
raise
except Exception:
_log.exception("PUT /api/config failed")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/api/env")
async def get_env_vars():
env_on_disk = load_env()
async def get_env_vars(profile: Optional[str] = None):
with _profile_scope(profile):
env_on_disk = load_env()
channel_keys = _channel_managed_env_keys()
result = {}
for var_name, info in OPTIONAL_ENV_VARS.items():
@@ -3030,9 +3063,10 @@ async def get_env_vars():
@app.put("/api/env")
async def set_env_var(body: EnvVarUpdate):
async def set_env_var(body: EnvVarUpdate, profile: Optional[str] = None):
try:
save_env_value(body.key, body.value)
with _profile_scope(profile or body.profile):
save_env_value(body.key, body.value)
return {"ok": True, "key": body.key}
except ValueError as exc:
# save_env_value raises ValueError for invalid names and for keys
@@ -3143,9 +3177,10 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
@app.delete("/api/env")
async def remove_env_var(body: EnvVarDelete):
async def remove_env_var(body: EnvVarDelete, profile: Optional[str] = None):
try:
removed = remove_env_value(body.key)
with _profile_scope(profile or body.profile):
removed = remove_env_value(body.key)
if not removed:
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
return {"ok": True, "key": body.key}
@@ -3162,7 +3197,9 @@ async def remove_env_var(body: EnvVarDelete):
@app.post("/api/env/reveal")
async def reveal_env_var(body: EnvVarReveal, request: Request):
async def reveal_env_var(
body: EnvVarReveal, request: Request, profile: Optional[str] = None
):
"""Return the real (unredacted) value of a single env var.
Protected by:
@@ -3182,7 +3219,8 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
_reveal_timestamps.append(now)
# --- Reveal ---
env_on_disk = load_env()
with _profile_scope(profile or body.profile):
env_on_disk = load_env()
value = env_on_disk.get(body.key)
if value is None:
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
@@ -6387,6 +6425,7 @@ class MCPServerCreate(BaseModel):
env: Dict[str, str] = {}
# auth: "oauth" | "header" | None
auth: Optional[str] = None
profile: Optional[str] = None
def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]:
@@ -6417,10 +6456,11 @@ def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
@app.get("/api/mcp/servers")
async def list_mcp_servers():
async def list_mcp_servers(profile: Optional[str] = None):
from hermes_cli.mcp_config import _get_mcp_servers
servers = _get_mcp_servers()
with _profile_scope(profile):
servers = _get_mcp_servers()
return {
"servers": [
_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
@@ -6429,13 +6469,15 @@ async def list_mcp_servers():
@app.post("/api/mcp/servers")
async def add_mcp_server(body: MCPServerCreate):
async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
from hermes_cli.mcp_config import _get_mcp_servers, _save_mcp_server
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Server name is required")
if name in _get_mcp_servers():
with _profile_scope(profile or body.profile):
existing = _get_mcp_servers()
if name in existing:
raise HTTPException(status_code=409, detail=f"Server '{name}' already exists")
if not body.url and not body.command:
raise HTTPException(
@@ -6456,7 +6498,10 @@ async def add_mcp_server(body: MCPServerCreate):
server_config["auth"] = body.auth
try:
_save_mcp_server(name, server_config)
with _profile_scope(profile or body.profile):
_save_mcp_server(name, server_config)
except HTTPException:
raise
except Exception as exc:
_log.exception("POST /api/mcp/servers failed")
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -6465,20 +6510,23 @@ async def add_mcp_server(body: MCPServerCreate):
@app.delete("/api/mcp/servers/{name}")
async def remove_mcp_server(name: str):
async def remove_mcp_server(name: str, profile: Optional[str] = None):
from hermes_cli.mcp_config import _remove_mcp_server
if not _remove_mcp_server(name):
with _profile_scope(profile):
removed = _remove_mcp_server(name)
if not removed:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
return {"ok": True}
@app.post("/api/mcp/servers/{name}/test")
async def test_mcp_server(name: str):
async def test_mcp_server(name: str, profile: Optional[str] = None):
"""Connect to the server, list its tools, disconnect. Returns tool list."""
from hermes_cli.mcp_config import _get_mcp_servers, _probe_single_server
servers = _get_mcp_servers()
with _profile_scope(profile):
servers = _get_mcp_servers()
if name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
@@ -6500,34 +6548,40 @@ async def test_mcp_server(name: str):
class MCPEnabledToggle(BaseModel):
enabled: bool
profile: Optional[str] = None
@app.put("/api/mcp/servers/{name}/enabled")
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle):
async def set_mcp_server_enabled(
name: str, body: MCPEnabledToggle, profile: Optional[str] = None
):
"""Enable or disable an MCP server (takes effect on next session/gateway).
Toggles the ``enabled`` key on the server's config.yaml entry — the same
flag the agent reads at startup. Disabled servers stay in config so they
can be re-enabled without re-entering their settings.
"""
cfg = load_config()
servers = cfg.get("mcp_servers")
if not isinstance(servers, dict) or name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
if not isinstance(servers[name], dict):
raise HTTPException(status_code=400, detail="Malformed server config")
servers[name]["enabled"] = bool(body.enabled)
save_config(cfg)
with _profile_scope(profile or body.profile):
cfg = load_config()
servers = cfg.get("mcp_servers")
if not isinstance(servers, dict) or name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
if not isinstance(servers[name], dict):
raise HTTPException(status_code=400, detail="Malformed server config")
servers[name]["enabled"] = bool(body.enabled)
save_config(cfg)
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
@app.get("/api/mcp/catalog")
async def list_mcp_catalog():
async def list_mcp_catalog(profile: Optional[str] = None):
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
Each entry reports whether it's already installed and enabled so the UI
can show install / enabled state inline. This is the same catalog
`hermes mcp catalog` / `hermes mcp install` read.
`hermes mcp catalog` / `hermes mcp install` read. ``profile`` scopes
the installed/enabled annotations (the catalog itself is repo-shipped
and identical for every profile).
"""
try:
from hermes_cli import mcp_catalog
@@ -6537,7 +6591,13 @@ async def list_mcp_catalog():
entries = []
try:
for entry in mcp_catalog.list_catalog():
with _profile_scope(profile):
catalog_entries = list(mcp_catalog.list_catalog())
installed_state = {
e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name))
for e in catalog_entries
}
for entry in catalog_entries:
auth = entry.auth
entries.append({
"name": entry.name,
@@ -6551,8 +6611,8 @@ async def list_mcp_catalog():
for e in getattr(auth, "env", []) or []
],
"needs_install": entry.install is not None,
"installed": mcp_catalog.is_installed(entry.name),
"enabled": mcp_catalog.is_enabled(entry.name),
"installed": installed_state.get(entry.name, (False, False))[0],
"enabled": installed_state.get(entry.name, (False, False))[1],
})
except Exception:
_log.exception("list_mcp_catalog failed")
@@ -6574,10 +6634,11 @@ class MCPCatalogInstall(BaseModel):
# env: KEY=VALUE map for catalog entries that declare required env vars.
env: Dict[str, str] = {}
enable: bool = True
profile: Optional[str] = None
@app.post("/api/mcp/catalog/install")
async def install_mcp_catalog_entry(body: MCPCatalogInstall):
async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[str] = None):
"""Install a catalog MCP into config.yaml.
For HTTP/stdio entries with required env vars, those are written to .env
@@ -6594,23 +6655,42 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall):
# Persist any supplied env vars first (catalog entries declare which names
# they need; we only write the ones the user provided).
effective_profile = profile or body.profile
if body.env:
for k, v in body.env.items():
if v:
save_env_value(k, v)
with _profile_scope(effective_profile):
for k, v in body.env.items():
if v:
save_env_value(k, v)
# Git-bootstrap entries can take a while to clone — run via the background
# action path so the request returns immediately and the UI can tail logs.
# The -p subprocess rebinds HERMES_HOME-derived paths in the child.
if entry.install is not None:
try:
proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install")
proc = _spawn_hermes_action(
_profile_cli_args(effective_profile) + ["mcp", "install", name],
"mcp-install",
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
return {"ok": True, "name": name, "background": True, "action": "mcp-install"}
# No git step — install synchronously via the catalog API.
# No git step — install synchronously via the catalog API. install_entry
# routes through load_config/save_config + save_env_value, all call-time
# resolvers, so the context override scopes it. Wrap the to_thread body
# in the scope INSIDE the thread (contextvars don't propagate into
# to_thread the other way around — asyncio.to_thread copies context, so
# setting it here works; keep it explicit for clarity).
def _install_scoped():
with _profile_scope(effective_profile):
mcp_catalog.install_entry(entry, enable=body.enable)
try:
await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable)
await asyncio.to_thread(_install_scoped)
except HTTPException:
raise
except Exception as exc:
_log.exception("install_mcp_catalog_entry failed")
raise HTTPException(status_code=400, detail=str(exc))
@@ -7394,13 +7474,13 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]:
@app.post("/api/skills/hub/install")
async def install_skill_hub(body: SkillInstallRequest):
async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None):
identifier = (body.identifier or "").strip()
if not identifier:
raise HTTPException(status_code=400, detail="identifier is required")
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile) + ["skills", "install", identifier],
_profile_cli_args(profile or body.profile) + ["skills", "install", identifier],
"skills-install",
)
except HTTPException:
@@ -7417,13 +7497,13 @@ class SkillUninstallRequest(BaseModel):
@app.post("/api/skills/hub/uninstall")
async def uninstall_skill_hub(body: SkillUninstallRequest):
async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None):
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile) + ["skills", "uninstall", name, "--yes"],
_profile_cli_args(profile or body.profile) + ["skills", "uninstall", name, "--yes"],
"skills-uninstall",
)
except HTTPException:
@@ -7439,11 +7519,13 @@ class SkillsUpdateRequest(BaseModel):
@app.post("/api/skills/hub/update")
async def update_skills_hub(body: Optional[SkillsUpdateRequest] = None):
async def update_skills_hub(
body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None
):
try:
profile = body.profile if body else None
effective = profile or (body.profile if body else None)
proc = _spawn_hermes_action(
_profile_cli_args(profile) + ["skills", "update"], "skills-update"
_profile_cli_args(effective) + ["skills", "update"], "skills-update"
)
except HTTPException:
raise
@@ -8461,9 +8543,9 @@ async def get_skills(profile: Optional[str] = None):
@app.put("/api/skills/toggle")
async def toggle_skill(body: SkillToggle):
async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
with _profile_scope(body.profile):
with _profile_scope(profile or body.profile):
config = load_config()
disabled = get_disabled_skills(config)
if body.enabled:
@@ -8516,7 +8598,7 @@ class ToolsetToggle(BaseModel):
@app.put("/api/tools/toolsets/{name}")
async def toggle_toolset(name: str, body: ToolsetToggle):
async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None):
"""Enable/disable a configurable toolset for the desktop (cli) platform.
Persists to ``platform_toolsets.cli`` via the same ``_save_platform_tools``
@@ -8534,7 +8616,7 @@ async def toggle_toolset(name: str, body: ToolsetToggle):
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile):
with _profile_scope(profile or body.profile):
config = load_config()
enabled = set(
_get_platform_tools(config, "cli", include_default_mcp_servers=False)
@@ -8616,7 +8698,9 @@ class ToolsetProviderSelect(BaseModel):
@app.put("/api/tools/toolsets/{name}/provider")
async def select_toolset_provider(name: str, body: ToolsetProviderSelect):
async def select_toolset_provider(
name: str, body: ToolsetProviderSelect, profile: Optional[str] = None
):
"""Persist a provider selection for a toolset (no key prompting).
Delegates to ``apply_provider_selection`` the shared, non-interactive
@@ -8634,7 +8718,7 @@ async def select_toolset_provider(name: str, body: ToolsetProviderSelect):
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile):
with _profile_scope(profile or body.profile):
config = load_config()
try:
apply_provider_selection(name, body.provider, config)
@@ -8650,7 +8734,7 @@ class ToolsetEnvUpdate(BaseModel):
@app.put("/api/tools/toolsets/{name}/env")
async def save_toolset_env(name: str, body: ToolsetEnvUpdate):
async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None):
"""Persist API keys for a toolset's provider env vars.
Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value``
@@ -8672,7 +8756,7 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate):
if name not in valid_ts:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile):
with _profile_scope(profile or body.profile):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
allowed: set[str] = set()
@@ -8753,23 +8837,26 @@ async def run_toolset_post_setup(name: str, body: ToolsetPostSetup):
class RawConfigUpdate(BaseModel):
yaml_text: str
profile: Optional[str] = None
@app.get("/api/config/raw")
async def get_config_raw():
path = get_config_path()
async def get_config_raw(profile: Optional[str] = None):
with _profile_scope(profile):
path = get_config_path()
if not path.exists():
return {"yaml": ""}
return {"yaml": path.read_text(encoding="utf-8")}
@app.put("/api/config/raw")
async def update_config_raw(body: RawConfigUpdate):
async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None):
try:
parsed = yaml.safe_load(body.yaml_text)
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="YAML must be a mapping")
save_config(parsed)
with _profile_scope(profile or body.profile):
save_config(parsed)
return {"ok": True}
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
@@ -9217,6 +9304,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
def _resolve_chat_argv(
resume: Optional[str] = None,
sidecar_url: Optional[str] = None,
profile: Optional[str] = None,
) -> tuple[list[str], Optional[str], Optional[dict]]:
"""Resolve the argv + cwd + env for the chat PTY.
@@ -9236,9 +9324,24 @@ def _resolve_chat_argv(
`sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so
the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the
dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`).
`profile` (when set) scopes the ENTIRE chat to that profile by pointing
``HERMES_HOME`` at the profile dir in the child env. Every spawned
process (the TUI and the ``tui_gateway.entry`` it launches) resolves
``get_hermes_home()`` from that env var at its own import, so the child
binds the profile's config, skills, memory, and state.db from the start
the same propagation ``hermes -p <name>`` performs. The in-process
``HERMES_TUI_GATEWAY_URL`` attach is SKIPPED for scoped chats: the
dashboard's in-memory gateway runs under the dashboard's own profile,
so a profile-scoped chat must spawn its own gateway subprocess.
"""
from hermes_cli.main import PROJECT_ROOT, _make_tui_argv
profile_dir: Optional[Path] = None
requested = (profile or "").strip()
if requested and requested.lower() != "current":
profile_dir = _resolve_profile_dir(requested)
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
env = os.environ.copy()
try:
@@ -9256,6 +9359,9 @@ def _resolve_chat_argv(
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
env.setdefault("HERMES_TUI_INLINE", "1")
if profile_dir is not None:
env["HERMES_HOME"] = str(profile_dir)
if resume:
latest_resume, _latest_path = _session_latest_descendant(resume)
if latest_resume:
@@ -9265,8 +9371,13 @@ def _resolve_chat_argv(
if sidecar_url:
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
if gateway_ws_url := _build_gateway_ws_url():
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
# Profile-scoped chats must NOT attach to the dashboard's in-memory
# gateway — it runs under the dashboard's own profile. Without the
# attach URL, gatewayClient spawns its own `tui_gateway.entry`, which
# inherits the profile HERMES_HOME set above.
if profile_dir is None:
if gateway_ws_url := _build_gateway_ws_url():
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
return list(argv), str(cwd) if cwd else None, env
@@ -9429,11 +9540,19 @@ async def pty_ws(ws: WebSocket) -> None:
# --- spawn PTY ------------------------------------------------------
resume = ws.query_params.get("resume") or None
profile = ws.query_params.get("profile") or None
channel = _channel_or_close_code(ws)
sidecar_url = _build_sidecar_url(channel) if channel else None
try:
argv, cwd, env = _resolve_chat_argv(resume=resume, sidecar_url=sidecar_url)
argv, cwd, env = _resolve_chat_argv(
resume=resume, sidecar_url=sidecar_url, profile=profile
)
except HTTPException as exc:
# Unknown/invalid profile from _resolve_profile_dir.
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc.detail}\x1b[0m\r\n")
await ws.close(code=1011)
return
except SystemExit as exc:
# _make_tui_argv calls sys.exit(1) when node/npm is missing.
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n")
@@ -10711,8 +10830,15 @@ def start_server(
port: int = 9119,
open_browser: bool = True,
allow_public: bool = False,
initial_profile: str = "",
):
"""Start the web UI server."""
"""Start the web UI server.
``initial_profile`` (when set) is appended to the auto-opened browser
URL as ``?profile=<name>`` so the SPA's profile switcher preselects it
used when a profile alias (``<profile> dashboard``) routes to the
machine dashboard.
"""
import uvicorn
# Phase 0: stash the auth-gate flag on app.state so middleware / SPA-token
@@ -10803,10 +10929,15 @@ def start_server(
)
if _has_display:
_open_url = f"http://{host}:{port}"
if initial_profile:
from urllib.parse import quote
_open_url += f"/?profile={quote(initial_profile)}"
def _open():
try:
time.sleep(1.0)
webbrowser.open(f"http://{host}:{port}")
webbrowser.open(_open_url)
except Exception:
pass