diff --git a/hermes_cli/web_deps.py b/hermes_cli/web_deps.py new file mode 100644 index 0000000000000..4c1ceaa7d4d22 --- /dev/null +++ b/hermes_cli/web_deps.py @@ -0,0 +1,74 @@ +"""Shared late-binding dependency seam for extracted dashboard routers. + +Why this exists +--------------- +``hermes_cli/web_server.py`` owns all dashboard runtime state: the ephemeral +``_SESSION_TOKEN``, the ``DASHBOARD_HEALTH`` singleton, config helpers, and a +large set of private helper functions the route handlers call. Extracted +``APIRouter`` modules under ``hermes_cli/web_routers/`` need those helpers, but + +* importing ``web_server`` at module import time from a router module would be + a circular import (``web_server`` imports the router modules to mount them), + and +* re-homing the helpers/state here would break the many tests (and any third + party code) that ``monkeypatch.setattr(web_server, "_helper", ...)``. + +Design: **late binding, state stays in web_server.** ``late(name)`` returns a +thin proxy that resolves ``hermes_cli.web_server.`` *at call time*. This +is cycle-safe (the import happens inside the call, long after both modules are +initialised) and keeps ``web_server``'s runtime behaviour byte-identical: +monkeypatching an attribute on ``web_server`` is still authoritative because +every call re-reads the attribute from the live module. +""" + +from __future__ import annotations + +import sys +from typing import Any + + +def _server(): + """Return the live ``hermes_cli.web_server`` module (imported on demand).""" + mod = sys.modules.get("hermes_cli.web_server") + if mod is None: # pragma: no cover - routers are only mounted by web_server + import hermes_cli.web_server as mod # type: ignore[no-redef] + return mod + + +def late(name: str): + """Late-binding proxy for a callable defined on ``web_server``. + + The returned wrapper looks up ``web_server.`` on every call, so + async/sync nature, monkeypatched replacements, and module state are all + resolved at call time — never frozen at import time. + """ + + def _proxy(*args: Any, **kwargs: Any): + return getattr(_server(), name)(*args, **kwargs) + + _proxy.__name__ = name + _proxy.__qualname__ = name + return _proxy + + +def late_attr(name: str) -> Any: + """Read ``web_server.`` right now (for non-callable state reads).""" + return getattr(_server(), name) + + +# --- Named accessors for the shared server state (call-time reads) --------- + + +def get_session_token() -> str: + """Current dashboard session token (``web_server._SESSION_TOKEN``).""" + return _server()._SESSION_TOKEN + + +def get_dashboard_health(): + """The ``DASHBOARD_HEALTH`` singleton owned by web_server.""" + return _server().DASHBOARD_HEALTH + + +def has_valid_session_token(request) -> bool: + """Late-bound alias for ``web_server._has_valid_session_token``.""" + return _server()._has_valid_session_token(request) diff --git a/hermes_cli/web_routers/__init__.py b/hermes_cli/web_routers/__init__.py new file mode 100644 index 0000000000000..76fe5ca2a15da --- /dev/null +++ b/hermes_cli/web_routers/__init__.py @@ -0,0 +1,8 @@ +"""Extracted APIRouter modules for the dashboard web server. + +Each module exposes ``router = APIRouter()`` (profiles additionally exposes +``sessions_router``) and is mounted by ``hermes_cli.web_server`` at the exact +point in module execution where the routes were originally registered, so +route-matching order is unchanged. Shared web_server helpers/state are +reached through the late-binding seam in ``hermes_cli.web_deps``. +""" diff --git a/hermes_cli/web_routers/cron.py b/hermes_cli/web_routers/cron.py new file mode 100644 index 0000000000000..611926466ddff --- /dev/null +++ b/hermes_cli/web_routers/cron.py @@ -0,0 +1,243 @@ +"""Cron dashboard routes (extracted verbatim from web_server.py). + +Handler bodies are byte-identical. The ``*_sync`` workers, profile resolution +and the threadpool wrapper (``_run_cron_dashboard_io``) still live in +web_server — reached via the late-binding seam in :mod:`hermes_cli.web_deps` +so ``monkeypatch.setattr(web_server, ...)`` keeps working (several cron tests +rely on exactly that). +""" + +import asyncio # noqa: F401 — used by handlers +import functools # noqa: F401 +import logging +from typing import Optional # noqa: F401 + +from fastapi import APIRouter, HTTPException, Request # noqa: F401 +from fastapi.responses import JSONResponse # noqa: F401 + +from hermes_cli.web_deps import late +from hermes_cli.web_models import ( + CronJobCreate, + CronJobUpdate, + AutomationBlueprintInstantiate, +) + +# Same logger the handlers used before extraction (identical logger object). +_log = logging.getLogger("hermes_cli.web_server") + +router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent — includes config readers so existing +# ``monkeypatch.setattr(web_server, "load_config", ...)`` idioms behave +# identically for these routes). +_run_cron_dashboard_io = late("_run_cron_dashboard_io") +_list_cron_jobs_sync = late("_list_cron_jobs_sync") +_get_cron_job_sync = late("_get_cron_job_sync") +_list_cron_job_runs_sync = late("_list_cron_job_runs_sync") +_create_cron_job_sync = late("_create_cron_job_sync") +_update_cron_job_sync = late("_update_cron_job_sync") +_pause_cron_job_sync = late("_pause_cron_job_sync") +_resume_cron_job_sync = late("_resume_cron_job_sync") +_trigger_cron_job_sync = late("_trigger_cron_job_sync") +_delete_cron_job_sync = late("_delete_cron_job_sync") +_find_cron_job_profile = late("_find_cron_job_profile") +_fire_cron_job_for_profile = late("_fire_cron_job_for_profile") +_call_cron_for_profile = late("_call_cron_for_profile") +load_config = late("load_config") +cfg_get = late("cfg_get") + + +@router.get("/api/cron/jobs") +async def list_cron_jobs(profile: str = "all"): + return await _run_cron_dashboard_io(_list_cron_jobs_sync, profile) + + +@router.get("/api/cron/jobs/{job_id}") +async def get_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_get_cron_job_sync, job_id, profile) + + +@router.get("/api/cron/jobs/{job_id}/runs") +async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: int = 20): + return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit) + + +@router.post("/api/cron/jobs") +async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile) + + +@router.get("/api/cron/delivery-targets") +async def get_cron_delivery_targets(): + """Delivery targets the cron dropdown should offer. + + Always includes the implicit ``local`` option. Beyond that, the list is + derived dynamically from the configured gateway platforms via + ``cron.scheduler.cron_delivery_targets()`` — no hardcoded platform list. A + configured platform that hasn't set its cron home channel is still returned + with ``home_target_set: false`` so the UI can surface it as "configure a + home channel first" rather than hiding it. + """ + targets = [ + { + "id": "local", + "name": "Local (save only)", + "home_target_set": True, + "home_env_var": None, + } + ] + try: + from cron.scheduler import cron_delivery_targets + + targets.extend(cron_delivery_targets()) + except Exception: + _log.exception("GET /api/cron/delivery-targets failed") + return {"targets": targets} + + +@router.put("/api/cron/jobs/{job_id}") +async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_update_cron_job_sync, job_id, body, profile) + + +@router.post("/api/cron/jobs/{job_id}/pause") +async def pause_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_pause_cron_job_sync, job_id, profile) + + +@router.post("/api/cron/jobs/{job_id}/resume") +async def resume_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_resume_cron_job_sync, job_id, profile) + + +@router.post("/api/cron/jobs/{job_id}/trigger") +async def trigger_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_trigger_cron_job_sync, job_id, profile) + + +@router.delete("/api/cron/jobs/{job_id}") +async def delete_cron_job(job_id: str, profile: Optional[str] = None): + return await _run_cron_dashboard_io(_delete_cron_job_sync, job_id, profile) + + +@router.post("/api/cron/fire") +async def cron_fire_webhook(request: Request): + """Chronos managed-cron fire webhook (NAS -> agent). + + Authenticated by a short-lived NAS-minted JWT (verified by the pluggable + Chronos fire-verifier), NOT the dashboard session cookie — so this path is + in ``PUBLIC_API_PATHS`` to bypass the dashboard auth gate, and the JWT is + the real gate. This is the inbound half of scale-to-zero managed cron: NAS + POSTs here at fire time, the agent verifies, claims the job (store CAS, so + at-most-once across replicas / on a NAS retry), runs it, and re-arms the + next one-shot. + + Lives on the dashboard app (not the api_server adapter) because the + dashboard is the agent's always-reachable public HTTP surface on hosted + deployments; the gateway may be idle/scaled down. + + Returns 202 immediately and runs the job in the background so a long agent + turn never trips NAS's HTTP timeout. + """ + from plugins.cron_providers.chronos.verify import get_fire_verifier + + auth = request.headers.get("Authorization", "") + token = auth[7:].strip() if auth.startswith("Bearer ") else "" + + cfg = load_config() + claims = get_fire_verifier()( + token=token, + expected_audience=cfg_get(cfg, "cron", "chronos", "expected_audience", default=""), + jwks_or_key=cfg_get(cfg, "cron", "chronos", "nas_jwks_url", default="") or None, + issuer=cfg_get(cfg, "cron", "chronos", "portal_url", default="") or None, + ) + if claims is None: + return JSONResponse({"error": "invalid fire token"}, status_code=401) + + try: + body = await request.json() + except Exception: + body = {} + job_id = (body or {}).get("job_id") if isinstance(body, dict) else None + if not job_id: + return JSONResponse({"error": "missing job_id"}, status_code=400) + + # _find_cron_job_profile walks every profile and lists its jobs (file + # I/O per profile) — run it off the event loop like the other cron + # dashboard endpoints. + profile = await _run_cron_dashboard_io(_find_cron_job_profile, job_id) + if not profile: + # Job is gone (cancelled / completed) — nothing to fire. 200 so NAS + # does not retry a fire that is intentionally absent. + return JSONResponse({"status": "gone", "job_id": job_id}, status_code=200) + + # Run in the background; the store CAS claim inside fire_due de-dupes a + # NAS/scheduler retry that arrives while this is in flight. + asyncio.create_task( + asyncio.to_thread(_fire_cron_job_for_profile, profile, job_id) + ) + return JSONResponse({"status": "accepted", "job_id": job_id}, status_code=202) + + +@router.get("/api/cron/blueprints") +async def list_cron_blueprints(): + """Return the blueprint catalog as form schemas for the dashboard gallery. + + The ``deliver`` slot's options are rewritten from the user's actually + configured gateway platforms (plus the universal origin/local/all), so the + form never offers a platform that isn't connected. + """ + try: + from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry + + deliver_options = None + try: + from cron.scheduler import cron_delivery_targets + + platforms = [t["id"] for t in cron_delivery_targets() if t.get("id")] + deliver_options = ["origin", "local", *platforms] + except Exception: + _log.debug("cron_delivery_targets unavailable; using static deliver options", exc_info=True) + + entries = [] + for r in CATALOG: + entry = blueprint_catalog_entry(r) + if deliver_options: + for f in entry.get("fields", []): + if f.get("name") == "deliver": + f["options"] = deliver_options + entries.append(entry) + return {"blueprints": entries} + except Exception as e: + _log.exception("GET /api/cron/blueprints failed") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/cron/blueprints/instantiate") +async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: str = "default"): + """Fill a blueprint's slots and create the cron job (form-submit path).""" + try: + from cron.blueprint_catalog import fill_blueprint, get_blueprint, BlueprintFillError + + blueprint = get_blueprint(body.blueprint) + if blueprint is None: + raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}") + try: + spec = fill_blueprint(blueprint, body.values) + except BlueprintFillError as exc: + # Field-level validation error — 422 so the form can show it inline. + raise HTTPException(status_code=422, detail=str(exc)) from exc + # Blueprint-created jobs deliver to the dashboard's configured target by + # default; the form's deliver slot overrides via spec["deliver"]. + spec.pop("origin", None) + # create_job does per-profile file I/O — keep it off the event loop + # like the sibling cron endpoints (partial avoids **spec keys ever + # colliding with the wrapper's own parameters). + _create = functools.partial(_call_cron_for_profile, profile, "create_job", **spec) + return await _run_cron_dashboard_io(_create) + except HTTPException: + raise + except Exception as e: + _log.exception("POST /api/cron/blueprints/instantiate failed") + raise HTTPException(status_code=400, detail=str(e)) diff --git a/hermes_cli/web_routers/git.py b/hermes_cli/web_routers/git.py new file mode 100644 index 0000000000000..62384d13a463b --- /dev/null +++ b/hermes_cli/web_routers/git.py @@ -0,0 +1,138 @@ +"""Git dashboard routes (extracted verbatim from web_server.py). + +Handler bodies are byte-identical to their previous in-web_server form; the +helpers they call (``_git_op``, ``_git_path``) still live in web_server and are +reached via the late-binding seam in :mod:`hermes_cli.web_deps`, so +``monkeypatch.setattr(web_server, ...)`` keeps working. +""" + +from typing import Optional + +from fastapi import APIRouter + +from hermes_cli import web_git as _web_git # noqa: F401 — used by handlers +from hermes_cli.web_deps import late +from hermes_cli.web_models import ( + GitPathBody, + GitFileBody, + GitCommitBody, + GitWorktreeAddBody, + GitWorktreeRemoveBody, + GitBranchSwitchBody, +) + +router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent). +_git_op = late("_git_op") +_git_path = late("_git_path") + + +@router.get("/api/git/status") +async def git_status_route(path: str): + return await _git_op(_web_git.repo_status, _git_path(path)) + + +@router.get("/api/git/worktrees") +async def git_worktrees_route(path: str): + return {"worktrees": await _git_op(_web_git.worktree_list, _git_path(path))} + + +@router.get("/api/git/branches") +async def git_branches_route(path: str): + return {"branches": await _git_op(_web_git.branch_list, _git_path(path))} + + +@router.get("/api/git/base-branches") +async def git_base_branches_route(path: str): + return {"branches": await _git_op(_web_git.base_branch_list, _git_path(path))} + + +@router.get("/api/git/review/list") +async def git_review_list_route(path: str, scope: str = "uncommitted", base: Optional[str] = None): + return await _git_op(_web_git.review_list, _git_path(path), scope, base) + + +@router.get("/api/git/review/diff") +async def git_review_diff_route( + path: str, file: str, scope: str = "uncommitted", base: Optional[str] = None, staged: bool = False +): + return {"diff": await _git_op(_web_git.review_diff, _git_path(path), file, scope, base, staged)} + + +@router.get("/api/git/file-diff") +async def git_file_diff_route(path: str, file: str): + return {"diff": await _git_op(_web_git.file_diff_vs_head, _git_path(path), file)} + + +@router.get("/api/git/review/commit-context") +async def git_commit_context_route(path: str): + return await _git_op(_web_git.review_commit_context, _git_path(path)) + + +@router.get("/api/git/review/rev-parse") +async def git_rev_parse_route(path: str, ref: Optional[str] = None): + return {"sha": await _git_op(_web_git.review_rev_parse, _git_path(path), ref)} + + +@router.get("/api/git/review/ship-info") +async def git_ship_info_route(path: str): + return await _git_op(_web_git.review_ship_info, _git_path(path)) + + +@router.post("/api/git/review/stage") +async def git_stage_route(body: GitFileBody): + return await _git_op(_web_git.review_stage, _git_path(body.path), body.file) + + +@router.post("/api/git/review/unstage") +async def git_unstage_route(body: GitFileBody): + return await _git_op(_web_git.review_unstage, _git_path(body.path), body.file) + + +@router.post("/api/git/review/revert") +async def git_revert_route(body: GitFileBody): + return await _git_op(_web_git.review_revert, _git_path(body.path), body.file) + + +@router.post("/api/git/review/commit") +async def git_commit_route(body: GitCommitBody): + return await _git_op(_web_git.review_commit, _git_path(body.path), body.message, body.push) + + +@router.post("/api/git/review/push") +async def git_push_route(body: GitPathBody): + return await _git_op(_web_git.review_push, _git_path(body.path)) + + +@router.post("/api/git/review/create-pr") +async def git_create_pr_route(body: GitPathBody): + return await _git_op(_web_git.review_create_pr, _git_path(body.path)) + + +@router.post("/api/git/worktree/add") +async def git_worktree_add_route(body: GitWorktreeAddBody): + options = { + key: value + for key, value in { + "name": body.name, + "branch": body.branch, + "base": body.base, + "existingBranch": body.existingBranch, + }.items() + if value + } + return await _git_op(_web_git.worktree_add, _git_path(body.path), options) + + +@router.post("/api/git/worktree/remove") +async def git_worktree_remove_route(body: GitWorktreeRemoveBody): + return await _git_op( + _web_git.worktree_remove, _git_path(body.path), _git_path(body.worktreePath), body.force + ) + + +@router.post("/api/git/branch/switch") +async def git_branch_switch_route(body: GitBranchSwitchBody): + return await _git_op(_web_git.branch_switch, _git_path(body.path), body.branch) diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py new file mode 100644 index 0000000000000..2eefe3407f9ae --- /dev/null +++ b/hermes_cli/web_routers/profiles.py @@ -0,0 +1,683 @@ +"""Profiles dashboard routes (extracted verbatim from web_server.py). + +Two routers because the original registration points are far apart and route +order matters: ``sessions_router`` (/api/profiles/sessions*) was registered +long before the generic ``/api/profiles/{name}`` routes on ``router`` — if the +literal-path routes were appended after ``{name}`` in one router, Starlette +would still match literals first here, but we preserve the original global +registration order exactly rather than rely on that. + +Handler bodies are byte-identical; web_server-owned helpers are reached via the +late-binding seam in :mod:`hermes_cli.web_deps` so tests that +``monkeypatch.setattr(web_server, "_helper", ...)`` keep working. +""" + +import asyncio # noqa: F401 — used by handlers +import logging +import subprocess # noqa: F401 +import sys # noqa: F401 +import time # noqa: F401 +from pathlib import Path # noqa: F401 +from typing import Any, Dict, List, Optional, Tuple # noqa: F401 + +from fastapi import APIRouter, HTTPException # noqa: F401 + +from hermes_cli.web_deps import late +from hermes_cli.web_models import ( + ProfileCreate, + ProfileActiveUpdate, + ProfileRename, + ProfileSoulUpdate, + ProfileDescriptionUpdate, + ProfileModelUpdate, + ProfileDescribeAuto, +) + +# Same logger the handlers used before extraction (identical logger object). +_log = logging.getLogger("hermes_cli.web_server") + +sessions_router = APIRouter() +router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent). +_cron_profile_home = late("_cron_profile_home") +_disable_unselected_skills = late("_disable_unselected_skills") +_fallback_profile_dicts = late("_fallback_profile_dicts") +_hub_action_name = late("_hub_action_name") +_profile_setup_command = late("_profile_setup_command") +_profile_to_dict = late("_profile_to_dict") +_resolve_profile_dir = late("_resolve_profile_dir") +_spawn_hermes_action = late("_spawn_hermes_action") +_strip_session_list_rows = late("_strip_session_list_rows") +_write_profile_mcp_servers = late("_write_profile_mcp_servers") +_write_profile_model = late("_write_profile_model") + + +@sessions_router.get("/api/profiles/sessions") +def get_profiles_sessions( + limit: int = 20, + offset: int = 0, + min_messages: int = 0, + archived: str = "exclude", + order: str = "recent", + profile: str = "all", + source: str = None, + sources: str = None, + exclude_sources: str = None, + full: bool = False, +): + """Unified, read-only session list aggregated across ALL profiles. + + Intentionally process-light: this opens each profile's ``state.db`` directly + from disk — it does NOT spawn a dashboard backend per profile. Each returned + session is tagged with its owning ``profile`` so the desktop renders one + browsable list and only spins up a profile's backend when the user actually + interacts (sends a message). A user with a single (default) profile gets the + same rows as ``/api/sessions``, just tagged ``profile="default"``. + + Rows omit ``system_prompt``/``model_config`` unless ``full=1`` — same + list projection as ``/api/sessions``. + """ + if archived not in ("exclude", "only", "include"): + raise HTTPException(status_code=400, detail="archived must be one of: exclude, only, include") + if order not in ("created", "recent"): + raise HTTPException(status_code=400, detail="order must be one of: created, recent") + + from hermes_state import SessionDB + from hermes_cli import profiles as profiles_mod + + targets: List[Tuple[str, Path]] = [] + if profile and profile != "all": + name, home = _cron_profile_home(profile) + targets.append((name, home)) + else: + try: + infos = profiles_mod.list_profiles() + targets = [(info.name, info.path) for info in infos] + except Exception: + _log.exception("GET /api/profiles/sessions: list_profiles failed") + targets = [] + if not targets: + targets.append(("default", profiles_mod.get_profile_dir("default"))) + + min_message_count = max(0, min_messages) + archived_only = archived == "only" + include_archived = archived == "include" + # Source scoping (see /api/sessions): recents pass exclude_sources=cron, + # the cron-jobs section passes source=cron — two independent lists so + # newest cron sessions can't starve the recents page. + source_filter = source or None + source_list = [s.strip() for s in (sources or "").split(",") if s.strip()] + exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()] + # Over-fetch per profile so the merged+sorted window is correct for the + # requested page. Capped so a huge profile can't blow up the response. + per_profile = min(max(limit + offset, limit), 500) + + merged: List[Dict[str, Any]] = [] + total = 0 + profile_totals: Dict[str, int] = {} + errors: List[Dict[str, str]] = [] + now = time.time() + for name, home in targets: + db_path = Path(home) / "state.db" + if not db_path.exists(): + continue + try: + # Read-only: this loop runs on every sidebar refresh, so it must + # never DDL/write-lock another profile's live DB (see SessionDB + # read_only docstring). + db = SessionDB(db_path=db_path, read_only=True) + except Exception as exc: + errors.append({"profile": name, "error": str(exc)}) + continue + try: + rows = db.list_sessions_rich( + source=source_filter, + sources=source_list or None, + exclude_sources=exclude_list or None, + limit=per_profile, + offset=0, + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, + order_by_last_active=order == "recent", + # Same SQL-level blob skip as /api/sessions (see above). + compact_rows=not full, + include_pinned=True, + ) + profile_total = db.session_count( + source=source_filter, + sources=source_list or None, + exclude_sources=exclude_list or None, + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, + exclude_children=True, + ) + total += profile_total + profile_totals[name] = profile_total + for s in rows: + s["profile"] = name + s["is_default_profile"] = name == "default" + s["is_active"] = ( + s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + s["archived"] = bool(s.get("archived")) + s["pinned"] = bool(s.get("pinned")) + merged.append(s) + except Exception as exc: + errors.append({"profile": name, "error": str(exc)}) + finally: + db.close() + + sort_key = "last_active" if order == "recent" else "started_at" + merged.sort(key=lambda s: s.get(sort_key) or s.get("started_at") or 0, reverse=True) + # Pinned rows are back-filled past each profile's LIMIT on purpose; keep + # them in the merged window instead of re-dropping them on recency. + window = merged[offset:offset + limit] + if len(merged) > offset + limit: + seen = {id(s) for s in window} + window.extend(s for s in merged[offset + limit:] if s.get("pinned") and id(s) not in seen) + if not full: + _strip_session_list_rows(window) + return { + "sessions": window, + "total": total, + "profile_totals": profile_totals, + "limit": limit, + "offset": offset, + "errors": errors, + } + + +@sessions_router.get("/api/profiles/sessions/sidebar") +def get_profiles_sessions_sidebar( + recents_profile: str = "all", + recents_limit: int = 20, + recents_exclude: str = None, + cron_limit: int = 50, + messaging_limit: int = 100, + messaging_exclude: str = None, +): + """Batched sidebar session slices — one profile-DB open per refresh. + + The desktop sidebar needs three source-scoped windows per refresh: recents + (local chats, scoped to the active profile), cron sessions (all profiles), + and messaging-platform sessions (all profiles). Served as three separate + ``/api/profiles/sessions`` calls they reopened every profile's ``state.db`` + three times and re-counted each refresh. This opens each DB once and runs + the three filtered queries together, returning the three windows in one + payload. Read-only and process-light, same row projection and 300s active + heuristic as ``/api/profiles/sessions``. + + The caller passes the source taxonomy (``recents_exclude`` / + ``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays + taxonomy-agnostic like the per-slice endpoint. All three slices use + ``min_messages=1`` / ``archived=exclude`` / recency order, matching the + desktop's per-slice calls. + """ + from hermes_state import SessionDB + from hermes_cli import profiles as profiles_mod + + # cron + messaging are cross-profile; recents is scoped to recents_profile. + # Scan every profile once regardless (each DB opened a single time). + try: + infos = profiles_mod.list_profiles() + targets: List[Tuple[str, Path]] = [(info.name, info.path) for info in infos] + except Exception: + _log.exception("GET /api/profiles/sessions/sidebar: list_profiles failed") + targets = [] + if not targets: + targets.append(("default", profiles_mod.get_profile_dir("default"))) + + recents_scope = (recents_profile or "all").strip() or "all" + recents_exclude_list = [s for s in (recents_exclude or "").split(",") if s.strip()] + messaging_exclude_list = [s for s in (messaging_exclude or "").split(",") if s.strip()] + + recents_cap = min(max(recents_limit, 1), 500) + cron_cap = min(max(cron_limit, 1), 500) + messaging_cap = min(max(messaging_limit, 1), 500) + + recents_rows: List[Dict[str, Any]] = [] + cron_rows: List[Dict[str, Any]] = [] + messaging_rows: List[Dict[str, Any]] = [] + recents_truncated: Dict[str, bool] = {} + errors: List[Dict[str, str]] = [] + now = time.time() + + def _tag(rows: List[Dict[str, Any]], name: str) -> List[Dict[str, Any]]: + for s in rows: + s["profile"] = name + s["is_default_profile"] = name == "default" + s["is_active"] = ( + s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + s["archived"] = bool(s.get("archived")) + # SQLite stores the pin as 0/1; the sidebar needs a real boolean to + # render the Pinned section from server state. + s["pinned"] = bool(s.get("pinned")) + return rows + + def _slice(db, *, source=None, exclude=None, cap): + return db.list_sessions_rich( + source=source, + exclude_sources=exclude or None, + limit=cap, + offset=0, + min_message_count=1, + include_archived=False, + archived_only=False, + order_by_last_active=True, + compact_rows=True, + # A pinned conversation must reach the sidebar even when it has + # aged past the window — otherwise its Pinned row renders empty. + include_pinned=True, + ) + + for name, home in targets: + db_path = Path(home) / "state.db" + if not db_path.exists(): + continue + try: + db = SessionDB(db_path=db_path, read_only=True) + except Exception as exc: + errors.append({"profile": name, "error": str(exc)}) + continue + try: + if recents_scope == "all" or name == recents_scope: + profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap) + # A full window means more rows remain on disk. That is all the + # sidebar's "load more" needs, and unlike an exact COUNT(*) per + # profile per refresh it costs nothing beyond the rows already + # read. Discount pinned back-fills — they arrive past the LIMIT + # and would otherwise fake a full page on a short list. + unpinned_count = sum(1 for s in profile_rows if not s.get("pinned")) + recents_truncated[name] = unpinned_count >= recents_cap + recents_rows.extend(_tag(profile_rows, name)) + cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name)) + messaging_rows.extend( + _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name) + ) + except Exception as exc: + errors.append({"profile": name, "error": str(exc)}) + finally: + db.close() + + def _window(rows: List[Dict[str, Any]], cap: int) -> List[Dict[str, Any]]: + rows.sort(key=lambda s: s.get("last_active") or s.get("started_at") or 0, reverse=True) + # Pinned rows survive the cap. The per-profile queries deliberately + # back-fill them past the LIMIT, so truncating the merged window on + # recency alone would throw away exactly what the back-fill fetched. + win = rows[:cap] + if len(rows) > cap: + seen = {id(s) for s in win} + win.extend(s for s in rows[cap:] if s.get("pinned") and id(s) not in seen) + _strip_session_list_rows(win) + return win + + return { + "recents": { + "sessions": _window(recents_rows, recents_cap), + "profiles_truncated": recents_truncated, + }, + "cron": {"sessions": _window(cron_rows, cron_cap)}, + "messaging": { + "sessions": _window(messaging_rows, messaging_cap), + "total": len(messaging_rows), + }, + "errors": errors, + } + + +@router.get("/api/profiles") +async def list_profiles_endpoint(): + from hermes_cli import profiles as profiles_mod + try: + loop = asyncio.get_running_loop() + profiles = await loop.run_in_executor(None, profiles_mod.list_profiles) + return {"profiles": [_profile_to_dict(p) for p in profiles]} + except Exception: + _log.exception("GET /api/profiles failed; falling back to profile directory scan") + return {"profiles": _fallback_profile_dicts(profiles_mod)} + + +@router.post("/api/profiles") +async def create_profile_endpoint(body: ProfileCreate): + from hermes_cli import profiles as profiles_mod + explicit_source = (body.clone_from or "").strip() + if explicit_source: + # Duplicating a specific profile: clone its config/skills/SOUL (or full + # state when clone_all) from the named source rather than "default". + clone = True + clone_from = explicit_source + clone_config = not body.clone_all + elif body.clone_all: + # Preserve the dashboard's historical clone-all behavior: a full-copy + # request with no explicit dropdown source copies from default. + clone = True + clone_from = "default" + clone_config = False + else: + clone = body.clone_from_default + clone_from = "default" if clone else None + clone_config = clone + try: + path = profiles_mod.create_profile( + name=body.name, + clone_from=clone_from, + clone_all=body.clone_all, + clone_config=clone_config, + no_skills=body.no_skills, + description=body.description, + ) + # Match the CLI's profile-create flow: fresh named profiles get the + # bundled skills installed. When cloning from default, create_profile() + # has already copied the source profile's skills, including any + # user-installed skills. When no_skills=True, create_profile() wrote + # the opt-out marker and seed_profile_skills() will no-op. + if not clone: + profiles_mod.seed_profile_skills(path, quiet=True) + + # Match the CLI's profile-create flow: named profiles should get a + # wrapper in ~/.local/bin when the alias is safe to create. + collision = profiles_mod.check_alias_collision(body.name) + if not collision: + profiles_mod.create_wrapper_script(body.name) + except (ValueError, FileExistsError, FileNotFoundError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles failed") + raise HTTPException(status_code=500, detail=str(e)) + + # Optional explicit model assignment for the new profile. Best-effort: + # the profile already exists, so a model-write hiccup must not 500 the + # whole create — the user can set the model later from the Models page + # or ` setup`. + provider = (body.provider or "").strip() + model = (body.model or "").strip() + model_set = False + if provider and model: + try: + _write_profile_model(path, provider, model) + model_set = True + except Exception: + _log.exception("Setting model for new profile %s failed", body.name) + + # Optional MCP servers. Best-effort, same rationale as model assignment. + mcp_written = 0 + if body.mcp_servers: + try: + mcp_written = _write_profile_mcp_servers(path, body.mcp_servers) + except Exception: + _log.exception("Writing MCP servers for new profile %s failed", body.name) + + # Optional "keep" skill selection — replace semantics. When the builder + # sends an explicit keep list, disable every seeded skill not in it. + # Best-effort. Skipped when keep_skills is empty (legacy: keep the bundle). + skills_disabled = 0 + if body.keep_skills: + try: + skills_disabled = _disable_unselected_skills(path, body.keep_skills) + except Exception: + _log.exception("Applying skill selection for new profile %s failed", body.name) + + # Optional skills-hub installs. Spawned async, scoped to the new profile + # via `-p ` (a fresh subprocess re-binds skills_hub.SKILLS_DIR to the + # profile's HERMES_HOME at import). Returns PIDs for the UI to poll. + hub_installs: List[Dict[str, Any]] = [] + for identifier in body.hub_skills: + ident = (identifier or "").strip() + if not ident: + continue + try: + proc = _spawn_hermes_action( + ["-p", body.name, "skills", "install", ident, "--yes"], + _hub_action_name("install", ident), + ) + hub_installs.append({"identifier": ident, "pid": proc.pid}) + except Exception: + _log.exception( + "Spawning hub-skill install %s for new profile %s failed", + ident, + body.name, + ) + hub_installs.append({"identifier": ident, "pid": None}) + + return { + "ok": True, + "name": body.name, + "path": str(path), + "model_set": model_set, + "mcp_written": mcp_written, + "skills_disabled": skills_disabled, + "hub_installs": hub_installs, + } + + +@router.get("/api/profiles/active") +async def get_active_profile_endpoint(): + """Return the sticky active profile and the profile this dashboard + process is currently running as. + + ``active`` is the sticky default written by ``hermes profile use`` — + the profile new CLI invocations pick up. ``current`` is the profile + the running dashboard/gateway is scoped to (derived from HERMES_HOME). + """ + from hermes_cli import profiles as profiles_mod + try: + active = profiles_mod.get_active_profile() or "default" + except Exception: + active = "default" + try: + current = profiles_mod.get_active_profile_name() or "default" + except Exception: + current = "default" + return {"active": active, "current": current} + + +@router.post("/api/profiles/active") +async def set_active_profile_endpoint(body: ProfileActiveUpdate): + """Set the sticky active profile (mirrors ``hermes profile use``). + + Note: this does not retarget the already-running dashboard process — + it changes which profile subsequent CLI commands and gateways use. + """ + from hermes_cli import profiles as profiles_mod + try: + profiles_mod.set_active_profile(body.name) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles/active failed") + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "active": profiles_mod.normalize_profile_name(body.name)} + + +@router.get("/api/profiles/{name}/setup-command") +async def get_profile_setup_command(name: str): + return {"command": _profile_setup_command(name)} + + +@router.post("/api/profiles/{name}/open-terminal") +async def open_profile_terminal_endpoint(name: str): + try: + command = _profile_setup_command(name) + + if sys.platform.startswith("win"): + subprocess.Popen(["cmd.exe", "/c", "start", "", command]) + elif sys.platform == "darwin": + escaped = command.replace("\\", "\\\\").replace('"', '\\"') + applescript = ( + 'tell application "Terminal"\n' + "activate\n" + f'do script "{escaped}"\n' + "end tell" + ) + subprocess.Popen(["osascript", "-e", applescript]) + else: + terminal_commands = [ + ("x-terminal-emulator", ["x-terminal-emulator", "-e", "sh", "-lc", command]), + ("gnome-terminal", ["gnome-terminal", "--", "sh", "-lc", command]), + ("konsole", ["konsole", "-e", "sh", "-lc", command]), + ("xfce4-terminal", ["xfce4-terminal", "-e", f"sh -lc '{command}'"]), + ("mate-terminal", ["mate-terminal", "-e", f"sh -lc '{command}'"]), + ("lxterminal", ["lxterminal", "-e", f"sh -lc '{command}'"]), + ("tilix", ["tilix", "-e", "sh", "-lc", command]), + ("alacritty", ["alacritty", "-e", "sh", "-lc", command]), + ("kitty", ["kitty", "sh", "-lc", command]), + ("xterm", ["xterm", "-e", "sh", "-lc", command]), + ] + for executable, popen_args in terminal_commands: + if subprocess.call( + ["which", executable], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) == 0: + subprocess.Popen(popen_args) + break + else: + raise HTTPException( + status_code=400, + detail="No supported terminal emulator found", + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + raise + except Exception as e: + _log.exception("POST /api/profiles/%s/open-terminal failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "command": command} + + +@router.patch("/api/profiles/{name}") +async def rename_profile_endpoint(name: str, body: ProfileRename): + from hermes_cli import profiles as profiles_mod + try: + path = profiles_mod.rename_profile(name, body.new_name) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except (ValueError, FileExistsError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("PATCH /api/profiles/%s failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "name": body.new_name, "path": str(path)} + + +@router.delete("/api/profiles/{name}") +async def delete_profile_endpoint(name: str): + """Delete a profile. The dashboard collects the user's confirmation in + its own dialog before this request, so we always pass ``yes=True`` to + skip the CLI's interactive prompt.""" + from hermes_cli import profiles as profiles_mod + try: + path = profiles_mod.delete_profile(name, yes=True) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("DELETE /api/profiles/%s failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "path": str(path)} + + +@router.get("/api/profiles/{name}/soul") +async def get_profile_soul(name: str): + soul_path = _resolve_profile_dir(name) / "SOUL.md" + if soul_path.exists(): + try: + return {"content": soul_path.read_text(encoding="utf-8"), "exists": True} + except OSError as e: + raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}") + return {"content": "", "exists": False} + + +@router.put("/api/profiles/{name}/soul") +async def update_profile_soul(name: str, body: ProfileSoulUpdate): + soul_path = _resolve_profile_dir(name) / "SOUL.md" + try: + soul_path.write_text(body.content, encoding="utf-8") + except OSError as e: + _log.exception("PUT /api/profiles/%s/soul failed", name) + raise HTTPException(status_code=500, detail=f"Could not write SOUL.md: {e}") + return {"ok": True} + + +@router.put("/api/profiles/{name}/description") +async def update_profile_description_endpoint(name: str, body: ProfileDescriptionUpdate): + """Set or clear a profile's role description (kanban routing signal). + + Empty string clears the description. Non-empty stores it as a + user-authored description (``description_auto: false``) so the + auto-describer won't overwrite it on a sweep. + """ + from hermes_cli import profiles as profiles_mod + profile_dir = _resolve_profile_dir(name) + text = (body.description or "").strip() + try: + profiles_mod.write_profile_meta( + profile_dir, + description=text, + description_auto=False, + ) + except Exception as e: + _log.exception("PUT /api/profiles/%s/description failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "description": text, "description_auto": False} + + +@router.put("/api/profiles/{name}/model") +async def update_profile_model_endpoint(name: str, body: ProfileModelUpdate): + """Set the main model (``model.default`` + ``model.provider``) for a + specific profile's config.yaml, without touching the dashboard's own + active profile. Mirrors ``POST /api/model/set`` (main scope) but scoped + to the named profile via the HERMES_HOME override. + """ + profile_dir = _resolve_profile_dir(name) + provider = (body.provider or "").strip() + model = (body.model or "").strip() + if not provider or not model: + raise HTTPException(status_code=400, detail="provider and model are required") + try: + _write_profile_model(profile_dir, provider, model) + except Exception as e: + _log.exception("PUT /api/profiles/%s/model failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "provider": provider, "model": model} + + +@router.post("/api/profiles/{name}/describe-auto") +async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto): + """Auto-generate a profile's description via the auxiliary LLM + (``auxiliary.profile_describer``). Mirrors ``hermes profile describe + --auto``. + + A failed generation (no aux client, LLM error, …) is returned as + ``ok: false`` with a reason rather than an HTTP error so the UI can + surface it inline and let the operator fix config and retry. + """ + _resolve_profile_dir(name) + try: + from hermes_cli import profile_describer + outcome = profile_describer.describe_profile(name, overwrite=bool(body.overwrite)) + except Exception as e: + _log.exception("POST /api/profiles/%s/describe-auto failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return { + "ok": bool(outcome.ok), + "reason": outcome.reason, + "description": outcome.description, + # Only a successful generation is an auto-authored description. A failed + # sweep leaves any existing description untouched, so don't claim it's + # auto-generated. + "description_auto": bool(outcome.ok), + } diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4c2d76a27598f..f5549c0ec1905 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2706,113 +2706,66 @@ def _git_path(path: str) -> str: return str(_fs_path(path)) -@app.get("/api/git/status") -async def git_status_route(path: str): - return await _git_op(_web_git.repo_status, _git_path(path)) +from hermes_cli.web_routers import git as _git_routes # noqa: E402 + +app.include_router(_git_routes.router) +from hermes_cli.web_routers.git import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + git_status_route, + git_worktrees_route, + git_branches_route, + git_base_branches_route, + git_review_list_route, + git_review_diff_route, + git_file_diff_route, + git_commit_context_route, + git_rev_parse_route, + git_ship_info_route, + git_stage_route, + git_unstage_route, + git_revert_route, + git_commit_route, + git_push_route, + git_create_pr_route, + git_worktree_add_route, + git_worktree_remove_route, + git_branch_switch_route, +) -@app.get("/api/git/worktrees") -async def git_worktrees_route(path: str): - return {"worktrees": await _git_op(_web_git.worktree_list, _git_path(path))} -@app.get("/api/git/branches") -async def git_branches_route(path: str): - return {"branches": await _git_op(_web_git.branch_list, _git_path(path))} -@app.get("/api/git/base-branches") -async def git_base_branches_route(path: str): - return {"branches": await _git_op(_web_git.base_branch_list, _git_path(path))} -@app.get("/api/git/review/list") -async def git_review_list_route(path: str, scope: str = "uncommitted", base: Optional[str] = None): - return await _git_op(_web_git.review_list, _git_path(path), scope, base) -@app.get("/api/git/review/diff") -async def git_review_diff_route( - path: str, file: str, scope: str = "uncommitted", base: Optional[str] = None, staged: bool = False -): - return {"diff": await _git_op(_web_git.review_diff, _git_path(path), file, scope, base, staged)} -@app.get("/api/git/file-diff") -async def git_file_diff_route(path: str, file: str): - return {"diff": await _git_op(_web_git.file_diff_vs_head, _git_path(path), file)} -@app.get("/api/git/review/commit-context") -async def git_commit_context_route(path: str): - return await _git_op(_web_git.review_commit_context, _git_path(path)) -@app.get("/api/git/review/rev-parse") -async def git_rev_parse_route(path: str, ref: Optional[str] = None): - return {"sha": await _git_op(_web_git.review_rev_parse, _git_path(path), ref)} -@app.get("/api/git/review/ship-info") -async def git_ship_info_route(path: str): - return await _git_op(_web_git.review_ship_info, _git_path(path)) -@app.post("/api/git/review/stage") -async def git_stage_route(body: GitFileBody): - return await _git_op(_web_git.review_stage, _git_path(body.path), body.file) -@app.post("/api/git/review/unstage") -async def git_unstage_route(body: GitFileBody): - return await _git_op(_web_git.review_unstage, _git_path(body.path), body.file) -@app.post("/api/git/review/revert") -async def git_revert_route(body: GitFileBody): - return await _git_op(_web_git.review_revert, _git_path(body.path), body.file) -@app.post("/api/git/review/commit") -async def git_commit_route(body: GitCommitBody): - return await _git_op(_web_git.review_commit, _git_path(body.path), body.message, body.push) -@app.post("/api/git/review/push") -async def git_push_route(body: GitPathBody): - return await _git_op(_web_git.review_push, _git_path(body.path)) -@app.post("/api/git/review/create-pr") -async def git_create_pr_route(body: GitPathBody): - return await _git_op(_web_git.review_create_pr, _git_path(body.path)) -@app.post("/api/git/worktree/add") -async def git_worktree_add_route(body: GitWorktreeAddBody): - options = { - key: value - for key, value in { - "name": body.name, - "branch": body.branch, - "base": body.base, - "existingBranch": body.existingBranch, - }.items() - if value - } - return await _git_op(_web_git.worktree_add, _git_path(body.path), options) -@app.post("/api/git/worktree/remove") -async def git_worktree_remove_route(body: GitWorktreeRemoveBody): - return await _git_op( - _web_git.worktree_remove, _git_path(body.path), _git_path(body.worktreePath), body.force - ) -@app.post("/api/git/branch/switch") -async def git_branch_switch_route(body: GitBranchSwitchBody): - return await _git_op(_web_git.branch_switch, _git_path(body.path), body.branch) # Host TCP ports each port-binding gateway platform listens on, as @@ -4843,282 +4796,15 @@ def get_sessions( raise HTTPException(status_code=500, detail="Internal server error") -@app.get("/api/profiles/sessions") -def get_profiles_sessions( - limit: int = 20, - offset: int = 0, - min_messages: int = 0, - archived: str = "exclude", - order: str = "recent", - profile: str = "all", - source: str = None, - sources: str = None, - exclude_sources: str = None, - full: bool = False, -): - """Unified, read-only session list aggregated across ALL profiles. +from hermes_cli.web_routers import profiles as _profiles_routes # noqa: E402 - Intentionally process-light: this opens each profile's ``state.db`` directly - from disk — it does NOT spawn a dashboard backend per profile. Each returned - session is tagged with its owning ``profile`` so the desktop renders one - browsable list and only spins up a profile's backend when the user actually - interacts (sends a message). A user with a single (default) profile gets the - same rows as ``/api/sessions``, just tagged ``profile="default"``. - - Rows omit ``system_prompt``/``model_config`` unless ``full=1`` — same - list projection as ``/api/sessions``. - """ - if archived not in ("exclude", "only", "include"): - raise HTTPException(status_code=400, detail="archived must be one of: exclude, only, include") - if order not in ("created", "recent"): - raise HTTPException(status_code=400, detail="order must be one of: created, recent") - - from hermes_state import SessionDB - from hermes_cli import profiles as profiles_mod - - targets: List[Tuple[str, Path]] = [] - if profile and profile != "all": - name, home = _cron_profile_home(profile) - targets.append((name, home)) - else: - try: - infos = profiles_mod.list_profiles() - targets = [(info.name, info.path) for info in infos] - except Exception: - _log.exception("GET /api/profiles/sessions: list_profiles failed") - targets = [] - if not targets: - targets.append(("default", profiles_mod.get_profile_dir("default"))) - - min_message_count = max(0, min_messages) - archived_only = archived == "only" - include_archived = archived == "include" - # Source scoping (see /api/sessions): recents pass exclude_sources=cron, - # the cron-jobs section passes source=cron — two independent lists so - # newest cron sessions can't starve the recents page. - source_filter = source or None - source_list = [s.strip() for s in (sources or "").split(",") if s.strip()] - exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()] - # Over-fetch per profile so the merged+sorted window is correct for the - # requested page. Capped so a huge profile can't blow up the response. - per_profile = min(max(limit + offset, limit), 500) - - merged: List[Dict[str, Any]] = [] - total = 0 - profile_totals: Dict[str, int] = {} - errors: List[Dict[str, str]] = [] - now = time.time() - for name, home in targets: - db_path = Path(home) / "state.db" - if not db_path.exists(): - continue - try: - # Read-only: this loop runs on every sidebar refresh, so it must - # never DDL/write-lock another profile's live DB (see SessionDB - # read_only docstring). - db = SessionDB(db_path=db_path, read_only=True) - except Exception as exc: - errors.append({"profile": name, "error": str(exc)}) - continue - try: - rows = db.list_sessions_rich( - source=source_filter, - sources=source_list or None, - exclude_sources=exclude_list or None, - limit=per_profile, - offset=0, - min_message_count=min_message_count, - include_archived=include_archived, - archived_only=archived_only, - order_by_last_active=order == "recent", - # Same SQL-level blob skip as /api/sessions (see above). - compact_rows=not full, - include_pinned=True, - ) - profile_total = db.session_count( - source=source_filter, - sources=source_list or None, - exclude_sources=exclude_list or None, - min_message_count=min_message_count, - include_archived=include_archived, - archived_only=archived_only, - exclude_children=True, - ) - total += profile_total - profile_totals[name] = profile_total - for s in rows: - s["profile"] = name - s["is_default_profile"] = name == "default" - s["is_active"] = ( - s.get("ended_at") is None - and (now - s.get("last_active", s.get("started_at", 0))) < 300 - ) - s["archived"] = bool(s.get("archived")) - s["pinned"] = bool(s.get("pinned")) - merged.append(s) - except Exception as exc: - errors.append({"profile": name, "error": str(exc)}) - finally: - db.close() - - sort_key = "last_active" if order == "recent" else "started_at" - merged.sort(key=lambda s: s.get(sort_key) or s.get("started_at") or 0, reverse=True) - # Pinned rows are back-filled past each profile's LIMIT on purpose; keep - # them in the merged window instead of re-dropping them on recency. - window = merged[offset:offset + limit] - if len(merged) > offset + limit: - seen = {id(s) for s in window} - window.extend(s for s in merged[offset + limit:] if s.get("pinned") and id(s) not in seen) - if not full: - _strip_session_list_rows(window) - return { - "sessions": window, - "total": total, - "profile_totals": profile_totals, - "limit": limit, - "offset": offset, - "errors": errors, - } +app.include_router(_profiles_routes.sessions_router) +from hermes_cli.web_routers.profiles import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + get_profiles_sessions, + get_profiles_sessions_sidebar, +) -@app.get("/api/profiles/sessions/sidebar") -def get_profiles_sessions_sidebar( - recents_profile: str = "all", - recents_limit: int = 20, - recents_exclude: str = None, - cron_limit: int = 50, - messaging_limit: int = 100, - messaging_exclude: str = None, -): - """Batched sidebar session slices — one profile-DB open per refresh. - - The desktop sidebar needs three source-scoped windows per refresh: recents - (local chats, scoped to the active profile), cron sessions (all profiles), - and messaging-platform sessions (all profiles). Served as three separate - ``/api/profiles/sessions`` calls they reopened every profile's ``state.db`` - three times and re-counted each refresh. This opens each DB once and runs - the three filtered queries together, returning the three windows in one - payload. Read-only and process-light, same row projection and 300s active - heuristic as ``/api/profiles/sessions``. - - The caller passes the source taxonomy (``recents_exclude`` / - ``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays - taxonomy-agnostic like the per-slice endpoint. All three slices use - ``min_messages=1`` / ``archived=exclude`` / recency order, matching the - desktop's per-slice calls. - """ - from hermes_state import SessionDB - from hermes_cli import profiles as profiles_mod - - # cron + messaging are cross-profile; recents is scoped to recents_profile. - # Scan every profile once regardless (each DB opened a single time). - try: - infos = profiles_mod.list_profiles() - targets: List[Tuple[str, Path]] = [(info.name, info.path) for info in infos] - except Exception: - _log.exception("GET /api/profiles/sessions/sidebar: list_profiles failed") - targets = [] - if not targets: - targets.append(("default", profiles_mod.get_profile_dir("default"))) - - recents_scope = (recents_profile or "all").strip() or "all" - recents_exclude_list = [s for s in (recents_exclude or "").split(",") if s.strip()] - messaging_exclude_list = [s for s in (messaging_exclude or "").split(",") if s.strip()] - - recents_cap = min(max(recents_limit, 1), 500) - cron_cap = min(max(cron_limit, 1), 500) - messaging_cap = min(max(messaging_limit, 1), 500) - - recents_rows: List[Dict[str, Any]] = [] - cron_rows: List[Dict[str, Any]] = [] - messaging_rows: List[Dict[str, Any]] = [] - recents_truncated: Dict[str, bool] = {} - errors: List[Dict[str, str]] = [] - now = time.time() - - def _tag(rows: List[Dict[str, Any]], name: str) -> List[Dict[str, Any]]: - for s in rows: - s["profile"] = name - s["is_default_profile"] = name == "default" - s["is_active"] = ( - s.get("ended_at") is None - and (now - s.get("last_active", s.get("started_at", 0))) < 300 - ) - s["archived"] = bool(s.get("archived")) - # SQLite stores the pin as 0/1; the sidebar needs a real boolean to - # render the Pinned section from server state. - s["pinned"] = bool(s.get("pinned")) - return rows - - def _slice(db, *, source=None, exclude=None, cap): - return db.list_sessions_rich( - source=source, - exclude_sources=exclude or None, - limit=cap, - offset=0, - min_message_count=1, - include_archived=False, - archived_only=False, - order_by_last_active=True, - compact_rows=True, - # A pinned conversation must reach the sidebar even when it has - # aged past the window — otherwise its Pinned row renders empty. - include_pinned=True, - ) - - for name, home in targets: - db_path = Path(home) / "state.db" - if not db_path.exists(): - continue - try: - db = SessionDB(db_path=db_path, read_only=True) - except Exception as exc: - errors.append({"profile": name, "error": str(exc)}) - continue - try: - if recents_scope == "all" or name == recents_scope: - profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap) - # A full window means more rows remain on disk. That is all the - # sidebar's "load more" needs, and unlike an exact COUNT(*) per - # profile per refresh it costs nothing beyond the rows already - # read. Discount pinned back-fills — they arrive past the LIMIT - # and would otherwise fake a full page on a short list. - unpinned_count = sum(1 for s in profile_rows if not s.get("pinned")) - recents_truncated[name] = unpinned_count >= recents_cap - recents_rows.extend(_tag(profile_rows, name)) - cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name)) - messaging_rows.extend( - _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name) - ) - except Exception as exc: - errors.append({"profile": name, "error": str(exc)}) - finally: - db.close() - - def _window(rows: List[Dict[str, Any]], cap: int) -> List[Dict[str, Any]]: - rows.sort(key=lambda s: s.get("last_active") or s.get("started_at") or 0, reverse=True) - # Pinned rows survive the cap. The per-profile queries deliberately - # back-fill them past the LIMIT, so truncating the merged window on - # recency alone would throw away exactly what the back-fill fetched. - win = rows[:cap] - if len(rows) > cap: - seen = {id(s) for s in win} - win.extend(s for s in rows[cap:] if s.get("pinned") and id(s) not in seen) - _strip_session_list_rows(win) - return win - - return { - "recents": { - "sessions": _window(recents_rows, recents_cap), - "profiles_truncated": recents_truncated, - }, - "cron": {"sessions": _window(cron_rows, cron_cap)}, - "messaging": { - "sessions": _window(messaging_rows, messaging_cap), - "total": len(messaging_rows), - }, - "errors": errors, - } @app.get("/api/sessions/search") @@ -12380,9 +12066,24 @@ async def _run_cron_dashboard_io(func, *args, **kwargs): return result -@app.get("/api/cron/jobs") -async def list_cron_jobs(profile: str = "all"): - return await _run_cron_dashboard_io(_list_cron_jobs_sync, profile) +from hermes_cli.web_routers import cron as _cron_routes # noqa: E402 + +app.include_router(_cron_routes.router) +from hermes_cli.web_routers.cron import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + list_cron_jobs, + get_cron_job, + list_cron_job_runs, + create_cron_job, + get_cron_delivery_targets, + update_cron_job, + pause_cron_job, + resume_cron_job, + trigger_cron_job, + delete_cron_job, + cron_fire_webhook, + list_cron_blueprints, + instantiate_blueprint, +) def _get_cron_job_sync(job_id: str, profile: Optional[str] = None): @@ -12395,9 +12096,6 @@ def _get_cron_job_sync(job_id: str, profile: Optional[str] = None): return job -@app.get("/api/cron/jobs/{job_id}") -async def get_cron_job(job_id: str, profile: Optional[str] = None): - return await _run_cron_dashboard_io(_get_cron_job_sync, job_id, profile) def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: int = 20): @@ -12445,9 +12143,6 @@ def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: db.close() -@app.get("/api/cron/jobs/{job_id}/runs") -async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: int = 20): - return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit) def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None): @@ -12488,37 +12183,8 @@ def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None): raise HTTPException(status_code=400, detail=str(e)) -@app.post("/api/cron/jobs") -async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None): - return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile) -@app.get("/api/cron/delivery-targets") -async def get_cron_delivery_targets(): - """Delivery targets the cron dropdown should offer. - - Always includes the implicit ``local`` option. Beyond that, the list is - derived dynamically from the configured gateway platforms via - ``cron.scheduler.cron_delivery_targets()`` — no hardcoded platform list. A - configured platform that hasn't set its cron home channel is still returned - with ``home_target_set: false`` so the UI can surface it as "configure a - home channel first" rather than hiding it. - """ - targets = [ - { - "id": "local", - "name": "Local (save only)", - "home_target_set": True, - "home_env_var": None, - } - ] - try: - from cron.scheduler import cron_delivery_targets - - targets.extend(cron_delivery_targets()) - except Exception: - _log.exception("GET /api/cron/delivery-targets failed") - return {"targets": targets} def _update_cron_job_sync(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): @@ -12555,9 +12221,6 @@ def _update_cron_job_sync(job_id: str, body: CronJobUpdate, profile: Optional[st return job -@app.put("/api/cron/jobs/{job_id}") -async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): - return await _run_cron_dashboard_io(_update_cron_job_sync, job_id, body, profile) def _pause_cron_job_sync(job_id: str, profile: Optional[str] = None): @@ -12570,9 +12233,6 @@ def _pause_cron_job_sync(job_id: str, profile: Optional[str] = None): return job -@app.post("/api/cron/jobs/{job_id}/pause") -async def pause_cron_job(job_id: str, profile: Optional[str] = None): - return await _run_cron_dashboard_io(_pause_cron_job_sync, job_id, profile) def _resume_cron_job_sync(job_id: str, profile: Optional[str] = None): @@ -12585,9 +12245,6 @@ def _resume_cron_job_sync(job_id: str, profile: Optional[str] = None): return job -@app.post("/api/cron/jobs/{job_id}/resume") -async def resume_cron_job(job_id: str, profile: Optional[str] = None): - return await _run_cron_dashboard_io(_resume_cron_job_sync, job_id, profile) def _trigger_cron_job_sync(job_id: str, profile: Optional[str] = None): @@ -12600,9 +12257,6 @@ def _trigger_cron_job_sync(job_id: str, profile: Optional[str] = None): return job -@app.post("/api/cron/jobs/{job_id}/trigger") -async def trigger_cron_job(job_id: str, profile: Optional[str] = None): - return await _run_cron_dashboard_io(_trigger_cron_job_sync, job_id, profile) def _delete_cron_job_sync(job_id: str, profile: Optional[str] = None): @@ -12618,9 +12272,6 @@ def _delete_cron_job_sync(job_id: str, profile: Optional[str] = None): return {"ok": True} -@app.delete("/api/cron/jobs/{job_id}") -async def delete_cron_job(job_id: str, profile: Optional[str] = None): - return await _run_cron_dashboard_io(_delete_cron_job_sync, job_id, profile) def _fire_cron_job_for_profile(profile: str, job_id: str) -> bool: @@ -12649,63 +12300,6 @@ def _fire_cron_job_for_profile(profile: str, job_id: str) -> bool: reset_hermes_home_override(token) -@app.post("/api/cron/fire") -async def cron_fire_webhook(request: Request): - """Chronos managed-cron fire webhook (NAS -> agent). - - Authenticated by a short-lived NAS-minted JWT (verified by the pluggable - Chronos fire-verifier), NOT the dashboard session cookie — so this path is - in ``PUBLIC_API_PATHS`` to bypass the dashboard auth gate, and the JWT is - the real gate. This is the inbound half of scale-to-zero managed cron: NAS - POSTs here at fire time, the agent verifies, claims the job (store CAS, so - at-most-once across replicas / on a NAS retry), runs it, and re-arms the - next one-shot. - - Lives on the dashboard app (not the api_server adapter) because the - dashboard is the agent's always-reachable public HTTP surface on hosted - deployments; the gateway may be idle/scaled down. - - Returns 202 immediately and runs the job in the background so a long agent - turn never trips NAS's HTTP timeout. - """ - from plugins.cron_providers.chronos.verify import get_fire_verifier - - auth = request.headers.get("Authorization", "") - token = auth[7:].strip() if auth.startswith("Bearer ") else "" - - cfg = load_config() - claims = get_fire_verifier()( - token=token, - expected_audience=cfg_get(cfg, "cron", "chronos", "expected_audience", default=""), - jwks_or_key=cfg_get(cfg, "cron", "chronos", "nas_jwks_url", default="") or None, - issuer=cfg_get(cfg, "cron", "chronos", "portal_url", default="") or None, - ) - if claims is None: - return JSONResponse({"error": "invalid fire token"}, status_code=401) - - try: - body = await request.json() - except Exception: - body = {} - job_id = (body or {}).get("job_id") if isinstance(body, dict) else None - if not job_id: - return JSONResponse({"error": "missing job_id"}, status_code=400) - - # _find_cron_job_profile walks every profile and lists its jobs (file - # I/O per profile) — run it off the event loop like the other cron - # dashboard endpoints. - profile = await _run_cron_dashboard_io(_find_cron_job_profile, job_id) - if not profile: - # Job is gone (cancelled / completed) — nothing to fire. 200 so NAS - # does not retry a fire that is intentionally absent. - return JSONResponse({"status": "gone", "job_id": job_id}, status_code=200) - - # Run in the background; the store CAS claim inside fire_due de-dupes a - # NAS/scheduler retry that arrives while this is in flight. - asyncio.create_task( - asyncio.to_thread(_fire_cron_job_for_profile, profile, job_id) - ) - return JSONResponse({"status": "accepted", "job_id": job_id}, status_code=202) # --------------------------------------------------------------------------- @@ -12713,67 +12307,8 @@ async def cron_fire_webhook(request: Request): # slot schema as a form; submitting instantiates a real cron job via the same # create_job path. See cron/blueprint_catalog.py for the single source of truth. # --------------------------------------------------------------------------- -@app.get("/api/cron/blueprints") -async def list_cron_blueprints(): - """Return the blueprint catalog as form schemas for the dashboard gallery. - - The ``deliver`` slot's options are rewritten from the user's actually - configured gateway platforms (plus the universal origin/local/all), so the - form never offers a platform that isn't connected. - """ - try: - from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry - - deliver_options = None - try: - from cron.scheduler import cron_delivery_targets - - platforms = [t["id"] for t in cron_delivery_targets() if t.get("id")] - deliver_options = ["origin", "local", *platforms] - except Exception: - _log.debug("cron_delivery_targets unavailable; using static deliver options", exc_info=True) - - entries = [] - for r in CATALOG: - entry = blueprint_catalog_entry(r) - if deliver_options: - for f in entry.get("fields", []): - if f.get("name") == "deliver": - f["options"] = deliver_options - entries.append(entry) - return {"blueprints": entries} - except Exception as e: - _log.exception("GET /api/cron/blueprints failed") - raise HTTPException(status_code=500, detail=str(e)) -@app.post("/api/cron/blueprints/instantiate") -async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: str = "default"): - """Fill a blueprint's slots and create the cron job (form-submit path).""" - try: - from cron.blueprint_catalog import fill_blueprint, get_blueprint, BlueprintFillError - - blueprint = get_blueprint(body.blueprint) - if blueprint is None: - raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}") - try: - spec = fill_blueprint(blueprint, body.values) - except BlueprintFillError as exc: - # Field-level validation error — 422 so the form can show it inline. - raise HTTPException(status_code=422, detail=str(exc)) from exc - # Blueprint-created jobs deliver to the dashboard's configured target by - # default; the form's deliver slot overrides via spec["deliver"]. - spec.pop("origin", None) - # create_job does per-profile file I/O — keep it off the event loop - # like the sibling cron endpoints (partial avoids **spec keys ever - # colliding with the wrapper's own parameters). - _create = functools.partial(_call_cron_for_profile, profile, "create_job", **spec) - return await _run_cron_dashboard_io(_create) - except HTTPException: - raise - except Exception as e: - _log.exception("POST /api/cron/blueprints/instantiate failed") - raise HTTPException(status_code=400, detail=str(e)) # --------------------------------------------------------------------------- @@ -14976,355 +14511,46 @@ def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int: return disabled_count -@app.get("/api/profiles") -async def list_profiles_endpoint(): - from hermes_cli import profiles as profiles_mod - try: - loop = asyncio.get_running_loop() - profiles = await loop.run_in_executor(None, profiles_mod.list_profiles) - return {"profiles": [_profile_to_dict(p) for p in profiles]} - except Exception: - _log.exception("GET /api/profiles failed; falling back to profile directory scan") - return {"profiles": _fallback_profile_dicts(profiles_mod)} +app.include_router(_profiles_routes.router) +from hermes_cli.web_routers.profiles import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + list_profiles_endpoint, + create_profile_endpoint, + get_active_profile_endpoint, + set_active_profile_endpoint, + get_profile_setup_command, + open_profile_terminal_endpoint, + rename_profile_endpoint, + delete_profile_endpoint, + get_profile_soul, + update_profile_soul, + update_profile_description_endpoint, + update_profile_model_endpoint, + describe_profile_auto_endpoint, +) -@app.post("/api/profiles") -async def create_profile_endpoint(body: ProfileCreate): - from hermes_cli import profiles as profiles_mod - explicit_source = (body.clone_from or "").strip() - if explicit_source: - # Duplicating a specific profile: clone its config/skills/SOUL (or full - # state when clone_all) from the named source rather than "default". - clone = True - clone_from = explicit_source - clone_config = not body.clone_all - elif body.clone_all: - # Preserve the dashboard's historical clone-all behavior: a full-copy - # request with no explicit dropdown source copies from default. - clone = True - clone_from = "default" - clone_config = False - else: - clone = body.clone_from_default - clone_from = "default" if clone else None - clone_config = clone - try: - path = profiles_mod.create_profile( - name=body.name, - clone_from=clone_from, - clone_all=body.clone_all, - clone_config=clone_config, - no_skills=body.no_skills, - description=body.description, - ) - # Match the CLI's profile-create flow: fresh named profiles get the - # bundled skills installed. When cloning from default, create_profile() - # has already copied the source profile's skills, including any - # user-installed skills. When no_skills=True, create_profile() wrote - # the opt-out marker and seed_profile_skills() will no-op. - if not clone: - profiles_mod.seed_profile_skills(path, quiet=True) - - # Match the CLI's profile-create flow: named profiles should get a - # wrapper in ~/.local/bin when the alias is safe to create. - collision = profiles_mod.check_alias_collision(body.name) - if not collision: - profiles_mod.create_wrapper_script(body.name) - except (ValueError, FileExistsError, FileNotFoundError) as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - _log.exception("POST /api/profiles failed") - raise HTTPException(status_code=500, detail=str(e)) - - # Optional explicit model assignment for the new profile. Best-effort: - # the profile already exists, so a model-write hiccup must not 500 the - # whole create — the user can set the model later from the Models page - # or ` setup`. - provider = (body.provider or "").strip() - model = (body.model or "").strip() - model_set = False - if provider and model: - try: - _write_profile_model(path, provider, model) - model_set = True - except Exception: - _log.exception("Setting model for new profile %s failed", body.name) - - # Optional MCP servers. Best-effort, same rationale as model assignment. - mcp_written = 0 - if body.mcp_servers: - try: - mcp_written = _write_profile_mcp_servers(path, body.mcp_servers) - except Exception: - _log.exception("Writing MCP servers for new profile %s failed", body.name) - - # Optional "keep" skill selection — replace semantics. When the builder - # sends an explicit keep list, disable every seeded skill not in it. - # Best-effort. Skipped when keep_skills is empty (legacy: keep the bundle). - skills_disabled = 0 - if body.keep_skills: - try: - skills_disabled = _disable_unselected_skills(path, body.keep_skills) - except Exception: - _log.exception("Applying skill selection for new profile %s failed", body.name) - - # Optional skills-hub installs. Spawned async, scoped to the new profile - # via `-p ` (a fresh subprocess re-binds skills_hub.SKILLS_DIR to the - # profile's HERMES_HOME at import). Returns PIDs for the UI to poll. - hub_installs: List[Dict[str, Any]] = [] - for identifier in body.hub_skills: - ident = (identifier or "").strip() - if not ident: - continue - try: - proc = _spawn_hermes_action( - ["-p", body.name, "skills", "install", ident, "--yes"], - _hub_action_name("install", ident), - ) - hub_installs.append({"identifier": ident, "pid": proc.pid}) - except Exception: - _log.exception( - "Spawning hub-skill install %s for new profile %s failed", - ident, - body.name, - ) - hub_installs.append({"identifier": ident, "pid": None}) - - return { - "ok": True, - "name": body.name, - "path": str(path), - "model_set": model_set, - "mcp_written": mcp_written, - "skills_disabled": skills_disabled, - "hub_installs": hub_installs, - } -@app.get("/api/profiles/active") -async def get_active_profile_endpoint(): - """Return the sticky active profile and the profile this dashboard - process is currently running as. - - ``active`` is the sticky default written by ``hermes profile use`` — - the profile new CLI invocations pick up. ``current`` is the profile - the running dashboard/gateway is scoped to (derived from HERMES_HOME). - """ - from hermes_cli import profiles as profiles_mod - try: - active = profiles_mod.get_active_profile() or "default" - except Exception: - active = "default" - try: - current = profiles_mod.get_active_profile_name() or "default" - except Exception: - current = "default" - return {"active": active, "current": current} -@app.post("/api/profiles/active") -async def set_active_profile_endpoint(body: ProfileActiveUpdate): - """Set the sticky active profile (mirrors ``hermes profile use``). - - Note: this does not retarget the already-running dashboard process — - it changes which profile subsequent CLI commands and gateways use. - """ - from hermes_cli import profiles as profiles_mod - try: - profiles_mod.set_active_profile(body.name) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - _log.exception("POST /api/profiles/active failed") - raise HTTPException(status_code=500, detail=str(e)) - return {"ok": True, "active": profiles_mod.normalize_profile_name(body.name)} -@app.get("/api/profiles/{name}/setup-command") -async def get_profile_setup_command(name: str): - return {"command": _profile_setup_command(name)} -@app.post("/api/profiles/{name}/open-terminal") -async def open_profile_terminal_endpoint(name: str): - try: - command = _profile_setup_command(name) - - if sys.platform.startswith("win"): - subprocess.Popen(["cmd.exe", "/c", "start", "", command]) - elif sys.platform == "darwin": - escaped = command.replace("\\", "\\\\").replace('"', '\\"') - applescript = ( - 'tell application "Terminal"\n' - "activate\n" - f'do script "{escaped}"\n' - "end tell" - ) - subprocess.Popen(["osascript", "-e", applescript]) - else: - terminal_commands = [ - ("x-terminal-emulator", ["x-terminal-emulator", "-e", "sh", "-lc", command]), - ("gnome-terminal", ["gnome-terminal", "--", "sh", "-lc", command]), - ("konsole", ["konsole", "-e", "sh", "-lc", command]), - ("xfce4-terminal", ["xfce4-terminal", "-e", f"sh -lc '{command}'"]), - ("mate-terminal", ["mate-terminal", "-e", f"sh -lc '{command}'"]), - ("lxterminal", ["lxterminal", "-e", f"sh -lc '{command}'"]), - ("tilix", ["tilix", "-e", "sh", "-lc", command]), - ("alacritty", ["alacritty", "-e", "sh", "-lc", command]), - ("kitty", ["kitty", "sh", "-lc", command]), - ("xterm", ["xterm", "-e", "sh", "-lc", command]), - ] - for executable, popen_args in terminal_commands: - if subprocess.call( - ["which", executable], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) == 0: - subprocess.Popen(popen_args) - break - else: - raise HTTPException( - status_code=400, - detail="No supported terminal emulator found", - ) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except HTTPException: - raise - except Exception as e: - _log.exception("POST /api/profiles/%s/open-terminal failed", name) - raise HTTPException(status_code=500, detail=str(e)) - return {"ok": True, "command": command} -@app.patch("/api/profiles/{name}") -async def rename_profile_endpoint(name: str, body: ProfileRename): - from hermes_cli import profiles as profiles_mod - try: - path = profiles_mod.rename_profile(name, body.new_name) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except (ValueError, FileExistsError) as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - _log.exception("PATCH /api/profiles/%s failed", name) - raise HTTPException(status_code=500, detail=str(e)) - return {"ok": True, "name": body.new_name, "path": str(path)} -@app.delete("/api/profiles/{name}") -async def delete_profile_endpoint(name: str): - """Delete a profile. The dashboard collects the user's confirmation in - its own dialog before this request, so we always pass ``yes=True`` to - skip the CLI's interactive prompt.""" - from hermes_cli import profiles as profiles_mod - try: - path = profiles_mod.delete_profile(name, yes=True) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - _log.exception("DELETE /api/profiles/%s failed", name) - raise HTTPException(status_code=500, detail=str(e)) - return {"ok": True, "path": str(path)} -@app.get("/api/profiles/{name}/soul") -async def get_profile_soul(name: str): - soul_path = _resolve_profile_dir(name) / "SOUL.md" - if soul_path.exists(): - try: - return {"content": soul_path.read_text(encoding="utf-8"), "exists": True} - except OSError as e: - raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}") - return {"content": "", "exists": False} -@app.put("/api/profiles/{name}/soul") -async def update_profile_soul(name: str, body: ProfileSoulUpdate): - soul_path = _resolve_profile_dir(name) / "SOUL.md" - try: - soul_path.write_text(body.content, encoding="utf-8") - except OSError as e: - _log.exception("PUT /api/profiles/%s/soul failed", name) - raise HTTPException(status_code=500, detail=f"Could not write SOUL.md: {e}") - return {"ok": True} -@app.put("/api/profiles/{name}/description") -async def update_profile_description_endpoint(name: str, body: ProfileDescriptionUpdate): - """Set or clear a profile's role description (kanban routing signal). - - Empty string clears the description. Non-empty stores it as a - user-authored description (``description_auto: false``) so the - auto-describer won't overwrite it on a sweep. - """ - from hermes_cli import profiles as profiles_mod - profile_dir = _resolve_profile_dir(name) - text = (body.description or "").strip() - try: - profiles_mod.write_profile_meta( - profile_dir, - description=text, - description_auto=False, - ) - except Exception as e: - _log.exception("PUT /api/profiles/%s/description failed", name) - raise HTTPException(status_code=500, detail=str(e)) - return {"ok": True, "description": text, "description_auto": False} -@app.put("/api/profiles/{name}/model") -async def update_profile_model_endpoint(name: str, body: ProfileModelUpdate): - """Set the main model (``model.default`` + ``model.provider``) for a - specific profile's config.yaml, without touching the dashboard's own - active profile. Mirrors ``POST /api/model/set`` (main scope) but scoped - to the named profile via the HERMES_HOME override. - """ - profile_dir = _resolve_profile_dir(name) - provider = (body.provider or "").strip() - model = (body.model or "").strip() - if not provider or not model: - raise HTTPException(status_code=400, detail="provider and model are required") - try: - _write_profile_model(profile_dir, provider, model) - except Exception as e: - _log.exception("PUT /api/profiles/%s/model failed", name) - raise HTTPException(status_code=500, detail=str(e)) - return {"ok": True, "provider": provider, "model": model} -@app.post("/api/profiles/{name}/describe-auto") -async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto): - """Auto-generate a profile's description via the auxiliary LLM - (``auxiliary.profile_describer``). Mirrors ``hermes profile describe - --auto``. - - A failed generation (no aux client, LLM error, …) is returned as - ``ok: false`` with a reason rather than an HTTP error so the UI can - surface it inline and let the operator fix config and retry. - """ - _resolve_profile_dir(name) - try: - from hermes_cli import profile_describer - outcome = profile_describer.describe_profile(name, overwrite=bool(body.overwrite)) - except Exception as e: - _log.exception("POST /api/profiles/%s/describe-auto failed", name) - raise HTTPException(status_code=500, detail=str(e)) - return { - "ok": bool(outcome.ok), - "reason": outcome.reason, - "description": outcome.description, - # Only a successful generation is an auto-authored description. A failed - # sweep leaves any existing description untouched, so don't claim it's - # auto-generated. - "description_auto": bool(outcome.ok), - } # ---------------------------------------------------------------------------