diff --git a/hermes_cli/web_routers/sessions.py b/hermes_cli/web_routers/sessions.py index 634571d1a1..0b9eadb12a 100644 --- a/hermes_cli/web_routers/sessions.py +++ b/hermes_cli/web_routers/sessions.py @@ -1,11 +1,9 @@ """Session dashboard routes. -Three routers because global route order matters: ``list_router`` -(GET /api/sessions) was registered before the profiles ``sessions_router`` -include, ``search_router`` (GET /api/sessions/search) right after it, and -``manage_router`` (mutation/detail endpoints) much later — each is mounted at -its original registration point. web_server-owned helpers are reached via -the late-binding seam so ``monkeypatch.setattr(web_server, ...)`` keeps working. +Three routers because global route order matters: ``list_router`` (GET +/api/sessions) mounts before the profiles ``sessions_router``, ``search_router`` +right after it, ``manage_router`` (mutation/detail) much later. web_server-owned +helpers are reached via the late-binding seam so monkeypatching keeps working. """ import asyncio @@ -13,7 +11,7 @@ import json import re import sqlite3 import time -from typing import Any, Callable, Dict, List, Optional +from typing import Callable, List, Optional from fastapi import APIRouter, HTTPException, Query, Request from fastapi.encoders import jsonable_encoder @@ -58,14 +56,6 @@ async def _read_session_import_body(request: Request) -> bytes: return bytes(body) -def _import_sessions_for_profile(profile: Optional[str], sessions: List[Dict[str, Any]]) -> Dict[str, Any]: - db = _open_session_db_for_profile(profile, read_only=False) - try: - return db.import_sessions(sessions) - finally: - db.close() - - # Prune filters forwarded to SessionDB; string filters map "" -> None. _PRUNE_STR_FILTERS = ( "source", "title_like", "end_reason", "cwd_prefix", "model_like", "provider", @@ -75,6 +65,9 @@ _PRUNE_NUM_FILTERS = ( "min_tool_calls", "max_tool_calls") +_PRUNE_ROW_KEYS = ("id", "source", "title", "model", "started_at", "last_active", "message_count") + + def _prune_sessions(body: SessionPrune): """Delete ended sessions matching filters (mirrors `hermes sessions prune`).""" from hermes_cli.web_server import get_hermes_home @@ -93,8 +86,7 @@ def _prune_sessions(body: SessionPrune): db = _open_session_db_for_profile(body.profile, read_only=False) try: filters = { - "older_than_days": effective_older_than, - "started_before": body.started_before, + "older_than_days": effective_older_than, "started_before": body.started_before, "started_after": body.started_after, "archived": None if body.include_archived else False, **{f: (getattr(body, f) or None) for f in _PRUNE_STR_FILTERS}, @@ -112,12 +104,7 @@ def _prune_sessions(body: SessionPrune): "newest_last_active": rows[-1]["last_active"] if rows else None, "oldest_started_at": min(r["started_at"] for r in rows) if rows else None, "newest_started_at": max(r["started_at"] for r in rows) if rows else None, - "sessions": [ - { - "id": r["id"], "source": r["source"], "title": r.get("title"), - "model": r.get("model"), "started_at": r["started_at"], - "last_active": r["last_active"], "message_count": r["message_count"]} - for r in rows]} + "sessions": [{k: r.get(k) for k in _PRUNE_ROW_KEYS} for r in rows]} sessions_dir = profile_home / "sessions" removed = db.prune_sessions( sessions_dir=sessions_dir if sessions_dir.exists() else None, **filters) @@ -156,12 +143,8 @@ def _serving_profile(profile: Optional[str]) -> str: def _resolve_session_id(db, session_id: str) -> Optional[str]: - """Resolve *session_id*, distinguishing "absent" from "unreadable". - - On a corrupt ``state.db`` the exact-match lookup just misses while the - prefix fallback scans the b-tree and raises "malformed"; report corruption - as 503 with the actual problem instead of an empty store (404). - """ + """Resolve *session_id*; a corrupt store (prefix scan raises "malformed") is + reported as 503 with the actual problem instead of a misleading 404.""" try: return db.resolve_session_id(session_id) except sqlite3.DatabaseError as exc: @@ -177,36 +160,26 @@ def _resolve_session_id(db, session_id: str) -> Optional[str]: ) from exc +# ``le=100`` on limit: an unbounded limit lets one request drag every session +# row (plus correlated-subquery preview work) out of SQLite in a single hit. @list_router.get("/api/sessions") def get_sessions( - # ``le=100``: an unbounded limit lets one request drag every session row - # (plus correlated-subquery preview work) out of SQLite in a single hit. - limit: int = Query(20, ge=0, le=100), - offset: int = Query(0, ge=0), - min_messages: int = 0, - archived: str = "exclude", - order: str = "created", - source: str = None, - sources: str = None, - exclude_sources: str = None, - cwd_prefix: str = None, - full: bool = False, + limit: int = Query(20, ge=0, le=100), offset: int = Query(0, ge=0), min_messages: int = 0, + archived: str = "exclude", order: str = "created", source: str = None, sources: str = None, + exclude_sources: str = None, cwd_prefix: str = None, full: bool = False, profile: Optional[str] = None): """List sessions. - ``archived``: ``exclude`` (default) / ``only`` / ``include``. ``order``: - ``created`` (start time) or ``recent`` (latest activity across the - compression chain, so a long-running chat stays on page one after it - auto-compresses onto a fresh id). Rows omit ``system_prompt`` / - ``model_config`` unless ``full=1``. + ``order=recent`` sorts by latest activity across the compression chain, so + a long-running chat stays on page one after it auto-compresses onto a fresh + id. Rows omit ``system_prompt`` / ``model_config`` unless ``full=1``. """ if archived not in ("exclude", "only", "include"): - raise HTTPException(status_code=400, detail="archived must be one of: 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") - profile_name: Optional[str] = None - if profile: - profile_name, _ = _cron_profile_home(profile) + profile_name = _cron_profile_home(profile)[0] if profile else None try: # Auto-archive is the only write on this GET path: run it on its own # maintenance connection, then open the listing connection read-only. @@ -221,12 +194,9 @@ def get_sessions( source_list = _csv(sources) exclude_list = _csv(exclude_sources) scope = dict( - source=source or None, - sources=source_list or None, - exclude_sources=exclude_list or None, - cwd_prefix=(cwd_prefix or None), - min_message_count=min_message_count, - include_archived=include_archived, + source=source or None, sources=source_list or None, + exclude_sources=exclude_list or None, cwd_prefix=(cwd_prefix or None), + min_message_count=min_message_count, include_archived=include_archived, archived_only=archived_only) sessions = db.list_sessions_rich( limit=limit, @@ -258,7 +228,6 @@ def get_sessions( _log.exception("GET /api/sessions failed") # 503, not 500: the store is busy, not gone — the desktop keeps its # sidebar instead of reading a 500 as an authoritative empty list. - # The bounded open-retry lives in SessionDB's read-only constructor. transient = is_transient_sqlite_error(exc) raise HTTPException( status_code=503 if transient else 500, @@ -284,19 +253,14 @@ def _is_compression_edge(child: dict, parent: dict) -> bool: @search_router.get("/api/sessions/search") async def search_sessions( - q: str = "", - limit: int = 20, - profile: Optional[str] = None, - source: str = None, - sources: str = None, - exclude_sources: str = None): - """Search sessions by ID plus FTS5 message content. + q: str = "", limit: int = 20, profile: Optional[str] = None, source: str = None, + sources: str = None, exclude_sources: str = None): + """Search sessions by ID (first) plus FTS5 message content. - ID matches first, then content matches. Results are deduped by - compression lineage, not raw ``session_id``: auto-compression rotates a - chat onto a fresh id and leaves the old segment in the FTS index, so one - logical chat owns many rows. Branches also use ``parent_session_id`` but - are real alternate conversations — they are NOT collapsed into the parent. + Results are deduped by compression lineage, not raw ``session_id``: + auto-compression rotates a chat onto a fresh id and leaves the old segment + in the FTS index. Branches also use ``parent_session_id`` but are real + alternate conversations — they are NOT collapsed into the parent. """ if not q or not q.strip(): return {"results": []} @@ -321,20 +285,12 @@ async def search_sessions( root_cache: dict = {} def compression_root(session_id: str) -> str: - if not session_id: - return session_id - if session_id in root_cache: - return root_cache[session_id] - chain = [] - cur = session_id - visited = set() - root = session_id - while cur and cur not in visited: - visited.add(cur) - chain.append(cur) + chain, cur, root = [], session_id, session_id + while cur and cur not in chain: # ``not in chain`` guards parent cycles if cur in root_cache: root = root_cache[cur] break + chain.append(cur) s = get_session(cur) parent = s.get("parent_session_id") if isinstance(s, dict) else None parent_session = get_session(parent) if parent else None @@ -349,15 +305,12 @@ async def search_sessions( tip_cache: dict = {} def lineage_tip(root_id: str) -> str: - if root_id in tip_cache: - return tip_cache[root_id] - tip = root_id - try: - tip = db.get_compression_tip(root_id) or tip - except Exception: - pass - tip_cache[root_id] = tip - return tip + if root_id not in tip_cache: + try: + tip_cache[root_id] = db.get_compression_tip(root_id) or root_id + except Exception: + tip_cache[root_id] = root_id + return tip_cache[root_id] # One keyspace for id-hits and content-hits, keyed by lineage root; # first hit wins, and ID matches run first. @@ -387,7 +340,8 @@ async def search_sessions( "started_at": row.get("started_at"), "ended_at": row.get("ended_at"), "last_active": last_active, - "is_active": row.get("ended_at") is None and (now - (last_active or 0)) < 300, + "is_active": ( + row.get("ended_at") is None and (now - (last_active or 0)) < 300), "message_count": row.get("message_count") or 0, "tool_call_count": row.get("tool_call_count") or 0, "input_tokens": row.get("input_tokens") or 0, @@ -399,10 +353,10 @@ async def search_sessions( payload["id"] = sid seen[root] = payload - def hit_payload(row: dict, snippet: str, role) -> dict: + def hit_payload(row: dict, snippet: str, role, session_started) -> dict: return { "snippet": snippet, "role": role, "source": row.get("source"), - "model": row.get("model"), "session_started": row.get("session_started")} + "model": row.get("model"), "session_started": session_started} # Direct ID matches first (pasted ids never appear in message text). for row in db.search_sessions_by_id( @@ -410,9 +364,8 @@ async def search_sessions( sources=source_list or None, exclude_sources=exclude_list or None): sid = row.get("id") preview = (row.get("preview") or "").strip() - payload = hit_payload(row, preview or f"Session ID: {sid}", None) - payload["session_started"] = row.get("started_at") - add_lineage_result(sid, payload) + snippet = preview or f"Session ID: {sid}" + add_lineage_result(sid, hit_payload(row, snippet, None, row.get("started_at"))) # Prefix wildcards so partial words match ("nimb" -> "nimb*"); # quoted phrases and existing wildcards are kept as-is. @@ -422,15 +375,15 @@ async def search_sessions( # Over-fetch so lineage dedup can still surface `limit` distinct # conversations when several hits collapse onto one root. matches = db.search_messages( - query=prefix_query, - source_filter=include_sources, - exclude_sources=exclude_list or None, - limit=max(safe_limit * 5, 50), + query=prefix_query, source_filter=include_sources, + exclude_sources=exclude_list or None, limit=max(safe_limit * 5, 50), fields=("session_id", "role", "snippet", "source", "model", "session_started")) for m in matches: if len(seen) >= safe_limit: break - add_lineage_result(m["session_id"], hit_payload(m, m.get("snippet", ""), m.get("role"))) + add_lineage_result( + m["session_id"], + hit_payload(m, m.get("snippet", ""), m.get("role"), m.get("session_started"))) return {"results": list(seen.values())} finally: db.close() @@ -438,17 +391,14 @@ async def search_sessions( @manage_router.post("/api/sessions/bulk-delete") async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): - """Delete every session in ``body.ids`` in one transaction (POST because - many clients refuse a DELETE body). + """Delete every session in ``body.ids`` in one transaction (POST: many + clients refuse a DELETE body). - Contract matches :meth:`SessionDB.delete_sessions`: unknown ids are - skipped (``deleted`` reports what really happened — selection state can - race another tab's delete), children are orphaned not cascaded, active - and archived rows ARE deleted because the user hand-picked them, and - on-disk transcript cleanup is left to the next prune pass. + Per :meth:`SessionDB.delete_sessions`: unknown ids are skipped (``deleted`` + reports what really happened), children are orphaned, active/archived rows + ARE deleted (hand-picked), on-disk cleanup is left to the next prune. """ - # Hard cap so a runaway selection can't lock the writer for long; 500 - # covers "select all on every page of a reasonable scrollback". + # Hard cap so a runaway selection can't lock the writer for long. if len(body.ids) > 500: raise HTTPException(status_code=400, detail="ids must contain at most 500 entries") deleted = await asyncio.to_thread( @@ -469,7 +419,8 @@ async def import_sessions_endpoint(request: Request): raise HTTPException(status_code=400, detail="Invalid session import payload") from exc try: - result = await asyncio.to_thread(_import_sessions_for_profile, body.profile, body.sessions) + result = await asyncio.to_thread( + _with_db, body.profile, lambda db: db.import_sessions(body.sessions), read_only=False) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -480,8 +431,7 @@ async def import_sessions_endpoint(request: Request): @manage_router.get("/api/sessions/empty/count") async def count_empty_sessions_endpoint(profile: Optional[str] = None): - """Count of empty, ended, non-archived sessions (drives the "Delete empty - (N)" button, hidden when N is 0).""" + """Count of empty, ended, non-archived sessions (the "Delete empty (N)" button).""" count = await asyncio.to_thread( _with_db, profile, lambda db: db.count_empty_sessions(), read_only=True) return {"count": count} @@ -491,11 +441,9 @@ async def count_empty_sessions_endpoint(profile: Optional[str] = None): async def delete_empty_sessions_endpoint(profile: Optional[str] = None): """Delete every empty, ended, non-archived session in one transaction. - Mirrors :meth:`SessionDB.delete_empty_sessions`: "empty" means NO - ``messages`` rows at all (a rewound/compacted chat reads - ``message_count == 0`` while its soft-archived rows are the only copy of - the transcript); active and archived sessions are skipped; children are - orphaned; on-disk cleanup is left to the next prune pass. + "Empty" means NO ``messages`` rows at all — a rewound/compacted chat reads + ``message_count == 0`` while its soft-archived rows are the only transcript + copy (see :meth:`SessionDB.delete_empty_sessions`). """ deleted = await asyncio.to_thread( _with_db, profile, lambda db: db.delete_empty_sessions(), read_only=False) @@ -504,17 +452,16 @@ async def delete_empty_sessions_endpoint(profile: Optional[str] = None): @manage_router.get("/api/sessions/stats") async def get_session_stats(profile: Optional[str] = None): - """Session-store statistics (mirrors `hermes sessions stats`). Registered - before ``/api/sessions/{session_id}`` so ``stats`` isn't taken as an id.""" + """Session-store statistics (mirrors `hermes sessions stats`).""" def _stats(db): out = { "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(), + "archived": db.session_count(archived_only=True), "messages": db.message_count(), "by_source": {}} try: - out["by_source"] = db.session_count_by_source(include_archived=True, exclude_children=True) + out["by_source"] = db.session_count_by_source( + include_archived=True, exclude_children=True) except Exception: pass return out @@ -529,9 +476,8 @@ async def get_session_detail(session_id: str, profile: Optional[str] = None): session = db.get_session(sid) if sid else None if not session: raise HTTPException(status_code=404, detail=_NOT_FOUND) - # Always stamp the owner: stamping only on explicit ``?profile=`` left - # default-profile rows unowned, so multi-profile clients resolved them - # to whichever gateway happened to be active. + # Always stamp the owner: unowned default-profile rows made multi-profile + # clients resolve them to whichever gateway happened to be active. session["profile"] = _serving_profile(profile) session["is_default_profile"] = session["profile"] == "default" return session @@ -546,9 +492,7 @@ async def get_session_latest_descendant(session_id: str, profile: Optional[str] if not latest: raise HTTPException(status_code=404, detail=_NOT_FOUND) return { - "requested_session_id": path[0] if path else session_id, - "session_id": latest, - "path": path, + "requested_session_id": path[0] if path else session_id, "session_id": latest, "path": path, "changed": bool(path and latest != path[0])} @@ -579,11 +523,8 @@ def _project_for_display(messages: list) -> list: @manage_router.get("/api/sessions/{session_id}/messages") async def get_session_messages( - session_id: str, - profile: Optional[str] = None, - limit: Optional[int] = Query(None, ge=0), - offset: int = Query(0, ge=0), - order: Optional[str] = Query(None), + session_id: str, profile: Optional[str] = None, limit: Optional[int] = Query(None, ge=0), + offset: int = Query(0, ge=0), order: Optional[str] = Query(None), include_compacted: bool = Query(False)): if order not in (None, "oldest", "latest"): raise HTTPException(status_code=400, detail="order must be one of: oldest, latest") @@ -593,15 +534,14 @@ async def get_session_messages( if not sid: return None sid = db.resolve_resume_session_id(sid) - # Always page: an omitted limit used to load whole transcripts (hundreds - # of thousands of rows for a runaway session). Explicit pagination - # anchors at the start; the default view is the latest page. + # Always page (an omitted limit used to load whole transcripts). Explicit + # pagination anchors at the start; the default view is the latest page. default_page = limit is None latest_page = order == "latest" or (order is None and default_page) _limit = 500 if default_page else min(limit, 500) return sid, _limit, db.get_messages( - sid, limit=_limit, offset=offset, latest=latest_page, include_compacted=include_compacted - ) + sid, limit=_limit, offset=offset, latest=latest_page, + include_compacted=include_compacted) result = await asyncio.to_thread(_with_db, profile, _read, read_only=True) if result is None: @@ -612,21 +552,17 @@ async def get_session_messages( "session_id": sid, "messages": projected_messages, "pagination": { - "limit": _limit, - "offset": offset, + "limit": _limit, "offset": offset, "order": order or ("latest" if limit is None else "oldest"), "returned": len(projected_messages)}} @manage_router.delete("/api/sessions/{session_id}") async def delete_session_endpoint(session_id: str, profile: Optional[str] = None): - # ``profile`` opens another local profile's state.db directly (remote - # profiles never reach here — the desktop routes those to the remote backend). def _delete(db): - # An already-absent session is an idempotent success: the desktop - # optimistically removes the row and RESTORES it on any error, so a 404 - # here resurrected ghost rows (transient empties from /goal + - # auto-compression churn racing the sidebar snapshot). + # Already-absent is an idempotent success: the desktop optimistically + # removes the row and RESTORES it on any error, so a 404 resurrected + # ghost rows (transient empties racing the sidebar snapshot). sid = _resolve_session_id(db, session_id) if not sid: return {"ok": True, "already_absent": True} @@ -638,20 +574,19 @@ async def delete_session_endpoint(session_id: str, profile: Optional[str] = None @manage_router.post("/api/sessions/owner-backfill") async def backfill_session_owner_profiles(body: SessionOwnerBackfill): - """Stamp legacy ``profile_name = NULL`` rows with this store's own - serving-profile identity. + """Stamp legacy ``profile_name = NULL`` rows with the serving-profile identity. - A multi-connection Desktop fails closed on unowned rows, leaving legacy - sessions unresumable. Each ``state.db`` belongs to exactly one profile, - so this is a single-match backfill (the same identity ``get_sessions`` - stamps on outgoing rows), idempotent: non-NULL owners are never overwritten. + A multi-connection Desktop fails closed on unowned rows. Each ``state.db`` + belongs to exactly one profile, so this is a single-match, idempotent + backfill (non-NULL owners are never overwritten). """ stamp = _serving_profile(body.profile) - with http_failure("POST /api/sessions/owner-backfill failed", 500, detail="Internal server error"): + with http_failure( + "POST /api/sessions/owner-backfill failed", 500, detail="Internal server error"): stamped = await asyncio.to_thread( - _with_db, body.profile, lambda db: db.backfill_null_session_profiles(stamp), read_only=False - ) + _with_db, body.profile, lambda db: db.backfill_null_session_profiles(stamp), + read_only=False) if stamped: _log.info( @@ -660,18 +595,20 @@ async def backfill_session_owner_profiles(body: SessionOwnerBackfill): return {"ok": True, "stamped": stamped, "profile": stamp} +# PATCH /api/sessions/{id} flag -> SessionDB setter, applied in this order. _RENAME_FLAG_SETTERS = ( - ("archived", "set_session_archived"), - ("hidden", "set_session_hidden"), - ("pinned", "set_session_pinned")) + ("archived", lambda db, sid, v: db.set_session_archived(sid, v)), + ("hidden", lambda db, sid, v: db.set_session_hidden(sid, v)), + ("pinned", lambda db, sid, v: db.set_session_pinned(sid, v)), + ("unread", lambda db, sid, v: db.set_session_read(sid, read=not v)), +) @manage_router.patch("/api/sessions/{session_id}") async def rename_session_endpoint(session_id: str, body: SessionRename): - """Update a session: ``title`` (empty clears), ``archived``, ``hidden``, - ``pinned`` (exempts from the auto-archive sweep), ``unread`` (True = - explicitly unread, False = read up to now). Any field may be omitted.""" - flags = ("archived", "hidden", "pinned", "unread") + """Update ``title`` (empty clears) and/or the flags; ``pinned`` exempts from + the auto-archive sweep, ``unread=False`` marks read up to now.""" + flags = [flag for flag, _ in _RENAME_FLAG_SETTERS] def _update(db): sid = _resolve_session_id(db, session_id) @@ -688,16 +625,13 @@ async def rename_session_endpoint(session_id: str, body: SessionRename): except ValueError as e: # Title too long, invalid characters, or already in use. raise HTTPException(status_code=400, detail=str(e)) + result = {"ok": True, "title": None} for flag, setter in _RENAME_FLAG_SETTERS: value = getattr(body, flag) if value is not None: - getattr(db, setter)(sid, value) - if body.unread is not None: - db.set_session_read(sid, read=not body.unread) - result = {"ok": True, "title": db.get_session_title(sid) or ""} - for f in flags: - if getattr(body, f) is not None: - result[f] = bool(getattr(body, f)) + setter(db, sid, value) + result[flag] = bool(value) + result["title"] = db.get_session_title(sid) or "" return result return _with_db(body.profile, _update, read_only=False) @@ -726,21 +660,15 @@ async def export_session_endpoint(session_id: str, profile: Optional[str] = None yield _compact_json(session)[:-1] + ',"messages":[' # Keyset pagination (id > last_seen): O(n) total over the # transcript, vs OFFSET's O(n²) on huge sessions. - last_id = None - first = True + last_id, first = 0, True while True: - messages = db.get_messages( - sid, limit=500, after_id=last_id if last_id is not None else 0) + messages = db.get_messages(sid, limit=500, after_id=last_id) for message in messages: - if not first: - yield "," - yield _compact_json(message) + yield ("" if first else ",") + _compact_json(message) first = False - if len(messages) < 500: + last_id = messages[-1].get("id") if len(messages) == 500 else None + if last_id is None: # short page, or cannot keyset without row ids break - last_id = messages[-1].get("id") - if last_id is None: - break # defensive: cannot keyset without row ids yield "]}" finally: db.close() diff --git a/hermes_cli/web_routers/skills.py b/hermes_cli/web_routers/skills.py index efd2e205f8..d49ab86676 100644 --- a/hermes_cli/web_routers/skills.py +++ b/hermes_cli/web_routers/skills.py @@ -40,7 +40,8 @@ _SKILL_HUB_SOURCE_LABELS = { "github": "GitHub", "clawhub": "ClawHub", "lobehub": "LobeHub", - "browse-sh": "browse.sh"} + "browse-sh": "browse.sh", +} def _hub_sources(profile: Optional[str]): @@ -78,12 +79,8 @@ def _flag(obj, attr: str) -> bool: def _skill_meta_to_payload(m) -> dict: return { - "name": m.name, - "description": m.description, - "source": m.source, - "identifier": m.identifier, - "trust_level": m.trust_level, - "repo": m.repo, + "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 [])} @@ -104,10 +101,8 @@ def _clear_skills_prompt_cache() -> None: async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None): identifier = require(body.identifier, "identifier is required") return spawn_profile_action( - body.profile or profile, - ["skills", "install", identifier, "--yes"], - _hub_action_name("install", identifier), - log_msg="Failed to spawn skills install", + body.profile or profile, ["skills", "install", identifier, "--yes"], + _hub_action_name("install", identifier), log_msg="Failed to spawn skills install", prefix="Failed to install skill") @@ -115,10 +110,8 @@ async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None): name = require(body.name, "name is required") return spawn_profile_action( - body.profile or profile, - ["skills", "uninstall", name, "--yes"], - _hub_action_name("uninstall", name), - log_msg="Failed to spawn skills uninstall", + body.profile or profile, ["skills", "uninstall", name, "--yes"], + _hub_action_name("uninstall", name), log_msg="Failed to spawn skills uninstall", prefix="Failed to uninstall skill") @@ -126,17 +119,13 @@ async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str async def update_skills_hub( body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None): return spawn_profile_action( - (body.profile if body else None) or profile, - ["skills", "update"], - "skills-update", - log_msg="Failed to spawn skills update", - prefix="Failed to update skills") + (body.profile if body else None) or profile, ["skills", "update"], "skills-update", + log_msg="Failed to spawn skills update", prefix="Failed to update skills") @hub_router.get("/api/skills/hub/official") async def list_official_skills(profile: Optional[str] = None): - """The ENTIRE built-in optional-skills catalog (local scan, no network), - each row marked installed-or-not for ``profile``.""" + """The ENTIRE optional-skills catalog (local scan), marked installed for ``profile``.""" def _run(): from tools.skills_hub import OptionalSkillSource @@ -160,9 +149,8 @@ async def list_official_skills(profile: Optional[str] = None): @hub_router.get("/api/skills/hub/sources") async def list_skills_hub_sources(profile: Optional[str] = None): - """Configured skill-hub sources + installed-skill provenance, so the - Browse-hub tab has something to show before a search runs. ``profile`` - scopes the installed-skill provenance.""" + """Configured skill-hub sources + installed-skill provenance (scoped to + ``profile``), so the Browse-hub tab has something before a search runs.""" def _run(): sources = _hub_sources(profile) @@ -190,9 +178,7 @@ async def list_skills_hub_sources(profile: Optional[str] = None): for entry in out: entry["searchable"] = not (index_available and entry["id"] in _API_SOURCE_IDS) return { - "sources": out, - "index_available": index_available, - "featured": featured, + "sources": out, "index_available": index_available, "featured": featured, "installed": _installed_hub_identifiers(profile)} with http_failure("skills hub sources listing failed", 502, "Hub sources failed"): @@ -202,8 +188,7 @@ async def list_skills_hub_sources(profile: Optional[str] = None): @hub_router.get("/api/skills/hub/search") async def search_skills_hub( q: str = "", source: str = "all", limit: int = 20, profile: Optional[str] = None): - """Search the skill hub across all configured sources (network-bound, - runs in a thread). Results install by identifier via /hub/install.""" + """Search the skill hub across all configured sources (network-bound).""" query = (q or "").strip() if not query: return {"results": [], "source_counts": {}, "timed_out": [], "installed": {}} @@ -220,15 +205,14 @@ async def search_skills_hub( _rank = {"builtin": 2, "trusted": 1, "community": 0} seen = {} for r in all_results: - if r.identifier not in seen or _rank.get(r.trust_level, 0) > _rank.get(seen[r.identifier].trust_level, 0): + prev = seen.get(r.identifier) + if prev is None or _rank.get(r.trust_level, 0) > _rank.get(prev.trust_level, 0): seen[r.identifier] = r deduped = list(seen.values())[:capped] return { - "results": [_skill_meta_to_payload(m) for m in deduped], - "source_counts": source_counts, - "timed_out": timed_out, - "installed": _installed_hub_identifiers(profile)} + "results": [_skill_meta_to_payload(m) for m in deduped], "source_counts": source_counts, + "timed_out": timed_out, "installed": _installed_hub_identifiers(profile)} with http_failure("skills hub search failed", 502, "Hub search failed"): return await asyncio.to_thread(_run) @@ -245,9 +229,8 @@ async def _hub_lookup(fn, ident: str, log_msg: str, prefix: str): @hub_router.get("/api/skills/hub/preview") async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None): - """A hub skill's SKILL.md + file manifest WITHOUT installing it. Scoped to - ``profile`` so a profile with different hub taps resolves against ITS - source router.""" + """A hub skill's SKILL.md + file manifest WITHOUT installing it; scoped to + ``profile`` so different hub taps resolve against THAT source router.""" ident = require(identifier, "identifier is required") def _run(): @@ -272,25 +255,21 @@ async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None) m = meta or bundle return { - "name": getattr(m, "name", ident), - "description": getattr(m, "description", "") or "", + "name": getattr(m, "name", ident), "description": getattr(m, "description", "") or "", "source": getattr(m, "source", "") or "", "identifier": getattr(m, "identifier", ident) or ident, "trust_level": getattr(m, "trust_level", "community") or "community", - "repo": getattr(m, "repo", None), - "tags": list(getattr(m, "tags", None) or []), - "skill_md": skill_md, - "files": sorted(files.keys())} + "repo": getattr(m, "repo", None), "tags": list(getattr(m, "tags", None) or []), + "skill_md": skill_md, "files": sorted(files.keys())} return await _hub_lookup(_run, ident, "skills hub preview failed", "Hub preview failed") @hub_router.get("/api/skills/hub/scan") async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None): - """Run the install-time security scan on a hub skill WITHOUT installing it - (same ``scan_skill`` / ``should_allow_install`` pipeline as the CLI, on a - quarantined bundle that is cleaned up afterwards). Scoped to ``profile`` - so the bundle resolves where an install would pull it from.""" + """Install-time security scan of a hub skill WITHOUT installing it (the CLI's + ``scan_skill`` / ``should_allow_install`` pipeline on a quarantined bundle); + scoped to ``profile`` so the bundle resolves where an install would.""" ident = require(identifier, "identifier is required") def _run(): @@ -306,12 +285,12 @@ async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None): if bundle.source == "official": scan_source = "official" else: - scan_source = getattr(bundle, "identifier", "") or getattr(meta, "identifier", "") or ident + scan_source = ( + getattr(bundle, "identifier", "") or getattr(meta, "identifier", "") or ident) - q_path = None tier1 = None + q_path = quarantine_bundle(bundle) try: - q_path = quarantine_bundle(bundle) result = scan_skill(q_path, source=scan_source) # Advisory SkillEvaluator Tier 1 second opinion: optional binary, # never blocks, errors degrade to no data (same as the CLI installer). @@ -325,29 +304,22 @@ async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None): "incomplete_checks": t1.incomplete_checks, "findings": [ { - "check": f.check, - "validator": f.validator, - "severity": f.severity, - "message": f.message, - "file": f.file, - "line": f.line, + "check": f.check, "validator": f.validator, + "severity": f.severity, "message": f.message, + "file": f.file, "line": f.line, "secrets_class": f.is_secrets_class} for f in t1.findings]} except Exception: _log.debug("Tier 1 advisory scan skipped", exc_info=True) finally: - if q_path is not None: - _shutil.rmtree(q_path, ignore_errors=True) + _shutil.rmtree(q_path, ignore_errors=True) # `allowed` may be None ("ask") for agent-created/dangerous gates. allowed, reason = should_allow_install(result, force=False) findings = [ { - "severity": f.severity, - "category": f.category, - "file": f.file, - "line": f.line, - "description": f.description} + "severity": f.severity, "category": f.category, "file": f.file, + "line": f.line, "description": f.description} for f in result.findings] counts = {sev: 0 for sev in ("critical", "high", "medium", "low")} for f in result.findings: @@ -443,9 +415,8 @@ async def get_skill_content(name: str, profile: Optional[str] = None): @router.post("/api/skills") async def create_skill(body: SkillCreate): - """Create a custom skill via the same validated write path as the agent's - ``skill_manage`` tool, minus the agent write-approval gate — a write from - the authenticated dashboard IS the user acting directly.""" + """Create a skill via the agent's ``skill_manage`` write path, minus the + write-approval gate — an authenticated dashboard write IS the user.""" from tools.skill_manager_tool import _create_skill result = await scoped_to_thread( diff --git a/hermes_cli/web_routers/tools.py b/hermes_cli/web_routers/tools.py index 037b0c4095..fb18a05af8 100644 --- a/hermes_cli/web_routers/tools.py +++ b/hermes_cli/web_routers/tools.py @@ -116,7 +116,8 @@ _BACKEND_PROBES = { "singularity": _probe_singularity_backend, "ssh": _probe_ssh_backend, "modal": _probe_modal_backend, - "daytona": _probe_daytona_backend} + "daytona": _probe_daytona_backend, +} def _probe_terminal_backend(name: str, terminal_cfg: dict) -> tuple: @@ -176,7 +177,8 @@ def _category_providers(ts_key: str, config: dict) -> list: return _visible_providers(cat, config, force_fresh=True) if cat else [] -def _find_toolset_provider_row(ts_key: str, config: dict, provider: Optional[str]) -> Optional[dict]: +def _find_toolset_provider_row( + ts_key: str, config: dict, provider: Optional[str]) -> Optional[dict]: """Resolve a provider picker row by name, or the active row when omitted.""" from hermes_cli.tools_config import _is_provider_active @@ -186,17 +188,12 @@ def _find_toolset_provider_row(ts_key: str, config: dict, provider: Optional[str return next((p for p in rows if _is_provider_active(p, config, force_fresh=True)), None) -def _terminal_backend_names() -> set: - """Valid ``terminal.backend`` values, including plugin backends.""" - return {row["name"] for row in _terminal_backend_rows()} - - def _require_known_toolset(name: str) -> None: """400 for toolset keys outside the effective configurable set.""" from hermes_cli.tools_config import _get_effective_configurable_toolsets if name not in {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}: - raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") + raise _bad_request(f"Unknown toolset: {name}") def _dict_section(config: dict, key: str) -> dict: @@ -208,35 +205,30 @@ def _dict_section(config: dict, key: str) -> dict: return section +def _bad_request(detail: str) -> HTTPException: + return HTTPException(status_code=400, detail=detail) + + def _no_models(name: str) -> dict: return {"name": name, "has_models": False, "models": [], "current": None, "default": None} -def _model_catalog_section(name: str) -> str: - section = _MODEL_CATALOG_TOOLSETS.get(name) - if section is None: - raise HTTPException(status_code=400, detail=f"Toolset has no model catalog: {name}") - return section - - @router.get("/api/tools/toolsets") async def get_toolsets(profile: Optional[str] = None): from hermes_cli.tools_config import ( - _CONFIG_ONLY_TOOLSETS, - _get_effective_configurable_toolsets, - _get_platform_tools, - _toolset_configuration_platform, - _toolset_has_keys, - get_nous_subscription_features, + _CONFIG_ONLY_TOOLSETS, _get_effective_configurable_toolsets, _get_platform_tools, + _toolset_configuration_platform, _toolset_has_keys, get_nous_subscription_features, gui_toolset_label) from hermes_cli.platforms import platform_label from toolsets import resolve_toolset + from utils import is_truthy_value def _read(): with _profile_scope(profile): config = load_config() toolset_rows = _get_effective_configurable_toolsets() - target_platforms = {_toolset_configuration_platform(name) for name, _, _ in toolset_rows} + target_platforms = { + _toolset_configuration_platform(name) for name, _, _ in toolset_rows} enabled_by_platform = { platform: _get_platform_tools(config, platform, include_default_mcp_servers=False) for platform in target_platforms} @@ -254,23 +246,17 @@ async def get_toolsets(profile: Optional[str] = None): if name in _CONFIG_ONLY_TOOLSETS: # Config-only capabilities (stt) have no per-platform toolset — # their switch is their own config section (e.g. stt.enabled). - from utils import is_truthy_value - section = config.get(name) section = section if isinstance(section, dict) else {} is_enabled = is_truthy_value(section.get("enabled", True), default=True) else: is_enabled = name in enabled_by_platform[target_platform] result.append({ - "name": name, - "label": gui_toolset_label(label), - "description": desc, + "name": name, "label": gui_toolset_label(label), "description": desc, "platform": target_platform, "platform_label": gui_toolset_label(platform_label(target_platform, target_platform)), - "enabled": is_enabled, - "available": is_enabled, - "configured": _toolset_has_keys(name, config, features=features), - "tools": tools}) + "enabled": is_enabled, "available": is_enabled, + "configured": _toolset_has_keys(name, config, features=features), "tools": tools}) return result @@ -280,9 +266,7 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] (``platform_toolsets.cli`` for most; platform-restricted toolsets target their own platform) via the same ``_save_platform_tools`` the CLI uses.""" from hermes_cli.tools_config import ( - _CONFIG_ONLY_TOOLSETS, - _get_platform_tools, - _save_platform_tools, + _CONFIG_ONLY_TOOLSETS, _get_platform_tools, _save_platform_tools, _toolset_configuration_platform) _require_known_toolset(name) @@ -298,7 +282,8 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] _dict_section(config, name)["enabled"] = bool(body.enabled) save_config(config) return - enabled = set(_get_platform_tools(config, target_platform, include_default_mcp_servers=False)) + enabled = set( + _get_platform_tools(config, target_platform, include_default_mcp_servers=False)) if body.enabled: enabled.add(name) else: @@ -307,11 +292,10 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] await asyncio.to_thread(_run) - # Install-on-enable: a provider with a post_setup hook whose install-state - # predicate is UNSATISFIED (cua-driver binary missing, etc.) gets the same - # background install `hermes tools` runs interactively — otherwise the - # toggle "saves" but the tool never appears because its check_fn can't - # find the binary. Best-effort: a spawn failure never fails the toggle. + # Install-on-enable: a provider whose post_setup install predicate is + # UNSATISFIED (cua-driver binary missing, etc.) gets the same background + # install `hermes tools` runs — otherwise the toggle "saves" but the tool + # never appears. Best-effort: a spawn failure never fails the toggle. post_setup_started: Optional[str] = None if body.enabled and name not in _CONFIG_ONLY_TOOLSETS: def _pending_install_key() -> Optional[str]: @@ -323,11 +307,8 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] return None with _profile_scope(scope_profile): config = load_config() - for prov in _visible_providers(cat, config): - key = prov.get("post_setup") - if key and not _post_setup_already_installed(key): - return key - return None + keys = [prov.get("post_setup") for prov in _visible_providers(cat, config)] + return next((k for k in keys if k and not _post_setup_already_installed(k)), None) try: pending_key = await asyncio.to_thread(_pending_install_key) @@ -340,24 +321,16 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] _log.exception("install-on-enable post-setup spawn failed for %s", name) return { - "ok": True, - "name": name, - "platform": target_platform, - "enabled": body.enabled, + "ok": True, "name": name, "platform": target_platform, "enabled": body.enabled, "post_setup_started": post_setup_started} @router.get("/api/tools/toolsets/{name}/config") async def get_toolset_config(name: str, profile: Optional[str] = None): - """Provider matrix + key status for a toolset's config panel: the same rows - the CLI ``hermes tools`` picker shows, each env var annotated with - ``is_set``. No ``TOOL_CATEGORIES`` entry -> empty list, ``has_category: - false``.""" + """Provider matrix + key status for a toolset's config panel (the CLI picker + rows, each env var annotated ``is_set``); no category -> ``has_category: false``.""" from hermes_cli.tools_config import ( - TOOL_CATEGORIES, - _is_provider_active, - _visible_providers, - provider_readiness_status, + TOOL_CATEGORIES, _is_provider_active, _visible_providers, provider_readiness_status, web_provider_capabilities) from hermes_cli.config import get_env_value from hermes_cli.nous_subscription import get_nous_subscription_features @@ -370,18 +343,14 @@ async def get_toolset_config(name: str, profile: Optional[str] = None): cat = TOOL_CATEGORIES.get(name) providers = [] active_provider = None - active_search_backend = None - active_extract_backend = None if cat: # Entitlement state fetched once for the whole matrix. features = get_nous_subscription_features(config, force_fresh=True) for prov in _visible_providers(cat, config, force_fresh=True): env_vars = [ { - "key": e["key"], - "prompt": e.get("prompt", e["key"]), - "url": e.get("url"), - "default": e.get("default"), + "key": e["key"], "prompt": e.get("prompt", e["key"]), + "url": e.get("url"), "default": e.get("default"), "is_set": bool(get_env_value(e["key"]))} for e in prov.get("env_vars", [])] # Same active-provider determination as the CLI picker, so the @@ -412,30 +381,24 @@ async def get_toolset_config(name: str, profile: Optional[str] = None): # (tts..*) holding the provider's voice/model settings. row["tts_provider"] = prov["tts_provider"] providers.append(row) + payload = { + "name": name, "has_category": cat is not None, "providers": providers, + "active_provider": active_provider} if name == "web": # Resolve active backends exactly as the web_search/web_extract # dispatchers do, so badges reflect what a call would hit now. try: from tools.web_tools import _get_extract_backend, _get_search_backend - active_search_backend = _get_search_backend() - active_extract_backend = _get_extract_backend() + search_backend = _get_search_backend() + extract_backend = _get_extract_backend() except Exception: - active_search_backend = None - active_extract_backend = None - return cat, providers, active_provider, active_search_backend, active_extract_backend + search_backend = extract_backend = None + payload["active_search_backend"] = search_backend + payload["active_extract_backend"] = extract_backend + return payload - cat, providers, active_provider, active_search_backend, active_extract_backend = await asyncio.to_thread(_read) - - payload = { - "name": name, - "has_category": cat is not None, - "providers": providers, - "active_provider": active_provider} - if name == "web": - payload["active_search_backend"] = active_search_backend - payload["active_extract_backend"] = active_extract_backend - return payload + return await asyncio.to_thread(_read) @router.get("/api/tools/toolsets/{name}/models") @@ -465,29 +428,17 @@ async def get_toolset_models( current = raw.strip() if current not in catalog: current = default_model if default_model in catalog else None - return row, plugin, catalog, default_model, current + models = [ + { + "id": model_id, "display": meta.get("display", model_id), + "speed": meta.get("speed", ""), "strengths": meta.get("strengths", ""), + "price": meta.get("price", "")} + for model_id, meta in catalog.items()] + return { + "name": name, "has_models": bool(models), "provider": row.get("name"), + "plugin": plugin, "models": models, "current": current, "default": default_model} - resolved = await asyncio.to_thread(_read) - if resolved is None: - return _no_models(name) - row, plugin, catalog, default_model, current = resolved - - models = [ - { - "id": model_id, - "display": meta.get("display", model_id), - "speed": meta.get("speed", ""), - "strengths": meta.get("strengths", ""), - "price": meta.get("price", "")} - for model_id, meta in catalog.items()] - return { - "name": name, - "has_models": bool(models), - "provider": row.get("name") if row else None, - "plugin": plugin, - "models": models, - "current": current, - "default": default_model} + return await asyncio.to_thread(_read) or _no_models(name) @router.put("/api/tools/toolsets/{name}/model") @@ -495,10 +446,12 @@ async def select_toolset_model( name: str, body: ToolsetModelSelect, profile: Optional[str] = None): """Persist a backend model selection (``image_gen.model`` / ``video_gen.model``), validated against the resolved backend's catalog.""" - section = _model_catalog_section(name) + section = _MODEL_CATALOG_TOOLSETS.get(name) + if section is None: + raise _bad_request(f"Toolset has no model catalog: {name}") model_id = (body.model or "").strip() if not model_id: - raise HTTPException(status_code=400, detail="model is required") + raise _bad_request("model is required") def _run(): with config_write_scope(body.profile or profile): @@ -506,13 +459,11 @@ async def select_toolset_model( row = _find_toolset_provider_row(name, config, body.provider) plugin = _resolve_toolset_model_plugin(name, row) if row else None if not plugin: - raise HTTPException( - status_code=400, detail=f"No model-capable backend is active for {name}") + raise _bad_request(f"No model-capable backend is active for {name}") catalog, _default = _toolset_model_catalog(name, plugin) if model_id not in catalog: - raise HTTPException( - status_code=400, detail=f"Unknown model {model_id!r} for backend {plugin!r}") + raise _bad_request(f"Unknown model {model_id!r} for backend {plugin!r}") _dict_section(config, section)["model"] = model_id save_config(config) @@ -525,19 +476,14 @@ async def select_toolset_model( @router.put("/api/tools/toolsets/{name}/provider") async def select_toolset_provider( name: str, body: ToolsetProviderSelect, profile: Optional[str] = None): - """Persist a provider selection via ``apply_provider_selection`` (the - non-interactive core shared with ``hermes tools``, so both write identical - config keys). Keys and post-setup are separate endpoints. + """Persist a provider selection via ``apply_provider_selection`` (shared with + ``hermes tools``, so both write identical keys). - ``web`` only: ``capability`` ('search' | 'extract') scopes the write to - ``web._backend`` (the per-capability override the runtime - dispatchers resolve first); the provider must support that capability. - Omitted -> legacy whole-provider write of ``web.backend``. - - Managed Nous rows report Portal entitlement: the GUI has no inline login, - so an unentitled selection would write config and never activate. The - response adds ``needs_nous_auth: true`` + ``feature`` so the client can - drive the Nous Portal OAuth flow and refetch. + ``web`` only: ``capability`` ('search' | 'extract') writes + ``web._backend`` (the override the dispatchers resolve first); + omitted -> legacy ``web.backend``. Managed Nous rows report Portal + entitlement (``needs_nous_auth`` + ``feature``): the GUI has no inline + login, so an unentitled selection would write config and never activate. """ from hermes_cli.tools_config import apply_provider_selection, web_provider_capabilities from hermes_cli.nous_subscription import ( @@ -547,13 +493,10 @@ async def select_toolset_provider( if body.capability is not None: if name != "web": - raise HTTPException( - status_code=400, detail="capability selection is only supported for the web toolset" - ) + raise _bad_request("capability selection is only supported for the web toolset") if body.capability not in ("search", "extract"): - raise HTTPException( - status_code=400, - detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')") + raise _bad_request( + f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')") def _provider_row(config): return next( @@ -569,24 +512,19 @@ async def select_toolset_provider( # resolving through the shared fallback chain. prov = _provider_row(config) if prov is None: - raise HTTPException( - status_code=400, - detail=f"Unknown provider {body.provider!r} for toolset {name!r}") + raise _bad_request( + f"Unknown provider {body.provider!r} for toolset {name!r}") backend = prov.get("web_backend") if not backend: - raise HTTPException( - status_code=400, - detail=f"Provider {body.provider!r} has no web backend key") + raise _bad_request(f"Provider {body.provider!r} has no web backend key") if body.capability not in web_provider_capabilities(backend): - raise HTTPException( - status_code=400, - detail=f"{body.provider} does not support {body.capability}") + raise _bad_request(f"{body.provider} does not support {body.capability}") _dict_section(config, "web")[f"{body.capability}_backend"] = backend else: try: apply_provider_selection(name, body.provider, config) except KeyError as exc: - raise HTTPException(status_code=400, detail=str(exc).strip('"')) + raise _bad_request(str(exc).strip('"')) save_config(config) response: Dict[str, Any] = {"ok": True, "name": name, "provider": body.provider} if body.capability is not None: @@ -635,9 +573,8 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[ unknown = [k for k in body.env if k not in allowed] if unknown: - raise HTTPException( - status_code=400, - detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}") + raise _bad_request( + f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}") saved: List[str] = [] skipped: List[str] = [] @@ -646,7 +583,7 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[ try: save_env_value(key, value.strip()) except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) + raise _bad_request(str(exc)) saved.append(key) else: skipped.append(key) @@ -661,23 +598,18 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[ @router.post("/api/tools/toolsets/{name}/post-setup") async def run_toolset_post_setup( name: str, body: ToolsetPostSetup, profile: Optional[str] = None): - """Spawn ``hermes tools post-setup `` (long-running installs: npm, - pip, cua-driver fetch) as a background action the frontend tails via - ``GET /api/actions/tools-post-setup/status``. The key is validated - against the declared allowlist; ``profile`` is threaded so hooks that - touch config see the same HERMES_HOME as the drawer's other writes.""" + """Spawn ``hermes tools post-setup `` (long-running installs) as a + background action tailed via ``GET /api/actions/tools-post-setup/status``; + ``profile`` is threaded so hooks see the drawer's HERMES_HOME.""" from hermes_cli.tools_config import valid_post_setup_keys _require_known_toolset(name) if body.key not in valid_post_setup_keys(): - raise HTTPException(status_code=400, detail=f"Unknown post-setup key: {body.key}") + raise _bad_request(f"Unknown post-setup key: {body.key}") result = spawn_profile_action( - body.profile or profile, - ["tools", "post-setup", body.key], - "tools-post-setup", - log_msg="Failed to spawn tools post-setup", - prefix="Failed to run post-setup") + body.profile or profile, ["tools", "post-setup", body.key], "tools-post-setup", + log_msg="Failed to spawn tools post-setup", prefix="Failed to run post-setup") result["key"] = body.key return result @@ -702,12 +634,8 @@ async def get_terminal_backends(profile: Optional[str] = None): for row in rows: status, detail = _probe_terminal_backend(row["name"], terminal_cfg) backends.append({ - "name": row["name"], - "label": row["label"], - "description": row["description"], - "active": row["name"] == active, - "status": status, - "detail": detail}) + "name": row["name"], "label": row["label"], "description": row["description"], + "active": row["name"] == active, "status": status, "detail": detail}) return {"active": active, "backends": backends} return await asyncio.to_thread(_read) @@ -719,11 +647,10 @@ async def select_terminal_backend( """Persist ``terminal.backend``. A backend that still needs setup is allowed — the picker shows guidance instead of blocking, like the CLI.""" backend = (body.backend or "").strip().lower() - valid_names = _terminal_backend_names() + valid_names = {row["name"] for row in _terminal_backend_rows()} if backend not in valid_names: - raise HTTPException( - status_code=400, - detail=f"Unknown terminal backend: {body.backend!r}. " + raise _bad_request( + f"Unknown terminal backend: {body.backend!r}. " f"Use one of: {', '.join(sorted(valid_names))}") def _run(): @@ -751,11 +678,8 @@ async def grant_computer_use_permissions(profile: Optional[str] = None): CuaDriver via LaunchServices so the TCC dialog is attributed correctly). The frontend polls ``GET /api/actions/computer-use-grant/status``.""" if sys.platform != "darwin": - raise HTTPException( - status_code=400, detail="Computer Use permission grants are a macOS concept.") + raise _bad_request("Computer Use permission grants are a macOS concept.") return spawn_profile_action( - profile, - ["computer-use", "permissions", "grant"], - "computer-use-grant", + profile, ["computer-use", "permissions", "grant"], "computer-use-grant", log_msg="Failed to spawn computer-use permissions grant", prefix="Failed to request permissions") diff --git a/hermes_cli/web_server_chat.py b/hermes_cli/web_server_chat.py index c5e51f67d1..ae64dab3b5 100644 --- a/hermes_cli/web_server_chat.py +++ b/hermes_cli/web_server_chat.py @@ -10,6 +10,7 @@ import logging import asyncio import atexit import concurrent.futures +import contextlib import hmac import os import re @@ -26,35 +27,21 @@ from hermes_cli.pty_session import PtySessionRegistry _log = logging.getLogger("hermes_cli.web_server") -# /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab: spawns the -# same ``hermes --tui`` binary the CLI uses behind a pseudo-terminal and forwards -# bytes + resize escapes; the browser renders the ANSI through xterm.js. -# Auth: ``?token=`` query param (browsers can't set Authorization -# on the WS upgrade), same ephemeral ``_SESSION_TOKEN`` as REST. - -# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows -# uses win_pty_bridge (pywinpty/ConPTY). Both expose the same surface — -# spawn/read/write/resize/close/is_available — so the handler needs no guards. -if sys.platform.startswith("win"): - try: +# /api/pty spawns ``hermes --tui`` behind a pseudo-terminal and forwards bytes + +# resize escapes to xterm.js. POSIX uses pty_bridge (fcntl/termios); native +# Windows uses win_pty_bridge (pywinpty/ConPTY); same surface, no handler guards. +try: + if sys.platform.startswith("win"): from hermes_cli.win_pty_bridge import WinPtyBridge as PtyBridge, PtyUnavailableError - _PTY_BRIDGE_AVAILABLE = True - except ImportError: # pragma: no cover - pywinpty missing - PtyBridge = None # type: ignore[assignment] - _PTY_BRIDGE_AVAILABLE = False - - class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] - """Stub when win_pty_bridge cannot be imported.""" -else: - try: + else: from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError - _PTY_BRIDGE_AVAILABLE = True - except ImportError: # pragma: no cover - dev env without ptyprocess - PtyBridge = None # type: ignore[assignment] - _PTY_BRIDGE_AVAILABLE = False + _PTY_BRIDGE_AVAILABLE = True +except ImportError: # pragma: no cover - pywinpty / ptyprocess missing + PtyBridge = None # type: ignore[assignment] + _PTY_BRIDGE_AVAILABLE = False - class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] - """Stub on platforms where pty_bridge can't be imported.""" + class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] + """Stub when the platform PTY bridge cannot be imported.""" _RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]") _PTY_READ_CHUNK_TIMEOUT = 0.2 @@ -84,21 +71,15 @@ async def _legacy_pump(ws: "WebSocket", bridge) -> None: except Exception: return finally: - # Child exited (EOF) or the send side broke. Close the WebSocket so - # the writer loop's ``ws.receive()`` returns instead of blocking - # forever on a half-open browser socket (no FIN, common on - # macOS/launchd) — otherwise the PTY's fds leak and auto-reconnect - # stacks a fresh PTY on each orphan. Reap the bridge here too - # (close() is idempotent): if the handler task is cancelled the - # instant we close the WS, the writer's ``finally`` can be skipped. - try: + # Close the WS so the writer's ``ws.receive()`` returns instead of + # blocking forever on a half-open browser socket (fds would leak and + # auto-reconnect stacks a fresh PTY on each orphan). Reap the bridge + # here too (idempotent): cancelling the handler the instant the WS + # closes can skip the writer's ``finally``. + with contextlib.suppress(Exception): await asyncio.to_thread(bridge.close) - except Exception: - pass - try: + with contextlib.suppress(Exception): await ws.close() - except Exception: - pass reader_task = asyncio.create_task(pump_pty_to_ws()) @@ -128,10 +109,8 @@ async def _legacy_pump(ws: "WebSocket", bridge) -> None: pass finally: reader_task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError, Exception): await reader_task - except (asyncio.CancelledError, Exception): - pass await asyncio.to_thread(bridge.close) @@ -143,14 +122,11 @@ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) def _ws_client_reason(ws: "WebSocket") -> Optional[str]: """Return a rejection reason token for the peer IP, or None when allowed. - Loopback bind: only loopback clients — the legacy ``?token=`` is the only - auth, so LAN hosts must not get to guess it. Explicit non-loopback bind - (``--host 0.0.0.0``/``::``/LAN IP, always with ``--insecure``): any peer; - DNS-rebinding is still blocked by :func:`_ws_host_origin_reason`. Gated - mode: any peer — ``proxy_headers=True`` rewrites ``ws.client.host`` to the - X-Forwarded-For value and the OAuth gate + ``?ticket=`` is the auth. - An empty peer on a loopback bind fails closed (misconfigured proxy / unix - socket must not reach a loopback-only surface). + Loopback bind: only loopback peers (the legacy ``?token=`` is the only auth, + LAN hosts must not get to guess it); an empty peer fails closed. Explicit + non-loopback bind (``--insecure``) or gated mode: any peer — DNS-rebinding is + blocked by :func:`_ws_host_origin_reason`, and in gated mode + ``ws.client.host`` is the X-Forwarded-For value anyway. """ from hermes_cli.web_server import app if getattr(app.state, "auth_required", False): @@ -172,34 +148,27 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool: def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]: - """Return a Host/Origin rejection reason (``host_mismatch …`` / - ``origin_mismatch …``), or None when allowed. + """Return ``host_mismatch …`` / ``origin_mismatch …``, or None when allowed. HTTP middleware does not run for WebSocket routes, so the DNS-rebinding - Host check is repeated here before accepting the upgrade; a browser Origin - header, when present, must target the same bound host. Non-web origins - (packaged Electron: file://, null, app://) are trusted — the upstream - credential check is the real auth boundary there. + Host check is repeated here; an Origin header, when present, must target the + bound host. Non-web origins (packaged Electron: file://, null, app://) are + trusted — the credential check is the real auth boundary there. """ from hermes_cli.web_server import _is_accepted_host, app bound_host = getattr(app.state, "bound_host", None) if not bound_host: return None - trusted_public_hosts = getattr(app.state, "trusted_public_hosts", frozenset()) - host_header = ws.headers.get("host", "") if not _is_accepted_host(host_header, bound_host, trusted_public_hosts): return f"host_mismatch host={host_header or '?'} bound={bound_host}" - origin = ws.headers.get("origin", "") if not origin: return None - parsed = urllib.parse.urlparse(origin) if parsed.scheme not in {"http", "https"}: return None - if not parsed.netloc or not _is_accepted_host(parsed.netloc, bound_host, trusted_public_hosts): return f"origin_mismatch origin={origin} bound={bound_host}" return None @@ -237,19 +206,15 @@ def _gateway_ws_ticket_from_subprotocol(ws: "WebSocket") -> tuple[str, str]: def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]: """Validate WS-upgrade auth; return ``(reason, credential)``. - ``reason`` is None when accepted, else ``no_credential`` / ``token_mismatch`` - / ``ticket_invalid`` / ``internal_invalid``; ``credential`` names what was - presented (``ticket``, ``ticket-subprotocol``, ``internal``, ``token``, - ``none``) so the accept path can log *how* a peer authed. + ``reason`` is None when accepted, else a short token (``no_credential``, + ``token_mismatch``, ``ticket_invalid``, ``internal_invalid``); + ``credential`` names what was presented so the accept path can log *how*. - Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>``, constant-time - compared. Gated: ``?ticket=`` (browser-minted, single-use, 30s TTL) or - ``?internal=`` (process-lifetime credential used only by WS clients the - server spawns itself — multi-use so the PTY child can reconnect; never - injected into the SPA, see ``dashboard_auth.ws_tickets``). The legacy - token is unconditionally rejected in gated mode: a leaked ``_SESSION_TOKEN`` - must not grant WS access once the gate is engaged. Rejections are - audit-logged so "WS keeps closing" can be debugged from the log. + Loopback / ``--insecure``: legacy ``?token=`` (constant-time compared). + Gated: ``?ticket=`` (browser-minted, single-use, 30s TTL) or ``?internal=`` + (process-lifetime, multi-use, only for server-spawned WS clients so the PTY + child can reconnect; never injected into the SPA). The legacy token is + rejected in gated mode: a leaked ``_SESSION_TOKEN`` must not grant access. """ from hermes_cli.web_server import _SESSION_TOKEN, app auth_required = bool(getattr(app.state, "auth_required", False)) @@ -262,10 +227,8 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]: def _reject(reason: str) -> None: audit_log( - AuditEvent.WS_TICKET_REJECTED, - reason=reason, - ip=(ws.client.host if ws.client else ""), - path=ws.url.path) + AuditEvent.WS_TICKET_REJECTED, reason=reason, + ip=(ws.client.host if ws.client else ""), path=ws.url.path) def _stamp_identity(info) -> None: # Server-minted {user_id, provider} stamped onto the WS object is the @@ -320,32 +283,20 @@ def _ws_auth_ok(ws: "WebSocket") -> bool: def _resolve_chat_argv( - resume: Optional[str] = None, - sidecar_url: Optional[str] = None, - profile: Optional[str] = None, + resume: Optional[str] = None, sidecar_url: Optional[str] = None, profile: Optional[str] = None, active_session_file: Optional[str] = None) -> tuple[list[str], Optional[str], Optional[dict]]: """Resolve the argv + cwd + env for the chat PTY (what ``hermes --tui`` runs). - Tests monkeypatch this to inject a tiny fake command so nothing has to - build the TUI bundle. Env contract with the child: - - * ``HERMES_TUI_RESUME`` — session resume (``ui-tui`` does not parse argv, so - ``--resume`` cannot be appended); resolved to the newest descendant first. - * ``HERMES_TUI_GATEWAY_URL`` — attach to this process's in-memory - ``tui_gateway`` instead of spawning a Python gateway subprocess. SKIPPED - for profile-scoped chats: the dashboard's gateway runs under the - dashboard's own profile, so a scoped chat must spawn its own. - * ``HERMES_TUI_SIDECAR_URL`` — mirror dispatcher emits to ``/api/pub``. - * ``HERMES_TUI_ACTIVE_SESSION_FILE`` — the TUI writes its current session id - there, a cross-process breadcrumb for reconnecting after a WS drop. - * ``profile`` scopes the ENTIRE chat by pointing ``HERMES_HOME`` at the - profile dir; every spawned process resolves ``get_hermes_home()`` from - that at import, the same propagation ``hermes -p `` performs. + Tests monkeypatch this with a tiny fake command. Env contract: resume goes + through ``HERMES_TUI_RESUME`` (``ui-tui`` does not parse argv), resolved to + the newest descendant; ``HERMES_TUI_GATEWAY_URL`` attaches to this process's + in-memory gateway but is SKIPPED for profile-scoped chats (that gateway runs + under the dashboard's own profile, so a scoped chat spawns its own); + ``profile`` scopes the ENTIRE chat by pointing ``HERMES_HOME`` at the profile + dir, the same propagation ``hermes -p `` performs. """ from hermes_cli.web_server import ( - _config_profile_scope, - _open_session_db_for_profile, - _resolve_profile_dir, + _config_profile_scope, _open_session_db_for_profile, _resolve_profile_dir, _session_latest_descendant) from hermes_cli.main import PROJECT_ROOT, _apply_tui_python_env, _make_tui_argv @@ -355,9 +306,8 @@ def _resolve_chat_argv( profile_dir = _resolve_profile_dir(requested) argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False) - # Build via the single spawn-env factory (profile-home contract applied; - # secrets kept — the spawned agent needs provider creds). An explicit - # profile scope overrides HERMES_HOME before config is bridged into the env. + # Secrets kept — the spawned agent needs provider creds. An explicit profile + # scope overrides HERMES_HOME before config is bridged into the env. from tools.environments.local import build_subprocess_env env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True) if profile_dir is not None: @@ -367,10 +317,8 @@ def _resolve_chat_argv( apply_terminal_config_to_env, read_raw_config, terminal_config_owned_env_vars) if profile_dir is not None: - # The dashboard already bridged its own terminal config into - # os.environ at startup. Remove only keys explicitly owned by that - # launch profile before applying the selected profile; operator - # exports for keys the launch profile omits remain valid fallbacks. + # Drop only the terminal keys the launch profile owns before applying + # the selected profile; operator exports for other keys stay valid. raw_launch_terminal = read_raw_config().get("terminal") for env_var in terminal_config_owned_env_vars(raw_launch_terminal): env.pop(env_var, None) @@ -386,10 +334,8 @@ def _resolve_chat_argv( # transcript scrolling; disable it for the dashboard PTY only. env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1") env.setdefault("HERMES_TUI_INLINE", "1") - # xterm.js always renders 24-bit RGB, but chalk in the child picks its - # depth from the SERVER env — hosted deploys under a process manager have - # no COLORTERM, so hex colors snap to the 256 palette (bronze -> salmon). - # Backfill; setdefault so an explicit operator value still wins. + # chalk in the child picks its color depth from the SERVER env; hosted + # deploys have no COLORTERM, so hex colors would snap to the 256 palette. env.setdefault("COLORTERM", "truecolor") env["HERMES_TUI_DASHBOARD"] = "1" @@ -418,61 +364,44 @@ def _resolve_chat_argv( return list(argv), str(cwd) if cwd else None, env -# Hosts that mean "listen on every interface" — bind to them, but an -# in-container client must NOT dial them: 0.0.0.0 routes through the wildcard -# stack and behind a forward proxy (HTTPS_PROXY without 0.0.0.0 in NO_PROXY) -# gets MITM'd into a failed handshake. Clients dial loopback instead. +# Wildcard bind hosts an in-container client must NOT dial: behind a forward +# proxy (HTTPS_PROXY without 0.0.0.0 in NO_PROXY) the handshake gets MITM'd. _WILDCARD_HOSTS = frozenset({"0.0.0.0", "::"}) def _resolve_client_ws_host() -> Optional[str]: - """Return the host the in-container WS client should dial. - - ``HERMES_DASHBOARD_WS_HOST`` wins always (operators behind a forward proxy - pin a routable host); a wildcard bind becomes ``127.0.0.1`` (dashboard and - TUI child share the container); any other bind host is preserved verbatim. - """ + """Host the in-container WS client should dial: ``HERMES_DASHBOARD_WS_HOST`` + wins always; a wildcard bind becomes ``127.0.0.1``; others verbatim.""" from hermes_cli.web_server import app explicit = os.environ.get("HERMES_DASHBOARD_WS_HOST", "").strip() if explicit: return explicit - host = getattr(app.state, "bound_host", None) if not host: return None - - if host in _WILDCARD_HOSTS: - return "127.0.0.1" - - return host + return "127.0.0.1" if host in _WILDCARD_HOSTS else host def _server_internal_ws_url(path: str, **extra_qs) -> Optional[str]: - """``ws://:?&`` for server-spawned WS - clients, or None when unbound. + """``ws://:?&`` for server-spawned WS clients, + or None when unbound. - Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``. Gated: the legacy - token is rejected by ``_ws_auth_ok``, so the PTY child authenticates with - the process-lifetime internal credential (``?internal=``) — NOT a single-use + Gated mode uses the process-lifetime internal credential, NOT a single-use browser ticket: the child reads the URL once and reuses it on every reconnect, and a 30s-TTL ticket can expire before a slow cold boot dials. """ from hermes_cli.web_server import _SESSION_TOKEN, app host = _resolve_client_ws_host() port = getattr(app.state, "bound_port", None) - if not host or not port: return None - netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" - if getattr(app.state, "auth_required", False): from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential auth = {"internal": internal_ws_credential()} else: auth = {"token": _SESSION_TOKEN} - return f"ws://{netloc}{path}?{urllib.parse.urlencode({**auth, **extra_qs})}" @@ -487,17 +416,10 @@ def _build_sidecar_url(channel: str) -> Optional[str]: async def _resolve_chat_argv_async( - resume: Optional[str] = None, - sidecar_url: Optional[str] = None, - profile: Optional[str] = None, + resume: Optional[str] = None, sidecar_url: Optional[str] = None, profile: Optional[str] = None, active_session_file: Optional[str] = None) -> tuple[list[str], Optional[str], Optional[dict]]: - """Resolve chat argv without blocking the dashboard event loop. - - ``_resolve_chat_argv`` may run ``npm install`` / ``npm run build``; keep - that off the WebSocket loop so keepalives keep flowing. The async lock - preserves one-build-at-a-time when several tabs connect at once without - occupying worker threads while queued connections wait. - """ + """Resolve chat argv off the event loop (it may run ``npm run build``); the + async lock keeps one-build-at-a-time without parking worker threads.""" from hermes_cli.web_server import _get_chat_argv_lock, _resolve_chat_argv, app kwargs = {"resume": resume, "sidecar_url": sidecar_url, "profile": profile} if active_session_file is not None: @@ -511,21 +433,15 @@ def _active_session_file_for_channel(app: "FastAPI", channel: str) -> Path: """Return the per-channel file where a dashboard TUI writes its active sid.""" from hermes_cli.web_server import _get_pty_active_session_files files = _get_pty_active_session_files(app) - existing = files.get(channel) - if existing is not None: - return existing - - fd, raw_path = tempfile.mkstemp(prefix="hermes-pty-active-", suffix=".json") - os.close(fd) - path = Path(raw_path) - files[channel] = path - return path + if files.get(channel) is None: + fd, raw_path = tempfile.mkstemp(prefix="hermes-pty-active-", suffix=".json") + os.close(fd) + files[channel] = Path(raw_path) + return files[channel] -# Console commands run in a worker thread; on timeout asyncio cancels the -# awaitable but the thread keeps running, so a stuck worker would exhaust the -# shared default pool. A small dedicated pool caps the leak and bounds -# concurrent console execution regardless of reconnects. +# On timeout asyncio cancels the awaitable but the console thread keeps running; +# a small dedicated pool caps the leak instead of exhausting the default pool. _CONSOLE_EXECUTOR_MAX_WORKERS = 4 _console_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None _console_executor_lock = threading.Lock() diff --git a/hermes_cli/web_server_sessions.py b/hermes_cli/web_server_sessions.py index 6aa6cedd91..d34e3beb86 100644 --- a/hermes_cli/web_server_sessions.py +++ b/hermes_cli/web_server_sessions.py @@ -1,9 +1,9 @@ """Session-DB access for the dashboard: per-profile SessionDB opening with schema heal, latest-descendant lookup and the auto-archive ticker. -Split out of ``hermes_cli.web_server``; every externally used name is re-imported -there, so ``web_server.`` keeps resolving (and monkeypatching) as before. -Helpers that tests patch on ``web_server`` are reached lazily through it. +Split out of ``hermes_cli.web_server``, which re-imports every external name so +``web_server.`` keeps resolving (and monkeypatching); helpers tests patch +there are reached lazily through it. """ import logging @@ -79,13 +79,9 @@ _session_db_bootstrap_lock = threading.Lock() def _session_db_read_probe_statements() -> tuple: - """Stale-schema probes for read-only opens, derived from SCHEMA_SQL. - - Read-only opens skip _reconcile_columns(), so an older store would 500 on - every poll until something opened it writable. Deriving the probe from the - schema means any new column is probed automatically (a hand-written list - went stale once and left the sidebar empty after `hermes update`). - """ + """Stale-schema probes for read-only opens (which skip _reconcile_columns()). + Derived from SCHEMA_SQL so a new column is probed automatically — a + hand-written list once went stale and emptied the sidebar after update.""" from hermes_state_schema import schema_read_probe_statements return schema_read_probe_statements() @@ -110,14 +106,9 @@ def _is_stale_schema_error(exc: BaseException) -> bool: def _open_session_db_at_path(db_path: Path, *, read_only: bool): """Open a SessionDB at an explicit path with an explicit access mode. - Writable opens keep the full init and repair path. Read-only opens bootstrap - a missing or zero-byte store once, and heal an older or malformed schema - through one writable open before reopening read-only; the healthy read path - never takes a write lock or requests a checkpoint. - - The probe covers every table/column in SCHEMA_SQL, so ANY schema addition - escalates a stale store to a one-time writable open (the same reconcile the - store's backend runs at startup). Tables created outside SCHEMA_SQL + Read-only opens bootstrap a missing/zero-byte store once and heal a stale or + malformed schema through ONE writable open before reopening read-only; the + healthy read path never takes a write lock. Tables outside SCHEMA_SQL (telemetry ``tel_*``, FTS shadow tables) are outside both probe and heal. """ from hermes_cli.web_server import ( @@ -213,12 +204,9 @@ _last_auto_archive_check: Dict[str, float] = {} def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None: - """Run the config-gated stale-session auto-archive for ``profile``. Never raises. - - The Desktop backend (``hermes serve``) runs neither the CLI nor the gateway - startup hooks, so the session-list path is what makes - ``sessions.auto_archive`` take effect there. - """ + """Config-gated stale-session auto-archive for ``profile``; never raises. + ``hermes serve`` runs neither CLI nor gateway startup hooks, so this + session-list trigger is what makes ``sessions.auto_archive`` work there.""" from hermes_cli.web_server import _open_session_db_for_profile try: key = profile or ""