feat(dashboard): complete admin panel — MCP catalog, enable/disable toggles, hook creation, system stats (#36736)

* feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats

Backend for the comprehensive admin pass:
- MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST
  /api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled
- Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes
  with 403 (hot-reloaded, no restart)
- Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove;
  list now reports accurate allowlist status + valid events
- System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/
  uptime/process, stdlib fallback

All gated by dashboard auth; secrets never returned.

* feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats

- McpPage: catalog section (browse Nous-approved MCPs, one-click install with
  env prompts) + per-server enable/disable toggle with gateway-restart note
- WebhooksPage: per-subscription enable/disable toggle (muted + badge when off)
- SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load),
  shell-hook create modal + delete, 'Create backup' label
- api.ts: client methods + types for catalog, toggles, hook CRUD, system stats

* test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle

Adds tests for the comprehensive pass: MCP enable/disable + catalog list +
catalog-install-unknown, hook create/delete with consent, system stats shape,
and webhook enable/disable. 26 tests total, all green.

* docs(dashboard): document the comprehensive admin pass + fresh screenshots

Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install,
enable/disable toggles, hook creation, and host system stats; adds the new
endpoints to the API table; replaces the screenshots with live captures of
the rebuilt pages (real data, no dummies) including the hook-create modal.

* feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops

Closes the last in-scope CLI gaps from the coverage audit:
- Curator: GET /api/curator (status), PUT /api/curator/paused, POST
  /api/curator/run (background)
- Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only)
- Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate
  (backgrounded, tailed via action status)

Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/
postinstall/uninstall/claw) remain CLI-only by design.

* feat(dashboard): curator + portal + diagnostics UI, tests

- SystemPage: Nous Portal status section (auth + Tool Gateway routing),
  Skill curator card (status + pause/resume + run now), and three new
  Operations buttons (prompt size, support dump, migrate config)
- api.ts: client methods + CuratorStatus/PortalStatus types
- tests: curator pause/resume, portal shape, system-stats shape, + auth-gate
  coverage for the new GET endpoints (31 tests total)

* docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots

Updates the System section for the Nous Portal status, Skill curator
controls, and the new prompt-size/dump/migrate operations; adds them to the
API table; refreshes the System screenshots (now showing Portal + Curator)
and adds a dedicated curator/gateway/memory capture.

* feat(dashboard): session stats/export/prune + skills hub search endpoints

Completes the existing tabs' backend depth (audit vs CLI):
- Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export,
  POST /api/sessions/prune. /stats is registered before /{session_id} so the
  literal path isn't captured by the parameterized route.
- Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded),
  returns installable identifiers
- (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI)

* feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit

Audited every existing tab against its CLI command and filled the gaps:
- Sessions: store stats bar, per-row rename + export (JSON download), and a
  prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats)
- Skills: new 'Browse hub' view — search the skill hub across all sources,
  install by identifier with a live install log, and 'Update all' (mirrors
  hermes skills search/install/update)
- Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit)
- api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions,
  updateCronJob, searchSkillsHub + types

Models tab was already comprehensive (provider+model picker, dynamic per-provider
lists, main + all 11 aux-task assignments, reset) — verified, no change needed.

* test(dashboard): cover session stats/rename/export/prune + skills hub search

Adds the route-shadowing guard for /api/sessions/stats (must not be captured
by /api/sessions/{session_id}), rename/export/prune, and the empty-query
short-circuit for hub search. 36 tests total, all green.

* docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit

Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse
hub view for search/install/update (+ screenshot). Cron: edit action. API
table updated with the new endpoints.
This commit is contained in:
Teknium 2026-06-01 21:16:11 -07:00 committed by GitHub
parent 40ae170647
commit bd8e2ec1a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2597 additions and 210 deletions

View file

@ -364,6 +364,15 @@ class WebhookAdapter(BasePlatformAdapter):
{"error": f"Unknown route: {route_name}"}, status=404
)
# Disabled routes are kept in the subscriptions file (so the dashboard
# can re-enable them) but reject incoming events. Default-enabled:
# only an explicit ``enabled: false`` turns a route off, matching the
# mcp_servers ``enabled`` semantics.
if route_config.get("enabled", True) is False:
return web.json_response(
{"error": f"Route disabled: {route_name}"}, status=403
)
# ── Auth-before-body ─────────────────────────────────────
# Check Content-Length before reading the full payload.
content_length = request.content_length or 0

View file

@ -753,6 +753,225 @@ async def get_status():
}
@app.get("/api/system/stats")
async def get_system_stats():
"""Host + process system stats for the System page.
OS / Python / host identity from stdlib; CPU / memory / disk / uptime from
psutil when available, with graceful degradation when it isn't. Read-only
and non-sensitive (no env values, no paths beyond the hermes home root).
"""
import platform as _platform
info: Dict[str, Any] = {
"os": _platform.system(),
"os_release": _platform.release(),
"os_version": _platform.version(),
"platform": _platform.platform(),
"arch": _platform.machine(),
"hostname": _platform.node(),
"python_version": _platform.python_version(),
"python_impl": _platform.python_implementation(),
"hermes_version": __version__,
"cpu_count": os.cpu_count(),
}
# psutil enriches the picture when present; everything below is optional.
try:
import psutil # type: ignore
vm = psutil.virtual_memory()
info["memory"] = {
"total": vm.total,
"available": vm.available,
"used": vm.used,
"percent": vm.percent,
}
try:
du = psutil.disk_usage(str(get_hermes_home()))
info["disk"] = {
"total": du.total,
"used": du.used,
"free": du.free,
"percent": du.percent,
}
except Exception:
pass
try:
info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
la = getattr(psutil, "getloadavg", None)
if la:
info["load_avg"] = list(la())
except Exception:
pass
try:
boot = psutil.boot_time()
info["uptime_seconds"] = int(time.time() - boot)
except Exception:
pass
try:
proc = psutil.Process()
info["process"] = {
"pid": proc.pid,
"rss": proc.memory_info().rss,
"create_time": int(proc.create_time()),
"num_threads": proc.num_threads(),
}
except Exception:
pass
info["psutil"] = True
except Exception:
info["psutil"] = False
# stdlib-only fallbacks for load average + uptime where the kernel
# exposes them.
try:
info["load_avg"] = list(os.getloadavg())
except (OSError, AttributeError):
pass
return info
# ---------------------------------------------------------------------------
# Curator endpoints — background skill-maintenance status + controls.
#
# The curator periodically reviews skills (archive stale, prune, pin). The
# dashboard surfaces its state and the pause/resume/run-now controls that
# `hermes curator` exposes.
# ---------------------------------------------------------------------------
@app.get("/api/curator")
async def get_curator_status():
try:
from agent import curator
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Curator unavailable: {exc}")
try:
state = curator.load_state()
except Exception:
state = {}
return {
"enabled": _safe_call(curator, "is_enabled", True),
"paused": _safe_call(curator, "is_paused", False),
"interval_hours": _safe_call(curator, "get_interval_hours", None),
"last_run_at": state.get("last_run_at"),
"min_idle_hours": _safe_call(curator, "get_min_idle_hours", None),
"stale_after_days": _safe_call(curator, "get_stale_after_days", None),
"archive_after_days": _safe_call(curator, "get_archive_after_days", None),
}
class CuratorPause(BaseModel):
paused: bool
@app.put("/api/curator/paused")
async def set_curator_paused(body: CuratorPause):
from agent import curator
curator.set_paused(bool(body.paused))
return {"ok": True, "paused": bool(body.paused)}
@app.post("/api/curator/run")
async def run_curator():
"""Trigger a curator review now (backgrounded; tail via action status)."""
try:
proc = _spawn_hermes_action(["curator", "run"], "curator-run")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to run curator: {exc}")
return {"ok": True, "pid": proc.pid, "name": "curator-run"}
def _safe_call(mod, fn_name: str, default):
try:
fn = getattr(mod, fn_name, None)
return fn() if callable(fn) else default
except Exception:
return default
# ---------------------------------------------------------------------------
# Portal endpoint — Nous Portal auth + Tool Gateway routing status (read-only).
# ---------------------------------------------------------------------------
@app.get("/api/portal")
async def get_portal_status():
cfg = load_config() or {}
auth: Dict[str, Any] = {}
try:
from hermes_cli.auth import get_nous_auth_status
auth = get_nous_auth_status() or {}
except Exception:
auth = {}
features = []
try:
from hermes_cli.nous_subscription import get_nous_subscription_features
feats = get_nous_subscription_features(cfg)
if feats is not None:
for feat in feats.items():
if getattr(feat, "managed_by_nous", False):
state = "via Nous Portal"
elif getattr(feat, "active", False) and getattr(feat, "current_provider", None):
state = feat.current_provider
elif getattr(feat, "active", False):
state = "active"
else:
state = "not configured"
features.append({"label": getattr(feat, "label", ""), "state": state})
except Exception:
_log.exception("portal features failed")
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
return {
"logged_in": bool(auth.get("logged_in")),
"portal_url": auth.get("portal_base_url"),
"inference_url": auth.get("inference_base_url"),
"provider": str((model_cfg or {}).get("provider") or ""),
"subscription_url": "https://portal.nousresearch.com/manage-subscription",
"features": features,
}
# ---------------------------------------------------------------------------
# Diagnostics: prompt-size, support dump, debug upload, config migrate.
# All produce text output, so they spawn background actions tailed via
# /api/actions/<name>/status.
# ---------------------------------------------------------------------------
@app.post("/api/ops/prompt-size")
async def run_prompt_size():
try:
proc = _spawn_hermes_action(["prompt-size"], "prompt-size")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
return {"ok": True, "pid": proc.pid, "name": "prompt-size"}
@app.post("/api/ops/dump")
async def run_dump():
try:
proc = _spawn_hermes_action(["dump"], "dump")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
return {"ok": True, "pid": proc.pid, "name": "dump"}
@app.post("/api/ops/config-migrate")
async def run_config_migrate():
try:
proc = _spawn_hermes_action(["config", "migrate"], "config-migrate")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
return {"ok": True, "pid": proc.pid, "name": "config-migrate"}
# ---------------------------------------------------------------------------
# Gateway + update actions (invoked from the Status page).
#
@ -779,6 +998,10 @@ _ACTION_LOG_FILES: Dict[str, str] = {
"skills-install": "action-skills-install.log",
"skills-uninstall": "action-skills-uninstall.log",
"skills-update": "action-skills-update.log",
"curator-run": "action-curator-run.log",
"prompt-size": "action-prompt-size.log",
"dump": "action-dump.log",
"config-migrate": "action-config-migrate.log",
}
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
@ -3690,6 +3913,38 @@ def _session_latest_descendant(session_id: str):
finally:
db.close()
@app.get("/api/sessions/stats")
async def get_session_stats():
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
path isn't captured as a session id by the parameterized route.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
total = db.session_count(include_archived=True)
active_store = db.session_count(include_archived=False)
archived = db.session_count(archived_only=True)
messages = db.message_count()
by_source: Dict[str, int] = {}
try:
for s in db.list_sessions_rich(limit=10000, include_archived=True):
src = str(s.get("source") or "cli")
by_source[src] = by_source.get(src, 0) + 1
except Exception:
pass
return {
"total": total,
"active_store": active_store,
"archived": archived,
"messages": messages,
"by_source": by_source,
}
finally:
db.close()
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str):
from hermes_state import SessionDB
@ -3782,6 +4037,49 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
db.close()
@app.get("/api/sessions/{session_id}/export")
async def export_session_endpoint(session_id: str):
"""Export a single session (metadata + messages) as JSON."""
from hermes_state import SessionDB
db = SessionDB()
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
data = db.export_session(sid)
if data is None:
raise HTTPException(status_code=404, detail="Session not found")
return data
finally:
db.close()
class SessionPrune(BaseModel):
older_than_days: int = 90
source: Optional[str] = None
@app.post("/api/sessions/prune")
async def prune_sessions_endpoint(body: SessionPrune):
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
if body.older_than_days < 1:
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
from hermes_state import SessionDB
db = SessionDB()
try:
sessions_dir = get_hermes_home() / "sessions"
removed = db.prune_sessions(
older_than_days=body.older_than_days,
source=(body.source or None),
sessions_dir=sessions_dir if sessions_dir.exists() else None,
)
return {"ok": True, "removed": removed}
finally:
db.close()
# ---------------------------------------------------------------------------
# Log viewer endpoint
# ---------------------------------------------------------------------------
@ -4172,6 +4470,129 @@ async def test_mcp_server(name: str):
}
class MCPEnabledToggle(BaseModel):
enabled: bool
@app.put("/api/mcp/servers/{name}/enabled")
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle):
"""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)
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
@app.get("/api/mcp/catalog")
async def list_mcp_catalog():
"""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.
"""
try:
from hermes_cli import mcp_catalog
except Exception as exc:
_log.exception("mcp_catalog import failed")
raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}")
entries = []
try:
for entry in mcp_catalog.list_catalog():
auth = entry.auth
entries.append({
"name": entry.name,
"description": entry.description,
"source": entry.source,
"transport": entry.transport.type,
"auth_type": getattr(auth, "type", "none"),
# Env vars the user must supply (names + prompts only, never values).
"required_env": [
{"name": e.name, "prompt": e.prompt, "required": e.required}
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),
})
except Exception:
_log.exception("list_mcp_catalog failed")
diagnostics = []
try:
diagnostics = [
{"name": n, "kind": k, "message": m}
for (n, k, m) in mcp_catalog.catalog_diagnostics()
]
except Exception:
pass
return {"entries": entries, "diagnostics": diagnostics}
class MCPCatalogInstall(BaseModel):
name: str
# env: KEY=VALUE map for catalog entries that declare required env vars.
env: Dict[str, str] = {}
enable: bool = True
@app.post("/api/mcp/catalog/install")
async def install_mcp_catalog_entry(body: MCPCatalogInstall):
"""Install a catalog MCP into config.yaml.
For HTTP/stdio entries with required env vars, those are written to .env
via the standard env path so the agent can read them at session start.
Entries that need a git bootstrap (``needs_install``) are installed via
the CLI action path because the clone can take time.
"""
from hermes_cli import mcp_catalog
name = (body.name or "").strip()
entry = mcp_catalog.get_entry(name)
if entry is None:
raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'")
# Persist any supplied env vars first (catalog entries declare which names
# they need; we only write the ones the user provided).
if body.env:
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.
if entry.install is not None:
try:
proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install")
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.
try:
await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable)
except Exception as exc:
_log.exception("install_mcp_catalog_entry failed")
raise HTTPException(status_code=400, detail=str(exc))
return {"ok": True, "name": name, "background": False}
# Register the mcp-install action log so /api/actions/mcp-install/status works.
_ACTION_LOG_FILES.setdefault("mcp-install", "action-mcp-install.log")
# ---------------------------------------------------------------------------
# Pairing endpoints — approve / revoke / list messaging pairing codes.
#
@ -4283,6 +4704,8 @@ def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> D
"url": f"{base_url}/webhooks/{name}",
# Secret is masked on read; full value only returned on create.
"secret_set": bool(route.get("secret")),
# Default-enabled; only an explicit enabled:false turns a route off.
"enabled": route.get("enabled", True) is not False,
}
@ -4367,6 +4790,30 @@ async def delete_webhook(name: str):
return {"ok": True}
class WebhookEnabledToggle(BaseModel):
enabled: bool
@app.put("/api/webhooks/{name}/enabled")
async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
"""Enable or disable a webhook route.
Disabled routes stay in the subscriptions file (so they can be
re-enabled) but the gateway rejects incoming events with 403. The
gateway hot-reloads the subscriptions file, so this takes effect on the
next event without a restart.
"""
import hermes_cli.webhook as wh
key = (name or "").strip().lower()
subs = wh._load_subscriptions()
if key not in subs:
raise HTTPException(status_code=404, detail=f"No subscription named '{key}'")
subs[key]["enabled"] = bool(body.enabled)
wh._save_subscriptions(subs)
return {"ok": True, "name": key, "enabled": bool(body.enabled)}
# ---------------------------------------------------------------------------
# Gateway lifecycle endpoints — start / stop.
#
@ -4688,38 +5135,160 @@ async def run_import(body: ImportRequest):
@app.get("/api/ops/hooks")
async def list_hooks():
"""Read-only list of configured shell hooks from config.yaml + allowlist."""
"""List configured shell hooks from config.yaml with consent + health.
Reports each hook's allowlist (consent) status and whether the script is
currently executable, plus the set of valid hook events so the create
form can offer them.
"""
from hermes_cli.config import load_config as _load_config
from agent import shell_hooks
try:
from hermes_cli.plugins import VALID_HOOKS
valid_events = sorted(VALID_HOOKS)
except Exception:
valid_events = []
specs = []
try:
specs = shell_hooks.iter_configured_hooks(_load_config())
except Exception:
_log.exception("iter_configured_hooks failed")
out = []
for spec in specs:
entry = None
try:
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
except Exception:
pass
executable = False
try:
executable = shell_hooks.script_is_executable(spec.command)
except Exception:
pass
out.append({
"event": spec.event,
"matcher": spec.matcher,
"command": spec.command,
"timeout": spec.timeout,
"allowed": entry is not None,
"approved_at": (entry or {}).get("approved_at"),
"executable": executable,
})
return {"hooks": out, "valid_events": valid_events}
class HookCreate(BaseModel):
event: str
command: str
matcher: Optional[str] = None
timeout: Optional[int] = None
# approve: write the consent allowlist entry too (the operator using the
# authenticated dashboard is giving consent). Without it the hook is
# configured but won't fire until approved.
approve: bool = True
@app.post("/api/ops/hooks")
async def create_hook(body: HookCreate):
"""Add a shell hook to config.yaml (and optionally approve it).
Shell hooks run arbitrary commands, so this is a privileged action: it
writes to the ``hooks:`` config block and, when ``approve`` is set, records
consent in the allowlist so the hook actually fires. Takes effect on the
next session / gateway restart.
"""
from agent import shell_hooks
event = (body.event or "").strip()
command = (body.command or "").strip()
if not event or not command:
raise HTTPException(status_code=400, detail="event and command are required")
try:
from hermes_cli.plugins import VALID_HOOKS
if event not in VALID_HOOKS:
raise HTTPException(
status_code=400,
detail=f"Unknown event '{event}'. Valid: {', '.join(sorted(VALID_HOOKS))}",
)
except HTTPException:
raise
except Exception:
pass
cfg = load_config()
hooks_cfg = cfg.get("hooks")
out = []
if isinstance(hooks_cfg, dict):
for event, entries in hooks_cfg.items():
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict):
continue
out.append({
"event": event,
"matcher": entry.get("matcher"),
"command": entry.get("command"),
"timeout": entry.get("timeout"),
})
# Consent allowlist status (which commands have been approved for run).
allowlist: List[str] = []
if not isinstance(hooks_cfg, dict):
hooks_cfg = {}
cfg["hooks"] = hooks_cfg
entries = hooks_cfg.get(event)
if not isinstance(entries, list):
entries = []
hooks_cfg[event] = entries
new_entry: Dict[str, Any] = {"command": command}
if body.matcher:
new_entry["matcher"] = body.matcher
if body.timeout is not None:
new_entry["timeout"] = int(body.timeout)
entries.append(new_entry)
save_config(cfg)
approved = False
if body.approve:
try:
shell_hooks._record_approval(event, command)
approved = True
except Exception:
_log.exception("hook consent record failed")
return {"ok": True, "event": event, "command": command, "approved": approved}
class HookDelete(BaseModel):
event: str
command: str
@app.delete("/api/ops/hooks")
async def delete_hook(body: HookDelete):
"""Remove a hook from config.yaml and revoke its consent allowlist entry."""
from agent import shell_hooks
event = (body.event or "").strip()
command = (body.command or "").strip()
if not event or not command:
raise HTTPException(status_code=400, detail="event and command are required")
cfg = load_config()
hooks_cfg = cfg.get("hooks")
removed = False
if isinstance(hooks_cfg, dict) and isinstance(hooks_cfg.get(event), list):
before = len(hooks_cfg[event])
hooks_cfg[event] = [
e for e in hooks_cfg[event]
if not (isinstance(e, dict) and e.get("command") == command)
]
removed = len(hooks_cfg[event]) < before
if not hooks_cfg[event]:
del hooks_cfg[event]
if not hooks_cfg:
cfg.pop("hooks", None)
save_config(cfg)
# Revoke consent regardless so a re-add re-prompts.
try:
allow_path = get_hermes_home() / "shell-hooks-allowlist.json"
if allow_path.exists():
data = json.loads(allow_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
allowlist = list(data.keys())
elif isinstance(data, list):
allowlist = [str(x) for x in data]
shell_hooks.revoke(command)
except Exception:
_log.exception("Failed to read shell-hooks allowlist")
for h in out:
h["allowed"] = h.get("command") in allowlist
return {"hooks": out, "allowlist": allowlist}
pass
if not removed:
raise HTTPException(status_code=404, detail="No matching hook found")
return {"ok": True}
@app.get("/api/ops/checkpoints")
@ -4818,6 +5387,46 @@ async def update_skills_hub():
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
@app.get("/api/skills/hub/search")
async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20):
"""Search the skill hub across all configured sources.
Network-bound (parallel source search); runs in a thread so the FastAPI
loop isn't blocked. Returns structured results the UI installs by
identifier via POST /api/skills/hub/install.
"""
query = (q or "").strip()
if not query:
return {"results": []}
def _run():
from tools.skills_hub import create_source_router, unified_search
sources = create_source_router()
metas = unified_search(
query, sources, source_filter=source or "all", limit=min(max(limit, 1), 50)
)
return [
{
"name": m.name,
"description": m.description,
"source": m.source,
"identifier": m.identifier,
"trust_level": m.trust_level,
"repo": m.repo,
"tags": list(m.tags or []),
}
for m in metas
]
try:
results = await asyncio.to_thread(_run)
except Exception as exc:
_log.exception("skills hub search failed")
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
return {"results": results}
# ---------------------------------------------------------------------------
# Profile management endpoints (minimal — list/create/rename/delete + SOUL.md)
# ---------------------------------------------------------------------------

View file

@ -74,6 +74,35 @@ class TestMcpEndpoints:
r = self.client.post("/api/mcp/servers", json={"name": "bad"})
assert r.status_code == 400
def test_enable_disable_toggle(self):
self.client.post("/api/mcp/servers", json={"name": "tog", "url": "u"})
r = self.client.put("/api/mcp/servers/tog/enabled", json={"enabled": False})
assert r.status_code == 200 and r.json()["enabled"] is False
srv = [
s for s in self.client.get("/api/mcp/servers").json()["servers"]
if s["name"] == "tog"
][0]
assert srv["enabled"] is False
# Toggling a missing server is a 404.
assert self.client.put(
"/api/mcp/servers/nope/enabled", json={"enabled": True}
).status_code == 404
def test_catalog_lists_entries(self):
r = self.client.get("/api/mcp/catalog")
assert r.status_code == 200
body = r.json()
assert "entries" in body and "diagnostics" in body
# The shipped optional-mcps/ catalog has at least one entry; each must
# carry the install/enabled status fields the UI relies on.
for e in body["entries"]:
assert {"name", "transport", "installed", "enabled", "needs_install"} <= set(e)
def test_catalog_install_unknown_404(self):
r = self.client.post("/api/mcp/catalog/install", json={"name": "no-such-mcp-xyz"})
assert r.status_code == 404
class TestCredentialPoolEndpoints:
@pytest.fixture(autouse=True)
@ -190,6 +219,40 @@ class TestOpsEndpoints:
save_config(cfg)
data = self.client.get("/api/ops/hooks").json()
assert data["hooks"][0]["command"] == "/bin/echo hi"
assert "valid_events" in data and len(data["valid_events"]) >= 1
def test_hook_create_and_delete(self):
# Create with consent approval.
r = self.client.post(
"/api/ops/hooks",
json={
"event": "pre_tool_call",
"command": "/bin/echo created",
"matcher": "terminal",
"timeout": 7,
"approve": True,
},
)
assert r.status_code == 200 and r.json()["approved"] is True
hooks = self.client.get("/api/ops/hooks").json()["hooks"]
created = [h for h in hooks if h["command"] == "/bin/echo created"]
assert created and created[0]["allowed"] is True
# Unknown event rejected.
assert self.client.post(
"/api/ops/hooks", json={"event": "no_such_event", "command": "/x"}
).status_code == 400
# Delete it.
r = self.client.request(
"DELETE",
"/api/ops/hooks",
json={"event": "pre_tool_call", "command": "/bin/echo created"},
)
assert r.status_code == 200
hooks2 = self.client.get("/api/ops/hooks").json()["hooks"]
assert not [h for h in hooks2 if h["command"] == "/bin/echo created"]
def test_checkpoints_list_empty(self):
data = self.client.get("/api/ops/checkpoints").json()
@ -200,6 +263,131 @@ class TestOpsEndpoints:
assert r.status_code == 404
class TestSystemStatsEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_stats_shape(self):
r = self.client.get("/api/system/stats")
assert r.status_code == 200
s = r.json()
# Identity fields always present (stdlib-sourced).
for key in ("os", "arch", "hostname", "python_version", "hermes_version"):
assert key in s and s[key]
# psutil flag tells the UI whether the richer metrics are populated.
assert "psutil" in s
class TestCuratorEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_status_and_pause_toggle(self):
r = self.client.get("/api/curator")
assert r.status_code == 200
body = r.json()
assert {"enabled", "paused", "interval_hours"} <= set(body)
# Pause then resume; the read reflects the write.
r = self.client.put("/api/curator/paused", json={"paused": True})
assert r.status_code == 200 and r.json()["paused"] is True
assert self.client.get("/api/curator").json()["paused"] is True
r = self.client.put("/api/curator/paused", json={"paused": False})
assert r.status_code == 200 and r.json()["paused"] is False
class TestPortalEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_status_shape(self):
r = self.client.get("/api/portal")
assert r.status_code == 200
body = r.json()
assert {"logged_in", "features", "subscription_url", "provider"} <= set(body)
assert isinstance(body["features"], list)
class TestSessionManagementEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
from hermes_state import SessionDB
db = SessionDB()
db.create_session(session_id="sess-x", source="cli")
db.close()
def test_stats_not_shadowed_by_session_id_route(self):
# /api/sessions/stats must resolve to the stats handler, not be captured
# as {session_id}="stats" by the parameterized route registered after it.
r = self.client.get("/api/sessions/stats")
assert r.status_code == 200
body = r.json()
assert {"total", "active_store", "archived", "messages", "by_source"} <= set(body)
assert body["total"] >= 1
def test_rename(self):
r = self.client.patch("/api/sessions/sess-x", json={"title": "Renamed"})
assert r.status_code == 200 and r.json()["title"] == "Renamed"
def test_export(self):
r = self.client.get("/api/sessions/sess-x/export")
assert r.status_code == 200 and "messages" in r.json()
assert self.client.get("/api/sessions/nope/export").status_code == 404
def test_prune_validation(self):
r = self.client.post("/api/sessions/prune", json={"older_than_days": 9999})
assert r.status_code == 200 and "removed" in r.json()
assert self.client.post(
"/api/sessions/prune", json={"older_than_days": 0}
).status_code == 400
class TestSkillsHubSearchEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_empty_query_returns_empty(self):
# Empty query short-circuits (no network) and returns no results.
r = self.client.get("/api/skills/hub/search?q=")
assert r.status_code == 200 and r.json() == {"results": []}
class TestWebhookToggleEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
# Enable the webhook platform so a subscription can be created.
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("platforms", {})["webhook"] = {
"enabled": True,
"extra": {"host": "0.0.0.0", "port": 8644},
}
save_config(cfg)
def test_create_toggle_disable(self):
r = self.client.post(
"/api/webhooks", json={"name": "hook1", "deliver": "log", "events": ["push"]}
)
assert r.status_code == 200 and r.json()["enabled"] is True
r = self.client.put("/api/webhooks/hook1/enabled", json={"enabled": False})
assert r.status_code == 200 and r.json()["enabled"] is False
subs = self.client.get("/api/webhooks").json()["subscriptions"]
assert subs[0]["enabled"] is False
assert self.client.put(
"/api/webhooks/nope/enabled", json={"enabled": True}
).status_code == 404
class TestAdminEndpointsAuthGate:
"""Every admin endpoint must sit behind the dashboard session-token gate."""
@ -221,6 +409,9 @@ class TestAdminEndpointsAuthGate:
"/api/memory",
"/api/ops/hooks",
"/api/ops/checkpoints",
"/api/curator",
"/api/portal",
"/api/system/stats",
],
)
def test_gated(self, path):

View file

@ -238,6 +238,24 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
renameSession: (id: string, title: string) =>
fetchJSON<{ ok: boolean; title: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
},
),
getSessionStats: () => fetchJSON<SessionStoreStats>("/api/sessions/stats"),
exportSessionUrl: (id: string) =>
`/api/sessions/${encodeURIComponent(id)}/export`,
pruneSessions: (older_than_days: number, source?: string) =>
fetchJSON<{ ok: boolean; removed: number }>("/api/sessions/prune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ older_than_days, source }),
}),
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
const qs = new URLSearchParams();
if (params.file) qs.set("file", params.file);
@ -311,6 +329,19 @@ export const api = {
}),
pauseCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
updateCronJob: (
id: string,
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string },
profile = "default",
) =>
fetchJSON<CronJob>(
`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
},
),
resumeCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
triggerCronJob: (id: string, profile = "default") =>
@ -522,6 +553,32 @@ export const api = {
`/api/mcp/servers/${encodeURIComponent(name)}/test`,
{ method: "POST" },
),
setMcpServerEnabled: (name: string, enabled: boolean) =>
fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
`/api/mcp/servers/${encodeURIComponent(name)}/enabled`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
},
),
getMcpCatalog: () =>
fetchJSON<{ entries: McpCatalogEntry[]; diagnostics: McpCatalogDiagnostic[] }>(
"/api/mcp/catalog",
),
installMcpCatalogEntry: (
name: string,
env: Record<string, string> = {},
enable = true,
) =>
fetchJSON<{ ok: boolean; name: string; background: boolean; action?: string }>(
"/api/mcp/catalog/install",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, env, enable }),
},
),
// ── Admin: Pairing ──────────────────────────────────────────────────
getPairing: () => fetchJSON<PairingResponse>("/api/pairing"),
@ -554,6 +611,15 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/api/webhooks/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
setWebhookEnabled: (name: string, enabled: boolean) =>
fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
`/api/webhooks/${encodeURIComponent(name)}/enabled`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
},
),
// ── Admin: Credential pool ──────────────────────────────────────────
getCredentialPool: () =>
@ -616,6 +682,45 @@ export const api = {
body: JSON.stringify({ archive }),
}),
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
createHook: (body: HookCreate) =>
fetchJSON<{ ok: boolean; event: string; command: string; approved: boolean }>(
"/api/ops/hooks",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
),
deleteHook: (event: string, command: string) =>
fetchJSON<{ ok: boolean }>("/api/ops/hooks", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event, command }),
}),
getSystemStats: () => fetchJSON<SystemStats>("/api/system/stats"),
// ── Admin: Curator ──────────────────────────────────────────────────
getCurator: () => fetchJSON<CuratorStatus>("/api/curator"),
setCuratorPaused: (paused: boolean) =>
fetchJSON<{ ok: boolean; paused: boolean }>("/api/curator/paused", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paused }),
}),
runCurator: () =>
fetchJSON<ActionResponse>("/api/curator/run", { method: "POST" }),
// ── Admin: Portal ───────────────────────────────────────────────────
getPortal: () => fetchJSON<PortalStatus>("/api/portal"),
// ── Admin: Diagnostics (backgrounded) ───────────────────────────────
runPromptSize: () =>
fetchJSON<ActionResponse>("/api/ops/prompt-size", { method: "POST" }),
runDump: () => fetchJSON<ActionResponse>("/api/ops/dump", { method: "POST" }),
runConfigMigrate: () =>
fetchJSON<ActionResponse>("/api/ops/config-migrate", { method: "POST" }),
getCheckpoints: () => fetchJSON<CheckpointsResponse>("/api/ops/checkpoints"),
pruneCheckpoints: () =>
fetchJSON<ActionResponse>("/api/ops/checkpoints/prune", { method: "POST" }),
@ -635,6 +740,10 @@ export const api = {
}),
updateSkillsFromHub: () =>
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
searchSkillsHub: (q: string, source = "all", limit = 20) =>
fetchJSON<{ results: SkillHubResult[] }>(
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`,
),
};
/** Identity payload returned by ``GET /api/auth/me`` (Phase 7).
@ -663,6 +772,24 @@ export interface ActionResponse {
update_command?: string;
}
export interface SessionStoreStats {
total: number;
active_store: number;
archived: number;
messages: number;
by_source: Record<string, number>;
}
export interface SkillHubResult {
name: string;
description: string;
source: string;
identifier: string;
trust_level: string;
repo: string | null;
tags: string[];
}
// ── Admin types ───────────────────────────────────────────────────────
export interface McpServer {
@ -677,6 +804,25 @@ export interface McpServer {
tools: string[] | null;
}
export interface McpCatalogEntry {
name: string;
description: string;
source: string;
transport: "http" | "stdio";
auth_type: "api_key" | "oauth" | "none";
required_env: Array<{ name: string; prompt: string; required: boolean }>;
needs_install: boolean;
installed: boolean;
enabled: boolean;
}
export interface McpCatalogDiagnostic {
name: string;
kind: string;
message: string;
}
export interface McpServerCreate {
name: string;
url?: string;
@ -716,6 +862,7 @@ export interface WebhookRoute {
created_at: string | null;
url: string;
secret_set: boolean;
enabled: boolean;
}
export interface WebhooksResponse {
@ -771,11 +918,65 @@ export interface HookEntry {
command: string | null;
timeout: number | null;
allowed: boolean;
approved_at?: string | null;
executable?: boolean;
}
export interface HooksResponse {
hooks: HookEntry[];
allowlist: string[];
valid_events: string[];
}
export interface HookCreate {
event: string;
command: string;
matcher?: string;
timeout?: number;
approve?: boolean;
}
export interface SystemStats {
os: string;
os_release: string;
os_version: string;
platform: string;
arch: string;
hostname: string;
python_version: string;
python_impl: string;
hermes_version: string;
cpu_count: number | null;
psutil: boolean;
cpu_percent?: number;
load_avg?: number[];
uptime_seconds?: number;
memory?: { total: number; available: number; used: number; percent: number };
disk?: { total: number; used: number; free: number; percent: number };
process?: { pid: number; rss: number; create_time: number; num_threads: number };
}
export interface CuratorStatus {
enabled: boolean;
paused: boolean;
interval_hours: number | null;
last_run_at: string | null;
min_idle_hours: number | null;
stale_after_days: number | null;
archive_after_days: number | null;
}
export interface PortalFeature {
label: string;
state: string;
}
export interface PortalStatus {
logged_in: boolean;
portal_url: string | null;
inference_url: string | null;
provider: string;
subscription_url: string;
features: PortalFeature[];
}
export interface CheckpointSession {

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Clock, Pause, Play, Trash2, X, Zap } from "lucide-react";
import { Clock, Pause, Pencil, Play, Trash2, X, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
@ -119,6 +119,29 @@ export default function CronPage() {
const [creating, setCreating] = useState(false);
const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
// Edit job modal state
const [editJob, setEditJob] = useState<CronJob | null>(null);
const [editPrompt, setEditPrompt] = useState("");
const [editSchedule, setEditSchedule] = useState("");
const [editName, setEditName] = useState("");
const [editDeliver, setEditDeliver] = useState("local");
const [saving, setSaving] = useState(false);
const closeEditModal = useCallback(() => setEditJob(null), []);
const editModalRef = useModalBehavior({
open: editJob !== null,
onClose: closeEditModal,
});
const openEditModal = useCallback((job: CronJob) => {
setEditJob(job);
setEditPrompt(getJobPrompt(job));
setEditSchedule(
asText(job.schedule?.expr) || asText(job.schedule_display) || "",
);
setEditName(getJobName(job));
setEditDeliver(asText(job.deliver) || "local");
}, []);
const loadJobs = useCallback(() => {
api
.getCronJobs(selectedProfile)
@ -168,6 +191,34 @@ export default function CronPage() {
}
};
const handleEdit = async () => {
if (!editJob) return;
if (!editPrompt.trim() || !editSchedule.trim()) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
setSaving(true);
try {
await api.updateCronJob(
editJob.id,
{
prompt: editPrompt.trim(),
schedule: editSchedule.trim(),
name: editName.trim(),
deliver: editDeliver,
},
getJobProfile(editJob),
);
showToast("Saved changes ✓", "success");
setEditJob(null);
loadJobs();
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally {
setSaving(false);
}
};
const handlePauseResume = async (job: CronJob) => {
try {
const isPaused = getJobState(job) === "paused";
@ -394,6 +445,112 @@ export default function CronPage() {
</div>
)}
{/* Edit job modal */}
{editJob && (
<div
ref={editModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && setEditJob(null)}
role="dialog"
aria-modal="true"
aria-labelledby="edit-cron-title"
>
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={() => setEditJob(null)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="edit-cron-title"
className="font-mondwest text-display text-base tracking-wider"
>
Edit job
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-cron-name">{t.cron.nameOptional}</Label>
<Input
id="edit-cron-name"
autoFocus
placeholder={t.cron.namePlaceholder}
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-cron-prompt">{t.cron.prompt}</Label>
<textarea
id="edit-cron-prompt"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
placeholder={t.cron.promptPlaceholder}
value={editPrompt}
onChange={(e) => setEditPrompt(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-cron-schedule">{t.cron.schedule}</Label>
<Input
id="edit-cron-schedule"
placeholder={t.cron.schedulePlaceholder}
value={editSchedule}
onChange={(e) => setEditSchedule(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="edit-cron-deliver"
value={editDeliver}
onValueChange={(v) => setEditDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleEdit}
disabled={saving}
prefix={saving ? <Spinner /> : undefined}
>
{saving ? t.common.loading : "Save changes"}
</Button>
</div>
</div>
</div>
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
@ -501,6 +658,16 @@ export default function CronPage() {
<Zap />
</Button>
<Button
ghost
size="icon"
title="Edit job"
aria-label="Edit job"
onClick={() => openEditModal(job)}
>
<Pencil />
</Button>
<Button
ghost
destructive

View file

@ -1,12 +1,18 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Server, Trash2, X, Zap } from "lucide-react";
import { Package, Power, Server, Trash2, X, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { McpServer, McpServerCreate, McpTestResult } from "@/lib/api";
import type {
McpCatalogDiagnostic,
McpCatalogEntry,
McpServer,
McpServerCreate,
McpTestResult,
} from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
@ -55,6 +61,8 @@ const TRANSPORT_TONE: Record<string, "success" | "warning" | "secondary"> = {
export default function McpPage() {
const [servers, setServers] = useState<McpServer[]>([]);
const [catalog, setCatalog] = useState<McpCatalogEntry[]>([]);
const [diagnostics, setDiagnostics] = useState<McpCatalogDiagnostic[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
@ -80,17 +88,44 @@ export default function McpPage() {
Record<string, McpTestResult>
>({});
// Enable/disable state
const [togglingName, setTogglingName] = useState<string | null>(null);
const [restartNote, setRestartNote] = useState<string | null>(null);
// Catalog install modal state
const [installEntry, setInstallEntry] = useState<McpCatalogEntry | null>(
null,
);
const [installEnv, setInstallEnv] = useState<Record<string, string>>({});
const [installingName, setInstallingName] = useState<string | null>(null);
const closeInstallModal = useCallback(() => setInstallEntry(null), []);
const installModalRef = useModalBehavior({
open: installEntry !== null,
onClose: closeInstallModal,
});
const loadServers = useCallback(() => {
api
return api
.getMcpServers()
.then((res) => setServers(res.servers))
.catch((e) => showToast(`Error: ${e}`, "error"))
.finally(() => setLoading(false));
.catch((e) => showToast(`Error: ${e}`, "error"));
}, [showToast]);
const loadCatalog = useCallback(() => {
return api
.getMcpCatalog()
.then((res) => {
setCatalog(res.entries);
setDiagnostics(res.diagnostics);
})
.catch((e) => showToast(`Error: ${e}`, "error"));
}, [showToast]);
useEffect(() => {
loadServers();
}, [loadServers]);
Promise.all([loadServers(), loadCatalog()]).finally(() =>
setLoading(false),
);
}, [loadServers, loadCatalog]);
const handleCreate = async () => {
if (!name.trim()) {
@ -141,10 +176,7 @@ export default function McpPage() {
const result = await api.testMcpServer(server.name);
setTestResults((prev) => ({ ...prev, [server.name]: result }));
if (result.ok) {
showToast(
`${server.name}: ${result.tools.length} tool(s)`,
"success",
);
showToast(`${server.name}: ${result.tools.length} tool(s)`, "success");
} else {
showToast(`${server.name}: ${result.error ?? "Failed"}`, "error");
}
@ -155,6 +187,26 @@ export default function McpPage() {
}
};
const handleToggleEnabled = async (server: McpServer) => {
const next = !server.enabled;
setTogglingName(server.name);
try {
await api.setMcpServerEnabled(server.name, next);
setServers((prev) =>
prev.map((s) =>
s.name === server.name ? { ...s, enabled: next } : s,
),
);
setRestartNote(
"Enable/disable takes effect on the next gateway restart.",
);
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setTogglingName(null);
}
};
const serverDelete = useConfirmDelete({
onDelete: useCallback(
async (serverName: string) => {
@ -176,6 +228,58 @@ export default function McpPage() {
),
});
// ── Catalog install ──────────────────────────────────────────────────
const runInstall = useCallback(
async (entry: McpCatalogEntry, envMap: Record<string, string>) => {
setInstallingName(entry.name);
try {
const res = await api.installMcpCatalogEntry(entry.name, envMap, true);
if (res.background) {
showToast("Installing in background…", "success");
} else {
showToast(`Installed: "${truncateText(entry.name, 30)}"`, "success");
}
setInstallEntry(null);
setInstallEnv({});
await Promise.all([loadServers(), loadCatalog()]);
} catch (e) {
showToast(`Failed to install: ${e}`, "error");
} finally {
setInstallingName(null);
}
},
[loadServers, loadCatalog, showToast],
);
const handleInstallClick = (entry: McpCatalogEntry) => {
if (entry.required_env.length > 0) {
const initial: Record<string, string> = {};
entry.required_env.forEach((item) => {
initial[item.name] = "";
});
setInstallEnv(initial);
setInstallEntry(entry);
} else {
void runInstall(entry, {});
}
};
const handleInstallSubmit = () => {
if (!installEntry) return;
const missing = installEntry.required_env.filter(
(item) => item.required && !(installEnv[item.name] ?? "").trim(),
);
if (missing.length > 0) {
showToast(`${missing[0].prompt} required`, "error");
return;
}
const envMap: Record<string, string> = {};
Object.entries(installEnv).forEach(([k, v]) => {
if (v.trim()) envMap[k] = v.trim();
});
void runInstall(installEntry, envMap);
};
// Put "Add Server" button in page header
useLayoutEffect(() => {
setEnd(
@ -200,6 +304,11 @@ export default function McpPage() {
);
}
const diagnosticsByName: Record<string, McpCatalogDiagnostic[]> = {};
diagnostics.forEach((d) => {
(diagnosticsByName[d.name] ??= []).push(d);
});
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
@ -338,6 +447,91 @@ export default function McpPage() {
</div>
)}
{/* Catalog install modal (required env vars) */}
{installEntry && (
<div
ref={installModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) =>
e.target === e.currentTarget && setInstallEntry(null)
}
role="dialog"
aria-modal="true"
aria-labelledby="install-mcp-title"
>
<div
className={cn(
themedBody,
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
)}
>
<Button
ghost
size="icon"
onClick={() => setInstallEntry(null)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="install-mcp-title"
className="font-mondwest text-display text-base tracking-wider"
>
Install {installEntry.name}
</h2>
</header>
<div className="p-5 grid gap-4">
<p className="text-xs text-muted-foreground">
This MCP requires the following values to be configured.
</p>
{installEntry.required_env.map((item) => (
<div className="grid gap-2" key={item.name}>
<Label htmlFor={`install-env-${item.name}`}>
{item.prompt}
{item.required ? " *" : ""}
</Label>
<Input
id={`install-env-${item.name}`}
type="password"
placeholder={item.name}
value={installEnv[item.name] ?? ""}
onChange={(e) =>
setInstallEnv((prev) => ({
...prev,
[item.name]: e.target.value,
}))
}
/>
</div>
))}
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleInstallSubmit}
disabled={installingName === installEntry.name}
prefix={
installingName === installEntry.name ? (
<Spinner />
) : undefined
}
>
{installingName === installEntry.name
? "Installing..."
: "Install"}
</Button>
</div>
</div>
</div>
</div>
)}
{/* ── Your MCP servers ── */}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
@ -345,10 +539,14 @@ export default function McpPage() {
className="flex items-center gap-2 text-muted-foreground"
>
<Server className="h-4 w-4" />
MCP Servers ({servers.length})
Your MCP servers ({servers.length})
</H2>
</div>
{restartNote && (
<p className="text-xs text-warning">{restartNote}</p>
)}
{servers.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
@ -363,13 +561,20 @@ export default function McpPage() {
return (
<Card key={server.name}>
<CardContent className="flex items-start gap-4 py-4">
<CardContent
className={cn(
"flex items-start gap-4 py-4",
!server.enabled && "opacity-60",
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{server.name}
</span>
<Badge tone={TRANSPORT_TONE[server.transport] ?? "secondary"}>
<Badge
tone={TRANSPORT_TONE[server.transport] ?? "secondary"}
>
{server.transport}
</Badge>
{!server.enabled && (
@ -414,6 +619,25 @@ export default function McpPage() {
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="sm"
title={server.enabled ? "Disable" : "Enable"}
aria-label={server.enabled ? "Disable" : "Enable"}
onClick={() => handleToggleEnabled(server)}
disabled={togglingName === server.name}
prefix={
togglingName === server.name ? (
<Spinner />
) : (
<Power />
)
}
className={server.enabled ? "text-success" : undefined}
>
{server.enabled ? "Disable" : "Enable"}
</Button>
<Button
ghost
size="icon"
@ -441,6 +665,93 @@ export default function McpPage() {
);
})}
</div>
{/* ── Catalog ── */}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Package className="h-4 w-4" />
Catalog ({catalog.length})
</H2>
</div>
<p className="text-xs text-muted-foreground">
Browse Nous-approved MCP servers and install them with one click.
</p>
{catalog.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No catalog entries available.
</CardContent>
</Card>
)}
{catalog.map((entry) => {
const entryDiags = diagnosticsByName[entry.name] ?? [];
const isInstalling = installingName === entry.name;
return (
<Card key={entry.name}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-sm truncate">
{entry.name}
</span>
<Badge
tone={TRANSPORT_TONE[entry.transport] ?? "secondary"}
>
{entry.transport}
</Badge>
<Badge tone="outline">
{entry.source === "official" ? "official" : entry.source}
</Badge>
{entry.installed && (
<Badge tone="success">Installed</Badge>
)}
{entry.installed && !entry.enabled && (
<Badge tone="outline">disabled</Badge>
)}
</div>
{entry.description && (
<p className="text-xs text-muted-foreground">
{entry.description}
</p>
)}
{entryDiags.map((d, i) => (
<p
key={`${entry.name}-diag-${i}`}
className="text-xs text-warning mt-1"
>
{d.message}
</p>
))}
</div>
<div className="flex items-center gap-1 shrink-0">
{entry.installed ? (
<Badge tone="success">Installed</Badge>
) : (
<Button
className="uppercase"
size="sm"
onClick={() => handleInstallClick(entry)}
disabled={isInstalling}
prefix={isInstalling ? <Spinner /> : undefined}
>
{isInstalling ? "Installing..." : "Install"}
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}

View file

@ -23,12 +23,17 @@ import {
Hash,
X,
Play,
Download,
Pencil,
Check,
Archive,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
SessionInfo,
SessionMessage,
SessionSearchResult,
SessionStoreStats,
StatusResponse,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
@ -44,6 +49,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/c
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { Input } from "@nous-research/ui/ui/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useI18n } from "@/i18n";
@ -262,6 +275,8 @@ function SessionRow({
isExpanded,
onToggle,
onDelete,
onRename,
onExport,
resumeInChatEnabled,
}: {
session: SessionInfo;
@ -270,11 +285,16 @@ function SessionRow({
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
onRename: (id: string, title: string) => Promise<void>;
onExport: (id: string) => void;
resumeInChatEnabled: boolean;
}) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(session.title ?? "");
const [renameSaving, setRenameSaving] = useState(false);
const { t } = useI18n();
const navigate = useNavigate();
@ -295,6 +315,21 @@ function SessionRow({
const SourceIcon = sourceInfo.icon;
const hasTitle = session.title && session.title !== "Untitled";
const submitRename = async () => {
const value = renameValue.trim();
if (!value || value === session.title) {
setRenaming(false);
return;
}
setRenameSaving(true);
try {
await onRename(session.id, value);
setRenaming(false);
} finally {
setRenameSaving(false);
}
};
const actionButtons = (
<>
<Badge tone="outline" className="text-xs">
@ -317,6 +352,39 @@ function SessionRow({
</Button>
)}
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label="Rename session"
title="Rename session"
onClick={(e) => {
e.stopPropagation();
setRenameValue(
session.title && session.title !== "Untitled"
? session.title
: "",
);
setRenaming(true);
}}
>
<Pencil />
</Button>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label="Export session"
title="Export session JSON"
onClick={(e) => {
e.stopPropagation();
onExport(session.id);
}}
>
<Download />
</Button>
<Button
ghost
destructive
@ -351,15 +419,61 @@ function SessionRow({
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span
className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
>
{hasTitle
? session.title
: session.preview
? session.preview.slice(0, 60)
: t.sessions.untitledSession}
</span>
{renaming ? (
<div
className="flex min-w-0 flex-1 items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submitRename();
else if (e.key === "Escape") setRenaming(false);
}}
placeholder="Session title"
className="h-7 min-w-0 flex-1 py-0 text-sm"
disabled={renameSaving}
/>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label="Save title"
title="Save title"
disabled={renameSaving}
onClick={() => void submitRename()}
>
{renameSaving ? (
<Spinner className="text-sm" />
) : (
<Check />
)}
</Button>
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
aria-label="Cancel rename"
title="Cancel rename"
disabled={renameSaving}
onClick={() => setRenaming(false)}
>
<X />
</Button>
</div>
) : (
<span
className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
>
{hasTitle
? session.title
: session.preview
? session.preview.slice(0, 60)
: t.sessions.untitledSession}
</span>
)}
{session.is_active && (
<Badge tone="success" className="shrink-0 text-xs">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
@ -492,9 +606,13 @@ export default function SessionsPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
const [stats, setStats] = useState<SessionStoreStats | null>(null);
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneDays, setPruneDays] = useState("90");
const [pruning, setPruning] = useState(false);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle } = usePageHeader();
const { setAfterTitle, setEnd } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
@ -513,6 +631,23 @@ export default function SessionsPage() {
};
}, [loading, setAfterTitle, total]);
useEffect(() => {
setEnd(
<Button
outlined
size="sm"
className="gap-1.5"
onClick={() => setPruneOpen(true)}
>
<Archive className="h-3.5 w-3.5" />
Prune old sessions
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd]);
const loadSessions = useCallback((p: number) => {
setLoading(true);
api
@ -525,6 +660,17 @@ export default function SessionsPage() {
.finally(() => setLoading(false));
}, []);
const loadStats = useCallback(() => {
api
.getSessionStats()
.then(setStats)
.catch(() => {});
}, []);
useEffect(() => {
loadStats();
}, [loadStats]);
useEffect(() => {
loadSessions(page);
}, [loadSessions, page]);
@ -583,6 +729,7 @@ export default function SessionsPage() {
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
showToast(t.sessions.sessionDeleted, "success");
loadStats();
} catch {
showToast(t.sessions.failedToDelete, "error");
throw new Error("delete failed");
@ -591,12 +738,82 @@ export default function SessionsPage() {
[
expandedId,
showToast,
loadStats,
t.sessions.sessionDeleted,
t.sessions.failedToDelete,
],
),
});
const handleRename = useCallback(
async (id: string, title: string) => {
try {
await api.renameSession(id, title);
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s)),
);
setOverviewSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s)),
);
showToast("Session renamed", "success");
loadStats();
} catch {
showToast("Failed to rename session", "error");
}
},
[showToast, loadStats],
);
const handleExport = useCallback(
async (id: string) => {
try {
const res = await fetch(api.exportSessionUrl(id), {
credentials: "include",
headers: {
"X-Hermes-Session-Token":
(window as unknown as { __HERMES_SESSION_TOKEN__?: string })
.__HERMES_SESSION_TOKEN__ ?? "",
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `session-${id}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
showToast("Failed to export session", "error");
}
},
[showToast],
);
const handlePrune = useCallback(async () => {
const days = parseInt(pruneDays, 10);
if (!Number.isFinite(days) || days < 0) {
showToast("Enter a valid number of days", "error");
return;
}
setPruning(true);
try {
const resp = await api.pruneSessions(days);
showToast(
`Pruned ${resp.removed} session${resp.removed === 1 ? "" : "s"}`,
"success",
);
setPruneOpen(false);
loadSessions(0);
setPage(0);
loadStats();
} catch {
showToast("Failed to prune sessions", "error");
} finally {
setPruning(false);
}
}, [pruneDays, showToast, loadSessions, loadStats]);
const pendingSession = sessionDelete.pendingId
? sessions.find((s) => s.id === sessionDelete.pendingId)
: null;
@ -681,6 +898,98 @@ export default function SessionsPage() {
loading={sessionDelete.isDeleting}
/>
<Dialog
open={pruneOpen}
onOpenChange={(open) => {
if (!pruning) setPruneOpen(open);
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Prune old sessions</DialogTitle>
<DialogDescription>
Permanently remove archived sessions whose last activity is older
than the given number of days. Active sessions are never pruned.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<label
htmlFor="prune-days"
className="text-xs font-medium text-muted-foreground"
>
Older than (days)
</label>
<Input
id="prune-days"
type="number"
min={0}
value={pruneDays}
onChange={(e) => setPruneDays(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handlePrune();
}}
disabled={pruning}
/>
</div>
<DialogFooter>
<Button
outlined
onClick={() => setPruneOpen(false)}
disabled={pruning}
>
{t.common.cancel}
</Button>
<Button
destructive
onClick={() => void handlePrune()}
disabled={pruning}
className="gap-1.5"
>
{pruning && <Spinner className="text-sm" />}
Prune
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{stats && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border border-border bg-background-base/40 px-4 py-3">
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{stats.total}
</span>
<span className="text-xs text-muted-foreground">Total</span>
</div>
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none text-success">
{stats.active_store}
</span>
<span className="text-xs text-muted-foreground">Active in store</span>
</div>
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{stats.archived}
</span>
<span className="text-xs text-muted-foreground">Archived</span>
</div>
<div className="flex flex-col">
<span className="text-lg font-semibold tabular-nums leading-none">
{stats.messages}
</span>
<span className="text-xs text-muted-foreground">Messages</span>
</div>
{Object.keys(stats.by_source).length > 0 && (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{Object.entries(stats.by_source).map(([src, count]) => (
<Badge key={src} tone="outline" className="text-xs">
{src}: {count}
</Badge>
))}
</div>
)}
</div>
)}
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
@ -850,6 +1159,8 @@ export default function SessionsPage() {
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => sessionDelete.requestDelete(s.id)}
onRename={handleRename}
onExport={handleExport}
resumeInChatEnabled={resumeInChatEnabled}
/>
))}

View file

@ -14,9 +14,11 @@ import {
Code,
Zap,
Filter,
Download,
RefreshCw,
} from "lucide-react";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import type { SkillInfo, ToolsetInfo, SkillHubResult } from "@/lib/api";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
@ -98,7 +100,7 @@ export default function SkillsPage() {
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [view, setView] = useState<"skills" | "toolsets">("skills");
const [view, setView] = useState<"skills" | "toolsets" | "hub">("skills");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
const { toast, showToast } = useToast();
@ -284,6 +286,15 @@ export default function SkillsPage() {
setSearch("");
}}
/>
<PanelItem
icon={Search}
label="Browse hub"
active={view === "hub"}
onClick={() => {
setView("hub");
setSearch("");
}}
/>
</div>
{view === "skills" &&
@ -408,7 +419,7 @@ export default function SkillsPage() {
)}
</CardContent>
</Card>
) : (
) : view === "toolsets" ? (
/* Toolsets grid */
<>
{filteredToolsets.length === 0 ? (
@ -484,6 +495,8 @@ export default function SkillsPage() {
</div>
)}
</>
) : (
<HubBrowser showToast={showToast} />
)}
</div>
</div>
@ -555,3 +568,188 @@ interface SkillRowProps {
skill: SkillInfo;
toggling: boolean;
}
/* ------------------------------------------------------------------ */
/* Hub browser — search the skill hub, install by identifier */
/* ------------------------------------------------------------------ */
function HubBrowser({
showToast,
}: {
showToast: (msg: string, kind: "success" | "error") => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SkillHubResult[]>([]);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
// Live action log for the most recent install/update (tailed via action status).
const [action, setAction] = useState<string | null>(null);
const [actionLog, setActionLog] = useState<string[]>([]);
const [actionRunning, setActionRunning] = useState(false);
const runSearch = async () => {
const q = query.trim();
if (!q) return;
setSearching(true);
setSearched(true);
try {
const r = await api.searchSkillsHub(q);
setResults(r.results);
} catch (e) {
showToast(`Hub search failed: ${e}`, "error");
setResults([]);
} finally {
setSearching(false);
}
};
// Poll a spawned action's log until it exits.
useEffect(() => {
if (!action) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
try {
const st = await api.getActionStatus(action, 200);
if (cancelled) return;
setActionLog(st.lines);
setActionRunning(st.running);
if (st.running) timer = setTimeout(poll, 1200);
} catch {
if (!cancelled) setActionRunning(false);
}
};
poll();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [action]);
const install = async (identifier: string) => {
try {
const res = await api.installSkillFromHub(identifier);
showToast(`Installing ${identifier}`, "success");
setActionLog([]);
setActionRunning(true);
setAction(res.name);
} catch (e) {
showToast(`Install failed: ${e}`, "error");
}
};
const updateAll = async () => {
try {
const res = await api.updateSkillsFromHub();
showToast("Updating installed skills…", "success");
setActionLog([]);
setActionRunning(true);
setAction(res.name);
} catch (e) {
showToast(`Update failed: ${e}`, "error");
}
};
return (
<div className="flex flex-col gap-3">
<Card className="rounded-none">
<CardContent className="py-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 text-sm"
placeholder="Search the skill hub (GitHub, official, community)…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void runSearch();
}}
/>
</div>
<Button
size="sm"
onClick={() => void runSearch()}
disabled={searching || !query.trim()}
prefix={searching ? <Spinner /> : <Search className="h-3.5 w-3.5" />}
>
Search
</Button>
<Button
size="sm"
outlined
onClick={() => void updateAll()}
prefix={<RefreshCw className="h-3.5 w-3.5" />}
>
Update all
</Button>
</div>
<p className="text-xs text-muted-foreground">
Results come from the same sources as <span className="font-mono">hermes skills search</span>.
Installs run in the background; the log streams below.
</p>
</CardContent>
</Card>
{action && (
<Card className="rounded-none">
<CardContent className="py-3">
<div className="flex items-center gap-2 mb-2">
<Download className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-xs">{action}</span>
{actionRunning ? (
<Badge tone="warning">running</Badge>
) : (
<Badge tone="success">done</Badge>
)}
</div>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words bg-background/50 border border-border p-2 text-xs font-mono text-muted-foreground">
{actionLog.length ? actionLog.join("\n") : "Starting…"}
</pre>
</CardContent>
</Card>
)}
{searching && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{!searching && searched && results.length === 0 && (
<Card className="rounded-none">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No matching skills found in the hub.
</CardContent>
</Card>
)}
{results.map((r) => (
<Card key={r.identifier} className="rounded-none">
<CardContent className="py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-mono-ui text-sm">{r.name}</span>
<Badge tone="secondary" className="text-xs">{r.source}</Badge>
<Badge tone="outline" className="text-xs">{r.trust_level}</Badge>
</div>
<p className="text-xs text-text-secondary">{r.description}</p>
<p className="text-xs font-mono text-text-tertiary truncate mt-0.5">
{r.identifier}
</p>
</div>
<Button
size="sm"
outlined
className="shrink-0"
onClick={() => void install(r.identifier)}
prefix={<Download className="h-3.5 w-3.5" />}
>
Install
</Button>
</CardContent>
</Card>
))}
</div>
);
}

View file

@ -2,12 +2,18 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
Activity,
Brain,
Cpu,
Database,
Globe,
HardDrive,
KeyRound,
Play,
Plus,
Power,
RotateCw,
Server,
ShieldCheck,
Sparkles,
Stethoscope,
Terminal,
Trash2,
@ -24,7 +30,9 @@ import { Label } from "@nous-research/ui/ui/components/label";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { cn, themedBody } from "@/lib/utils";
import { api } from "@/lib/api";
import type {
StatusResponse,
@ -32,20 +40,32 @@ import type {
CredentialPoolProvider,
CheckpointsResponse,
HooksResponse,
HookEntry,
SystemStats,
CuratorStatus,
PortalStatus,
} from "@/lib/api";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDuration(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
/**
* A running-action log viewer. The spawn-based admin actions (doctor,
* security audit, backup, import, skills update, checkpoints prune,
* gateway start/stop) stream their stdout to a per-action log file the
* server tails via /api/actions/<name>/status. When an action is launched
* we poll that endpoint until the process exits, showing live output.
* Live action-log viewer for the spawn-based admin actions (doctor, audit,
* backup, import, skills update, checkpoints prune, gateway start/stop).
* Polls /api/actions/<name>/status until the process exits.
*/
function ActionLogViewer({
action,
@ -68,13 +88,9 @@ function ActionLogViewer({
setLines(st.lines);
setRunning(st.running);
setExitCode(st.exit_code);
if (st.running) {
timer.current = setTimeout(poll, 1200);
}
if (st.running) timer.current = setTimeout(poll, 1200);
} catch {
if (!cancelled) {
setRunning(false);
}
if (!cancelled) setRunning(false);
}
};
poll();
@ -111,19 +127,30 @@ function ActionLogViewer({
);
}
const HOOK_EVENTS_FALLBACK = [
"pre_tool_call",
"post_tool_call",
"pre_llm_call",
"post_llm_call",
"on_session_start",
"on_session_end",
];
export default function SystemPage() {
const { toast, showToast } = useToast();
const [status, setStatus] = useState<StatusResponse | null>(null);
const [stats, setStats] = useState<SystemStats | null>(null);
const [memory, setMemory] = useState<MemoryStatus | null>(null);
const [pool, setPool] = useState<CredentialPoolProvider[]>([]);
const [checkpoints, setCheckpoints] = useState<CheckpointsResponse | null>(
null,
);
const [hooks, setHooks] = useState<HooksResponse | null>(null);
const [curator, setCurator] = useState<CuratorStatus | null>(null);
const [portal, setPortal] = useState<PortalStatus | null>(null);
const [loading, setLoading] = useState(true);
// Which spawn-action log is currently shown (null = none).
const [activeAction, setActiveAction] = useState<string | null>(null);
// Add-credential form.
@ -132,23 +159,42 @@ export default function SystemPage() {
const [credLabel, setCredLabel] = useState("");
const [addingCred, setAddingCred] = useState(false);
// Import archive path.
const [importPath, setImportPath] = useState("");
// Create-hook modal.
const [hookModalOpen, setHookModalOpen] = useState(false);
const closeHookModal = useCallback(() => setHookModalOpen(false), []);
const hookModalRef = useModalBehavior({
open: hookModalOpen,
onClose: closeHookModal,
});
const [hookEvent, setHookEvent] = useState("pre_tool_call");
const [hookCommand, setHookCommand] = useState("");
const [hookMatcher, setHookMatcher] = useState("");
const [hookTimeout, setHookTimeout] = useState("");
const [hookApprove, setHookApprove] = useState(true);
const [creatingHook, setCreatingHook] = useState(false);
const loadAll = useCallback(() => {
Promise.allSettled([
api.getStatus(),
api.getSystemStats(),
api.getMemory(),
api.getCredentialPool(),
api.getCheckpoints(),
api.getHooks(),
api.getCurator(),
api.getPortal(),
])
.then(([s, m, p, c, h]) => {
.then(([s, st, m, p, c, h, cur, prt]) => {
if (s.status === "fulfilled") setStatus(s.value);
if (st.status === "fulfilled") setStats(st.value);
if (m.status === "fulfilled") setMemory(m.value);
if (p.status === "fulfilled") setPool(p.value.providers);
if (c.status === "fulfilled") setCheckpoints(c.value);
if (h.status === "fulfilled") setHooks(h.value);
if (cur.status === "fulfilled") setCurator(cur.value);
if (prt.status === "fulfilled") setPortal(prt.value);
})
.finally(() => setLoading(false));
}, []);
@ -158,9 +204,7 @@ export default function SystemPage() {
}, [loadAll]);
// ── Gateway lifecycle ──────────────────────────────────────────────
const runGateway = async (
verb: "start" | "stop" | "restart",
) => {
const runGateway = async (verb: "start" | "stop" | "restart") => {
try {
if (verb === "start") {
await api.startGateway();
@ -179,14 +223,23 @@ export default function SystemPage() {
}
};
// ── Curator ────────────────────────────────────────────────────────
const toggleCuratorPaused = async () => {
if (!curator) return;
try {
await api.setCuratorPaused(!curator.paused);
showToast(curator.paused ? "Curator resumed" : "Curator paused", "success");
loadAll();
} catch (e) {
showToast(`Curator toggle failed: ${e}`, "error");
}
};
// ── Memory ─────────────────────────────────────────────────────────
const setMemoryProvider = async (provider: string) => {
try {
await api.setMemoryProvider(provider);
showToast(
`Memory provider: ${provider || "built-in only"}`,
"success",
);
showToast(`Memory provider: ${provider || "built-in only"}`, "success");
loadAll();
} catch (e) {
showToast(`Failed to set provider: ${e}`, "error");
@ -253,10 +306,7 @@ export default function SystemPage() {
});
// ── Operations ─────────────────────────────────────────────────────
const runOp = async (
fn: () => Promise<{ name: string }>,
label: string,
) => {
const runOp = async (fn: () => Promise<{ name: string }>, label: string) => {
try {
const res = await fn();
setActiveAction(res.name);
@ -279,6 +329,53 @@ export default function SystemPage() {
}, [showToast]),
});
// ── Hooks ──────────────────────────────────────────────────────────
const createHook = async () => {
if (!hookCommand.trim()) {
showToast("Command is required", "error");
return;
}
setCreatingHook(true);
try {
await api.createHook({
event: hookEvent,
command: hookCommand.trim(),
matcher: hookMatcher.trim() || undefined,
timeout: hookTimeout.trim() ? Number(hookTimeout) : undefined,
approve: hookApprove,
});
showToast("Hook created", "success");
setHookCommand("");
setHookMatcher("");
setHookTimeout("");
setHookModalOpen(false);
loadAll();
} catch (e) {
showToast(`Failed to create hook: ${e}`, "error");
} finally {
setCreatingHook(false);
}
};
const hookDelete = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
const sep = key.indexOf("|");
const event = key.slice(0, sep);
const command = key.slice(sep + 1);
try {
await api.deleteHook(event, command);
showToast("Hook removed", "success");
loadAll();
} catch (e) {
showToast(`Failed to remove hook: ${e}`, "error");
throw e;
}
},
[loadAll, showToast],
),
});
if (loading) {
return (
<div className="flex items-center justify-center py-24">
@ -288,6 +385,9 @@ export default function SystemPage() {
}
const gatewayRunning = status?.gateway_running;
const validEvents = hooks?.valid_events?.length
? hooks.valid_events
: HOOK_EVENTS_FALLBACK;
return (
<div className="flex flex-col gap-8">
@ -317,6 +417,112 @@ export default function SystemPage() {
description="Delete the rollback checkpoint shadow store? Existing /rollback points will be lost."
loading={checkpointsPrune.isDeleting}
/>
<DeleteConfirmDialog
open={hookDelete.isOpen}
onCancel={hookDelete.cancel}
onConfirm={hookDelete.confirm}
title="Remove shell hook"
description="Remove this hook from config and revoke its consent? It stops firing on the next restart."
loading={hookDelete.isDeleting}
/>
{/* Create-hook modal */}
{hookModalOpen && (
<div
ref={hookModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && setHookModalOpen(false)}
role="dialog"
aria-modal="true"
>
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={() => setHookModalOpen(false)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2 className="font-mondwest text-display text-base tracking-wider">
New shell hook
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="hook-event">Event</Label>
<Select
id="hook-event"
value={hookEvent}
onValueChange={(v) => setHookEvent(v)}
>
{validEvents.map((ev) => (
<SelectOption key={ev} value={ev}>
{ev}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="hook-command">Command (absolute path)</Label>
<Input
id="hook-command"
autoFocus
placeholder="/usr/local/bin/my-hook.sh"
value={hookCommand}
onChange={(e) => setHookCommand(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="hook-matcher">Matcher (optional)</Label>
<Input
id="hook-matcher"
placeholder="e.g. terminal"
value={hookMatcher}
onChange={(e) => setHookMatcher(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="hook-timeout">Timeout (s)</Label>
<Input
id="hook-timeout"
placeholder="10"
value={hookTimeout}
onChange={(e) => setHookTimeout(e.target.value)}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={hookApprove}
onChange={(e) => setHookApprove(e.target.checked)}
/>
Approve now (grant consent so it fires; otherwise it stays
configured but inactive)
</label>
<p className="text-xs text-warning">
Shell hooks run arbitrary commands on this host. Only add scripts
you trust. Takes effect on the next gateway/session restart.
</p>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={createHook}
disabled={creatingHook}
prefix={creatingHook ? <Spinner /> : undefined}
>
{creatingHook ? "Creating" : "Create hook"}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Live action log */}
{activeAction && (
@ -326,6 +532,166 @@ export default function SystemPage() {
/>
)}
{/* ── Host / system stats ───────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Server className="h-4 w-4" /> Host
</H2>
<Card>
<CardContent className="py-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-y-3 gap-x-6 text-sm">
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">OS</div>
<div>{stats?.os} {stats?.os_release}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Arch</div>
<div>{stats?.arch}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Host</div>
<div className="truncate">{stats?.hostname}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Python</div>
<div>{stats?.python_impl} {stats?.python_version}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Hermes</div>
<div>v{stats?.hermes_version}</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<Cpu className="h-3 w-3" /> CPU
</div>
<div>
{stats?.cpu_count ?? "—"} cores
{typeof stats?.cpu_percent === "number"
? ` · ${stats.cpu_percent.toFixed(0)}%`
: ""}
</div>
</div>
{stats?.memory && (
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Memory</div>
<div>
{formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)} ({stats.memory.percent}%)
</div>
</div>
)}
{stats?.disk && (
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<HardDrive className="h-3 w-3" /> Disk
</div>
<div>
{formatBytes(stats.disk.used)} / {formatBytes(stats.disk.total)} ({stats.disk.percent}%)
</div>
</div>
)}
{typeof stats?.uptime_seconds === "number" && (
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Uptime</div>
<div>{formatDuration(stats.uptime_seconds)}</div>
</div>
)}
{stats?.load_avg && stats.load_avg.length >= 3 && (
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Load avg</div>
<div>{stats.load_avg.map((n) => n.toFixed(2)).join(" / ")}</div>
</div>
)}
</div>
{stats && !stats.psutil && (
<p className="mt-3 text-xs text-muted-foreground">
Install the <span className="font-mono">psutil</span> extra for
CPU / memory / disk metrics.
</p>
)}
</CardContent>
</Card>
</section>
{/* ── Portal ────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4" /> Nous Portal
</H2>
<Card>
<CardContent className="flex flex-col gap-3 py-4">
<div className="flex items-center gap-3">
<Badge tone={portal?.logged_in ? "success" : "secondary"}>
{portal?.logged_in ? "logged in" : "not logged in"}
</Badge>
{portal?.provider && (
<span className="text-sm text-muted-foreground">
inference provider: {portal.provider}
</span>
)}
<a
href={portal?.subscription_url || "https://portal.nousresearch.com/manage-subscription"}
target="_blank"
rel="noreferrer"
className="ml-auto text-xs text-primary underline"
>
Manage subscription
</a>
</div>
{portal?.features && portal.features.length > 0 && (
<div className="flex flex-col gap-1 border-t border-border pt-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
Tool Gateway routing
</span>
{portal.features.map((f) => (
<div key={f.label} className="flex items-center justify-between text-sm">
<span>{f.label}</span>
<span className="text-muted-foreground">{f.state}</span>
</div>
))}
</div>
)}
{!portal?.logged_in && (
<p className="text-xs text-muted-foreground">
Log in with <span className="font-mono">hermes auth add nous --type oauth</span>.
</p>
)}
</CardContent>
</Card>
</section>
{/* ── Curator ───────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Sparkles className="h-4 w-4" /> Skill curator
</H2>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Badge tone={curator?.paused ? "warning" : curator?.enabled ? "success" : "secondary"}>
{curator?.paused ? "paused" : curator?.enabled ? "active" : "disabled"}
</Badge>
<span className="text-sm text-muted-foreground">
{curator?.interval_hours ? `every ${curator.interval_hours}h` : ""}
{curator?.last_run_at ? ` · last run ${new Date(curator.last_run_at).toLocaleString()}` : " · never run"}
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" ghost onClick={toggleCuratorPaused}>
{curator?.paused ? "Resume" : "Pause"}
</Button>
<Button
size="sm"
ghost
prefix={<Play className="h-3.5 w-3.5" />}
onClick={() => runOp(api.runCurator, "Curator review")}
>
Run now
</Button>
</div>
</CardContent>
</Card>
</section>
{/* ── Gateway ───────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
@ -402,7 +768,6 @@ export default function SystemPage() {
<span className="font-mono">hermes memory setup</span>.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<span className="text-xs text-muted-foreground">
Built-in files MEMORY.md:{" "}
@ -410,28 +775,13 @@ export default function SystemPage() {
{formatBytes(memory?.builtin_files.user ?? 0)}
</span>
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
ghost
className="text-destructive"
onClick={() => memoryReset.requestDelete("memory")}
>
<Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("memory")}>
Reset MEMORY.md
</Button>
<Button
size="sm"
ghost
className="text-destructive"
onClick={() => memoryReset.requestDelete("user")}
>
<Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("user")}>
Reset USER.md
</Button>
<Button
size="sm"
ghost
className="text-destructive"
onClick={() => memoryReset.requestDelete("all")}
>
<Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("all")}>
Reset all
</Button>
</div>
@ -450,45 +800,22 @@ export default function SystemPage() {
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 items-end">
<div className="grid gap-2">
<Label htmlFor="cred-provider">Provider</Label>
<Input
id="cred-provider"
value={credProvider}
onChange={(e) => setCredProvider(e.target.value)}
placeholder="openrouter"
/>
<Input id="cred-provider" value={credProvider} onChange={(e) => setCredProvider(e.target.value)} placeholder="openrouter" />
</div>
<div className="grid gap-2 sm:col-span-2">
<Label htmlFor="cred-key">API key</Label>
<Input
id="cred-key"
type="password"
value={credKey}
onChange={(e) => setCredKey(e.target.value)}
placeholder="sk-…"
/>
<Input id="cred-key" type="password" value={credKey} onChange={(e) => setCredKey(e.target.value)} placeholder="sk-…" />
</div>
<div className="grid gap-2">
<Label htmlFor="cred-label">Label</Label>
<Input
id="cred-label"
value={credLabel}
onChange={(e) => setCredLabel(e.target.value)}
placeholder="optional"
/>
<Input id="cred-label" value={credLabel} onChange={(e) => setCredLabel(e.target.value)} placeholder="optional" />
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
className="uppercase"
onClick={addCredential}
disabled={addingCred}
prefix={addingCred ? <Spinner /> : undefined}
>
<Button size="sm" className="uppercase" onClick={addCredential} disabled={addingCred} prefix={addingCred ? <Spinner /> : undefined}>
Add key
</Button>
</div>
{pool.length === 0 && (
<p className="text-sm text-muted-foreground">
No pooled credentials. Add one above to enable key rotation.
@ -500,29 +827,12 @@ export default function SystemPage() {
{prov.provider}
</span>
{prov.entries.map((entry) => (
<div
key={`${prov.provider}-${entry.index}`}
className="flex items-center gap-3 border border-border bg-background/40 px-3 py-2"
>
<div key={`${prov.provider}-${entry.index}`} className="flex items-center gap-3 border border-border bg-background/40 px-3 py-2">
<span className="text-sm font-medium">{entry.label}</span>
<span className="font-mono text-xs text-muted-foreground">
{entry.token_preview}
</span>
<span className="font-mono text-xs text-muted-foreground">{entry.token_preview}</span>
<Badge tone="outline">{entry.auth_type}</Badge>
{entry.last_status && (
<Badge tone="secondary">{entry.last_status}</Badge>
)}
<Button
ghost
size="icon"
className="ml-auto text-destructive"
aria-label="Remove credential"
onClick={() =>
credDelete.requestDelete(
`${prov.provider}|${entry.index}`,
)
}
>
{entry.last_status && <Badge tone="secondary">{entry.last_status}</Badge>}
<Button ghost size="icon" className="ml-auto text-destructive" aria-label="Remove credential" onClick={() => credDelete.requestDelete(`${prov.provider}|${entry.index}`)}>
<Trash2 />
</Button>
</div>
@ -540,52 +850,34 @@ export default function SystemPage() {
</H2>
<Card>
<CardContent className="flex flex-wrap gap-2 py-4">
<Button
size="sm"
ghost
prefix={<Stethoscope className="h-3.5 w-3.5" />}
onClick={() => runOp(api.runDoctor, "Doctor")}
>
<Button size="sm" ghost prefix={<Stethoscope className="h-3.5 w-3.5" />} onClick={() => runOp(api.runDoctor, "Doctor")}>
Run doctor
</Button>
<Button
size="sm"
ghost
prefix={<ShieldCheck className="h-3.5 w-3.5" />}
onClick={() => runOp(api.runSecurityAudit, "Security audit")}
>
<Button size="sm" ghost prefix={<ShieldCheck className="h-3.5 w-3.5" />} onClick={() => runOp(api.runSecurityAudit, "Security audit")}>
Security audit
</Button>
<Button
size="sm"
ghost
prefix={<Database className="h-3.5 w-3.5" />}
onClick={() => runOp(() => api.runBackup(), "Backup")}
>
Backup
<Button size="sm" ghost prefix={<Database className="h-3.5 w-3.5" />} onClick={() => runOp(() => api.runBackup(), "Backup")}>
Create backup
</Button>
<Button
size="sm"
ghost
prefix={<RotateCw className="h-3.5 w-3.5" />}
onClick={() => runOp(api.updateSkillsFromHub, "Skills update")}
>
<Button size="sm" ghost prefix={<RotateCw className="h-3.5 w-3.5" />} onClick={() => runOp(api.updateSkillsFromHub, "Skills update")}>
Update skills
</Button>
<Button size="sm" ghost prefix={<Activity className="h-3.5 w-3.5" />} onClick={() => runOp(api.runPromptSize, "Prompt size")}>
Prompt size
</Button>
<Button size="sm" ghost prefix={<Database className="h-3.5 w-3.5" />} onClick={() => runOp(api.runDump, "Support dump")}>
Support dump
</Button>
<Button size="sm" ghost prefix={<RotateCw className="h-3.5 w-3.5" />} onClick={() => runOp(api.runConfigMigrate, "Config migrate")}>
Migrate config
</Button>
</CardContent>
</Card>
{/* Import from backup */}
<Card>
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-end">
<div className="grid gap-2 flex-1">
<Label htmlFor="import-path">Restore from backup archive</Label>
<Input
id="import-path"
value={importPath}
onChange={(e) => setImportPath(e.target.value)}
placeholder="/path/to/hermes-backup.zip"
/>
<Input id="import-path" value={importPath} onChange={(e) => setImportPath(e.target.value)} placeholder="/path/to/hermes-backup.zip" />
</div>
<Button
size="sm"
@ -613,25 +905,23 @@ export default function SystemPage() {
{checkpoints?.sessions.length ?? 0} session(s) ·{" "}
{formatBytes(checkpoints?.total_bytes ?? 0)}
</span>
<Button
size="sm"
ghost
className="text-destructive"
disabled={!checkpoints?.sessions.length}
prefix={<Trash2 className="h-3.5 w-3.5" />}
onClick={() => checkpointsPrune.requestDelete("all")}
>
<Button size="sm" ghost className="text-destructive" disabled={!checkpoints?.sessions.length} prefix={<Trash2 className="h-3.5 w-3.5" />} onClick={() => checkpointsPrune.requestDelete("all")}>
Prune
</Button>
</CardContent>
</Card>
</section>
{/* ── Hooks ─────────────────────────────────────────────────── */}
{/* ── Shell hooks ───────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Terminal className="h-4 w-4" /> Shell hooks
</H2>
<div className="flex items-center justify-between">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Terminal className="h-4 w-4" /> Shell hooks
</H2>
<Button size="sm" className="uppercase" prefix={<Plus className="h-3.5 w-3.5" />} onClick={() => setHookModalOpen(true)}>
New hook
</Button>
</div>
{(!hooks || hooks.hooks.length === 0) && (
<Card>
<CardContent className="py-6 text-center text-sm text-muted-foreground">
@ -639,21 +929,31 @@ export default function SystemPage() {
</CardContent>
</Card>
)}
{hooks?.hooks.map((h, i) => (
{hooks?.hooks.map((h: HookEntry, i) => (
<Card key={`${h.event}-${i}`}>
<CardContent className="flex items-center gap-3 py-3">
<Badge tone="outline">{h.event}</Badge>
{h.matcher && (
<span className="text-xs text-muted-foreground">
matcher: {h.matcher}
</span>
<span className="text-xs text-muted-foreground">matcher: {h.matcher}</span>
)}
<span className="font-mono text-xs truncate flex-1">{h.command}</span>
{h.executable === false && (
<Badge tone="destructive">not executable</Badge>
)}
<span className="font-mono text-xs truncate flex-1">
{h.command}
</span>
<Badge tone={h.allowed ? "success" : "warning"}>
{h.allowed ? "allowed" : "not approved"}
</Badge>
<Button
ghost
size="icon"
className="text-destructive"
aria-label="Remove hook"
onClick={() =>
hookDelete.requestDelete(`${h.event}|${h.command ?? ""}`)
}
>
<Trash2 />
</Button>
</CardContent>
</Card>
))}

View file

@ -128,6 +128,27 @@ export default function WebhooksPage() {
}
};
const [togglingName, setTogglingName] = useState<string | null>(null);
const handleToggleEnabled = useCallback(
async (subName: string, nextEnabled: boolean) => {
setTogglingName(subName);
try {
await api.setWebhookEnabled(subName, nextEnabled);
showToast(
nextEnabled ? `Enabled: "${subName}"` : `Disabled: "${subName}"`,
"success",
);
loadWebhooks();
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setTogglingName(null);
}
},
[loadWebhooks, showToast],
);
const webhookDelete = useConfirmDelete({
onDelete: useCallback(
async (name: string) => {
@ -378,6 +399,11 @@ export default function WebhooksPage() {
Subscriptions ({subscriptions.length})
</H2>
<p className="text-xs text-muted-foreground -mt-1">
Disabled webhooks reject incoming events; the gateway hot-reloads
changes (no restart needed).
</p>
{subscriptions.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
@ -389,7 +415,7 @@ export default function WebhooksPage() {
{subscriptions.map((sub: WebhookRoute) => (
<Card key={sub.name}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className={cn("flex-1 min-w-0", !sub.enabled && "opacity-60")}>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-sm truncate">
{sub.name}
@ -398,6 +424,7 @@ export default function WebhooksPage() {
{sub.deliver_only && (
<Badge tone="secondary">deliver only</Badge>
)}
{!sub.enabled && <Badge tone="warning">disabled</Badge>}
</div>
{sub.description && (
@ -427,6 +454,15 @@ export default function WebhooksPage() {
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="sm"
className="uppercase"
disabled={togglingName === sub.name}
onClick={() => handleToggleEnabled(sub.name, !sub.enabled)}
>
{sub.enabled ? "Disable" : "Enable"}
</Button>
<Button
ghost
destructive

View file

@ -142,10 +142,16 @@ Advanced/rarely-used keys are hidden by default behind a toggle.
Browse and inspect all agent sessions. Each row shows the session title, source platform icon (CLI, Telegram, Discord, Slack, cron), model name, message count, tool call count, and how long ago it was active. Live sessions are marked with a pulsing badge.
- **Search** — full-text search across all message content using FTS5. Results show highlighted snippets and auto-scroll to the first matching message when expanded.
- **Stats** — a summary bar shows total sessions, how many are active in the store, archived count, total messages, and a per-source breakdown.
- **Expand** — click a session to load its full message history. Messages are color-coded by role (user, assistant, system, tool) and rendered as Markdown with syntax highlighting.
- **Tool calls** — assistant messages with tool calls show collapsible blocks with the function name and JSON arguments.
- **Rename** — set or clear a session's title inline (pencil icon).
- **Export** — download a session (metadata + full message history) as JSON (download icon).
- **Prune** — the header "Prune old sessions" button deletes ended sessions older than N days.
- **Delete** — remove a session and its message history with the trash icon.
![Sessions admin page — stats bar, prune, and per-row rename / export / delete](/img/dashboard/admin-sessions.png)
### Logs
View agent, gateway, and error log files with filtering and live tailing.
@ -173,28 +179,42 @@ Create and manage scheduled cron jobs that run agent prompts on a recurring sche
- **Create** — fill in a name (optional), prompt, cron expression (e.g. `0 9 * * *`), and delivery target (local, Telegram, Discord, Slack, or email)
- **Job list** — each job shows its name, prompt preview, schedule expression, state badge (enabled/paused/error), delivery target, last run time, and next run time
- **Pause / Resume** — toggle a job between active and paused states
- **Edit** — open a pre-filled modal to change a job's prompt, schedule, name, or delivery target
- **Trigger now** — immediately execute a job outside its normal schedule
- **Delete** — permanently remove a cron job
### Skills
Browse, search, and toggle skills and toolsets. Skills are loaded from `~/.hermes/skills/` and grouped by category.
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.
- **Search** — filter skills and toolsets by name, description, or category
- **Search** — filter installed skills and toolsets by name, description, or category
- **Category filter** — click category pills to narrow the list (e.g. MLOps, MCP, Red Teaming, AI)
- **Toggle** — enable or disable individual skills with a switch. Changes take effect on the next session.
- **Toolsets** — a separate section shows built-in toolsets (file operations, web browsing, etc.) with their active/inactive status, setup requirements, and list of included tools
- **Toolsets** — a separate view shows built-in toolsets (file operations, web browsing, etc.) with their active/inactive status, setup requirements, and list of included tools
- **Browse hub** — a third view searches the skill hub across all sources (the same as `hermes skills search`), installs any result by identifier with a live install log, and offers an "Update all" button to refresh installed skills.
![Skills admin page — the Browse hub view: search, install, and update](/img/dashboard/admin-skills-hub.png)
### MCP
Manage [MCP](/integrations/mcp) servers without the CLI. The same `mcp_servers`
block in `config.yaml` that `hermes mcp` reads from.
**Your MCP servers:**
- **Add** — register an HTTP/SSE server (URL) or a stdio server (command + args), with optional `KEY=VALUE` environment variables for stdio servers
- **Enable / disable** — toggle a server on or off without deleting it. A disabled server stays in config so you can re-enable it later. Takes effect on the next gateway restart.
- **Test** — connect to a server, list its tools, and disconnect — verifies the connection before the agent depends on it
- **Remove** — delete a server from the config
- Secret-shaped env values are redacted in the list view
**Catalog:** browse the Nous-approved MCP servers (the bundled `optional-mcps/`
catalog) and install any of them with one click. Entries that need API keys
prompt for them inline; the values go to `.env`. This is the same catalog
`hermes mcp catalog` / `hermes mcp install` use.
![MCP admin page — your servers with enable/disable toggles, plus the install catalog](/img/dashboard/admin-mcp.png)
### Webhooks
Manage dynamic [webhook subscriptions](/user-guide/messaging/webhooks). The
@ -202,28 +222,47 @@ webhook platform must be enabled in messaging settings first; the page shows a
hint when it isn't.
- **Create** — name, description, event filter, delivery target, optional direct-delivery mode, and an agent prompt. On creation the page surfaces the route URL and the one-time HMAC secret to copy.
- **Enable / disable** — toggle a subscription on or off. Disabled routes stay in the subscriptions file but the gateway rejects their incoming events (403). The gateway hot-reloads the file, so the change takes effect on the next event — no restart needed.
- **List** — each subscription shows its URL, events, and delivery target
- **Delete** — remove a subscription (hot-reloaded by the gateway, no restart needed)
- **Delete** — remove a subscription
![Webhooks admin page — subscriptions with enable/disable toggles](/img/dashboard/admin-webhooks.png)
### Pairing
Approve and revoke messaging users without the CLI — how a remote admin
onboards Telegram/Discord/etc. users to a paired gateway.
onboards Telegram/Discord/etc. users to a paired gateway. Full parity with
`hermes pairing`.
- **Pending requests** — each shows platform, code, user, and age, with an Approve button
- **Approved users** — each shows platform and user, with a Revoke button
- **Clear pending** — drop all outstanding pairing codes
![Pairing admin page](/img/dashboard/admin-pairing.png)
### System
A consolidated administration panel for installation-wide operations:
- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.)
- **Nous Portal** — login status, the active inference provider, and the Tool Gateway routing table (which tools run via the Portal vs. locally), with a link to manage your subscription. Read-only mirror of `hermes portal`.
- **Skill curator** — the background skill-maintenance status (active / paused, interval, last run) with pause/resume and a run-now button. Mirrors `hermes curator`.
- **Gateway** — start, stop, and restart the messaging gateway, with live status (running/stopped, PID, state)
- **Memory** — pick the external memory provider (or built-in only), and reset the built-in `MEMORY.md` / `USER.md` stores
- **Credential pool** — add and remove the rotating API keys the agent round-robins through (per provider). Keys are redacted in the list; the raw value only ever reaches the agent.
- **Operations** — run `doctor`, a security audit, a backup, restore from a backup archive, or update skills. Each spawns a background action whose live log streams into the page.
- **Operations** — run `doctor`, a security audit, create a backup, restore from a backup archive, update skills, show the system-prompt size breakdown, generate a support dump, or migrate config for retired settings. Each spawns a background action whose live log streams into the page.
- **Checkpoints** — see the `/rollback` shadow store size and prune it
- **Shell hooks** — read-only list of configured hooks with their consent-allowlist status
- **Shell hooks** — list configured hooks with their consent + executable status, **create** a hook (event, command, matcher, timeout, with an opt-in consent grant), and remove one. Hooks run arbitrary commands, so the create form carries a security warning and the hook only fires after consent is granted.
![System admin page — host stats and Nous Portal status](/img/dashboard/admin-system-top.png)
![System admin page — skill curator, gateway, memory, and credential pool](/img/dashboard/admin-system-curator.png)
![System admin page — operations, checkpoints, and shell hooks](/img/dashboard/admin-system-ops.png)
Creating a shell hook (note the consent checkbox and the run-arbitrary-commands warning):
![New shell hook modal](/img/dashboard/admin-hook-create.png)
:::warning Security
The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. The dashboard has no authentication of its own.
@ -350,7 +389,10 @@ same auth gate as the rest of `/api/`.
| `GET /api/mcp/servers` | List configured MCP servers (env values redacted) |
| `POST /api/mcp/servers` | Add a server. Body: `{name, url?, command?, args?, env?, auth?}` |
| `POST /api/mcp/servers/{name}/test` | Connect, list tools, disconnect |
| `PUT /api/mcp/servers/{name}/enabled` | Enable / disable a server |
| `DELETE /api/mcp/servers/{name}` | Remove a server |
| `GET /api/mcp/catalog` | Browse the Nous-approved MCP catalog |
| `POST /api/mcp/catalog/install` | Install a catalog entry (with required env) |
| `GET /api/pairing` | List pending + approved messaging users |
| `POST /api/pairing/approve` | Approve a code. Body: `{platform, code}` |
| `POST /api/pairing/revoke` | Revoke a user. Body: `{platform, user_id}` |
@ -368,7 +410,19 @@ same auth gate as the rest of `/api/`.
| `POST /api/ops/doctor` · `/security-audit` · `/backup` · `/import` | Diagnostics & maintenance (backgrounded; tail via `/api/actions/{name}/status`) |
| `GET /api/ops/hooks` | Configured shell hooks + allowlist status |
| `GET /api/ops/checkpoints` · `POST .../prune` | Inspect / prune the `/rollback` store |
| `POST /api/ops/hooks` · `DELETE /api/ops/hooks` | Create / remove a shell hook (consent-gated) |
| `GET /api/system/stats` | Host stats — OS, CPU, memory, disk, uptime |
| `GET /api/curator` · `PUT .../paused` · `POST .../run` | Skill-curator status + pause/resume + run |
| `GET /api/portal` | Nous Portal auth + Tool Gateway routing (read-only) |
| `POST /api/ops/prompt-size` · `/dump` · `/config-migrate` | Diagnostics (backgrounded) |
| `PUT /api/webhooks/{name}/enabled` | Enable / disable a webhook route |
| `POST /api/skills/hub/install` · `/uninstall` · `/update` | Skills hub actions (backgrounded) |
| `GET /api/skills/hub/search` | Search the skill hub across all sources |
| `GET /api/sessions/stats` | Session-store statistics |
| `PATCH /api/sessions/{id}` | Rename / archive a session |
| `GET /api/sessions/{id}/export` | Export a session (metadata + messages) as JSON |
| `POST /api/sessions/prune` | Delete ended sessions older than N days |
| `PUT /api/cron/jobs/{id}` | Edit a cron job's prompt / schedule / name / deliver |
## OAuth Authentication (gated mode)

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB