Files
hermes-agent/tui_gateway/server.py
T

3538 lines
166 KiB
Python

import atexit
import concurrent.futures
import contextlib
import contextvars
import copy
import hashlib
import importlib
import inspect # noqa: F401 (split modules)
import json
import logging
import os
import queue
import subprocess
import sys
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, NamedTuple, Optional # noqa: F401 (Callable: split modules)
# Several of these look unused here but are resolved BARE by split-module bodies rebound onto this
# namespace (method_ctx.bind_module) — deleting one breaks a handler at call time, not import time.
from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope # noqa: F401
from hermes_constants import (
get_hermes_home, get_hermes_home_override, reset_hermes_home_override, set_hermes_home_override)
from hermes_cli.env_loader import load_hermes_dotenv
from utils import is_truthy_value
from tools.environments.local import hermes_subprocess_env
from agent.replay_cleanup import sanitize_replay_history
from agent.compaction_display import project_compaction_message_for_display # noqa: F401
from agent.skill_commands import describe_skill_invocation # noqa: F401
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX # noqa: F401
from tui_gateway import git_probe # noqa: F401
from tui_gateway._env import env_float, env_int
from tui_gateway.turn_marker import clear_turn_marker, read_turn_marker, record_turn_start # noqa: F401
from tui_gateway.transport import (
StdioTransport, Transport, bind_transport, current_transport, reset_transport)
logger = logging.getLogger(__name__)
_hermes_home = get_hermes_home()
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env")
# ── Panic logger ─────────────────────────────────────────────────────
# Crashes otherwise leave no forensics (stdout is the JSON-RPC pipe, stderr
# doesn't flush before exit): append every unhandled exception to
# logs/tui_gateway_crash.log and re-emit a one-line stderr summary for Activity.
_CRASH_LOG = os.path.join(_hermes_home, "logs", "tui_gateway_crash.log")
def _record_crash(kind: str, exc_type, exc_value, exc_tb, *, thread_name: str | None = None) -> None:
"""Append the trace to the crash log, then print the one-line stderr summary the TUI shows."""
import traceback
trace = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
suffix = f" · thread={thread_name}" if thread_name is not None else ""
with contextlib.suppress(Exception):
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
f.write(f"\n=== {kind} · {time.strftime('%Y-%m-%d %H:%M:%S')}{suffix} ===\n")
f.write(trace)
# The first line is what the user sees (gateway.stderr Activity line); the rest stays in the log.
first = str(exc_value).strip().splitlines()[0] if str(exc_value).strip() else exc_type.__name__
who = f"thread {thread_name} raised " if thread_name is not None else ""
print(f"[gateway-crash] {who}{exc_type.__name__}: {first}", file=sys.stderr, flush=True)
def _panic_hook(exc_type, exc_value, exc_tb):
_record_crash("unhandled exception", exc_type, exc_value, exc_tb)
sys.__excepthook__(exc_type, exc_value, exc_tb) # chain so the process still terminates normally
def _thread_panic_hook(args):
_record_crash("thread exception", args.exc_type, args.exc_value, args.exc_traceback, thread_name=args.thread.name)
sys.excepthook = _panic_hook
threading.excepthook = _thread_panic_hook
with contextlib.suppress(Exception):
from hermes_cli.banner import prefetch_update_check
prefetch_update_check()
from tui_gateway.render import make_stream_renderer, render_diff, render_message # noqa: F401
_sessions: dict[str, dict] = {}
_methods: dict[str, callable] = {}
_pending: dict[str, tuple[str, threading.Event]] = {}
_pending_prompt_payloads: dict[str, tuple[str, dict]] = {}
_answers: dict[str, str] = {}
# Batch clarify accumulators: rid → {"qids": [...], "answers": {qid: answer}}. Written by
# clarify.respond (per-question lock, update-in-place), read out by _block on resolution/timeout
# so locked answers survive the deadline.
_batch_clarify: dict[str, dict] = {}
_db = None
_db_error: str | None = None
_stdout_lock = threading.Lock()
_cfg_lock = threading.Lock()
# Shared profile UI metadata is updated concurrently by Desktop, mobile and pool RPCs; its
# compare/check/write transaction needs its own lock, not the unrelated config cache lock.
_profile_ui_meta_lock = threading.Lock()
_sessions_lock = threading.RLock() # reentrant: _close_session_by_id may run under callers that already hold it
_prompt_lock = threading.Lock()
_cfg_cache: dict | None = None
_cfg_mtime: float | None = None
_cfg_path = None
_session_resume_lock = threading.Lock()
_SLASH_WORKER_TIMEOUT_S = max(5.0, env_float("HERMES_TUI_SLASH_TIMEOUT_S", 45.0))
def _ws_orphan_setting(env_var: str, cfg_key: str, default: float) -> float:
"""``dashboard.<cfg_key>`` seconds; the env var is an internal override that wins when set."""
raw = os.environ.get(env_var)
if raw is None or not str(raw).strip():
try:
from hermes_cli.config import load_config
raw = (load_config().get("dashboard") or {}).get(cfg_key)
except Exception:
raw = None
try:
value = float(raw) if raw is not None else default
except (ValueError, TypeError):
value = default
return max(0.0, value)
def _resolve_ws_orphan_reap_grace() -> float:
"""Grace before an orphaned WS session is interrupted/reaped; 0 = park forever.
ws.py parks a disconnected session for a quick reattach, but a browser refresh
mints a NEW sid and never reattaches the old one (leaking its slash worker)."""
return _ws_orphan_setting("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S", "ws_orphan_reap_grace_s", 20.0)
_WS_ORPHAN_REAP_GRACE_S = _resolve_ws_orphan_reap_grace()
# A detached RUNNING turn is interrupted by the orphan reaper only once its activity
# clock (API waits, stream tokens, tool heartbeats) has idled this long; 600s matches
# the turn-liveness watchdog so "wedged" means the same on both paths. 0 disables the gate.
_WS_ORPHAN_ACTIVITY_STALE_S = _ws_orphan_setting(
"HERMES_TUI_WS_ORPHAN_ACTIVITY_STALE_S", "ws_orphan_activity_stale_s", 600.0)
_WS_ORPHAN_INTERRUPT_REAP_POLL_S = 1.0
# Budget for the interrupt-then-reap poll chain: an interrupted turn that never
# settles (thread hung in a syscall) would reschedule the 1s poll forever. After
# this many polls, log loudly and force-reap.
_WS_ORPHAN_INTERRUPT_REAP_MAX_POLLS = 60
_TURN_SETTLE_BEFORE_CLOSE_SECONDS = 5.0
_DETAIL_SECTION_NAMES = ("thinking", "tools", "subagents", "activity")
_DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
# ── Async RPC dispatch ───────────────────────────────────────────────
# Slow handlers (seconds to minutes) would leave approval.respond and
# session.interrupt unread in the stdin pipe; only those go to a small thread
# pool, everything else stays inline so fast-path ordering stays sane.
# write_json is _stdout_lock-guarded, so concurrent response writes are safe.
# Why each is slow: billing/subscription/usage = blocking portal (+Stripe) round-trips;
# complete.* = git ls-files / prompt_toolkit import + skill scan; model.options =
# credential pool + pricing + provider probe; pet.* = network or PNG decode (generate
# = several image-model round-trips); reload.mcp / mcp.servers.* = server rediscovery,
# cold npx spawn, ~30s OAuth wait; profiles.* = skill-tree walk + state.db open;
# bot_relay.* = a FULL one-turn agent conversation (600s); setup.* / session.active_list
# = Desktop-polled and under GIL pressure block the WS read loop (false "needs setup",
# stalled interrupts); voice.*/wake.* = SYNCHRONOUS faster-whisper install (300s);
# session.workspace.move = git subprocess probes on an arbitrary (maybe slow) mount.
_LONG_HANDLERS = frozenset({
"billing.state", "subscription.state", "subscription.preview", "subscription.change",
"subscription.resume", "subscription.upgrade", "usage.bars", "session.usage", "billing.step_up",
"browser.manage", "cli.exec", "complete.path", "complete.slash", "llm.oneshot", "model.options",
"pet.cells", "pet.gallery", "pet.generate", "pet.hatch", "pet.info", "pet.select", "pet.thumb",
"learning.frames", "plugins.manage", "reload.mcp", "mcp.servers.test", "mcp.servers.oauth.start",
"process.list", "profiles.configure", "profiles.create", "profiles.describe", "profiles.get_asset",
"profiles.list", "profiles.set_asset", "bot_relay.roster.sync", "bot_relay.outbox.drain",
"bot_relay.deliver", "bot_relay.reply", "image.generate", "projects.discover_repos",
"projects.record_repos", "projects.for_cwd", "projects.tree", "projects.project_sessions",
"setup.runtime_check", "setup.status", "voice.toggle", "voice.record", "voice.tts", "wake.start",
"wake.status", "session.active_list", "session.branch", "session.compress", "session.list",
"session.resume", "session.workspace.move", "shell.exec", "skills.manage", "slash.exec",
})
_rpc_pool_workers = max(2, env_int("HERMES_TUI_RPC_POOL_WORKERS", 8))
_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_rpc_pool_workers, thread_name_prefix="tui-rpc")
atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
# Exact in-memory session generation executing on the current turn thread.
# Unlike a public session id, this object identity cannot be supplied by RPC.
_current_runtime_session_record: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
"hermes_gateway_runtime_session_record", default=None)
# JSON-RPC method being dispatched on this thread/task. Diagnostic only (names WHICH client
# poll is looping in the 4001 warning); never authorization — the method string is client-supplied.
_current_rpc_method: contextvars.ContextVar[str] = contextvars.ContextVar("hermes_gateway_rpc_method", default="")
# Reserve real stdout for JSON-RPC only; redirect Python's stdout to stderr so stray print() from
# libraries/tools becomes harmless gateway.stderr instead of corrupting the JSON protocol.
_real_stdout = sys.stdout
sys.stdout = sys.stderr
class _DropTransport:
"""Detached WS sink: keep sessions resumable without writing stale frames."""
def write(self, obj: dict) -> bool:
return False
def close(self) -> None:
pass
# Module-level stdio transport — fallback sink when no transport is bound via contextvar or session.
# Stream resolved through a lambda so test monkey-patches of `_real_stdout` still land.
_stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock)
# Detached websocket sessions use a drop sink instead of stdio: Desktop embeds the gateway in-process
# and captures stdout into logs, so stale frames must not fall through while a session awaits resume/reap.
_detached_ws_transport = _DropTransport()
def _prepend_tool_paths(env: dict[str, str]) -> dict[str, str]:
"""Prepend managed bin, venv bin and ~/.local/bin to PATH so slash_worker children resolve
Hermes-managed CLIs (browser-use, uvx, uv) under the Desktop's minimal PATH. Managed bin leads
(managed-first policy for the Browser Use CLI)."""
managed_bin = ""
with contextlib.suppress(Exception):
managed_bin = str(Path(get_hermes_home()) / "bin")
venv_bin = str(Path(sys.executable).parent) # <venv>/bin (POSIX) or <venv>/Scripts (Windows)
user_bin = str(Path.home() / ".local" / "bin")
existing = env.get("PATH") or ""
env["PATH"] = os.pathsep.join([p for p in (managed_bin, venv_bin, user_bin) if p] + ([existing] if existing else []))
return env
class _SlashWorker:
"""Persistent HermesCLI subprocess for slash commands."""
def __init__(self, session_key: str, model: str, profile_home: str | None = None):
self._lock = threading.Lock()
self._seq = 0
self.stderr_tail: list[str] = []
self.stdout_queue: queue.Queue[dict | None] = queue.Queue()
argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key]
if model:
argv += ["--model", model]
self._closed = False
from hermes_cli._subprocess_compat import windows_hide_flags
from tools.environments.local import build_subprocess_env
# The worker runs the agent → needs provider credentials; tier-1 secrets (gateway/GitHub/
# infra) are still stripped. Multi-profile sessions resolve against the session's profile
# home via `extra` (applied last, always wins); the base already carries the HOME contract.
env = _prepend_tool_paths(build_subprocess_env(
hermes_subprocess_env(inherit_credentials=True), scrub_secrets=False,
inherit_profile_home=False, extra={"HERMES_HOME": str(profile_home)} if profile_home else None))
# start_new_session: otherwise the worker inherits the gateway's pgid and mcp_tool's orphan
# sweep, racing the spawn, killpg()s the TUI parent itself. errors="replace": bytes invalid
# in the system locale (GBK Windows) must not raise UnicodeDecodeError in the drain threads.
self.proc = subprocess.Popen(
argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
encoding="utf-8", errors="replace", bufsize=1, cwd=os.getcwd(), env=env,
creationflags=windows_hide_flags(), start_new_session=True)
threading.Thread(target=self._drain_stdout, daemon=True).start()
threading.Thread(target=self._drain_stderr, daemon=True).start()
def _drain_stdout(self):
for line in self.proc.stdout or []:
with contextlib.suppress(json.JSONDecodeError):
self.stdout_queue.put(json.loads(line))
self.stdout_queue.put(None)
def _drain_stderr(self):
for line in self.proc.stderr or []:
if text := line.rstrip("\n"):
self.stderr_tail = (self.stderr_tail + [text])[-80:]
def run(self, command: str) -> str:
if self.proc.poll() is not None:
raise RuntimeError("slash worker exited")
with self._lock:
self._seq += 1
rid = self._seq
self.proc.stdin.write(json.dumps({"id": rid, "command": command}) + "\n")
self.proc.stdin.flush()
while True:
try:
msg = self.stdout_queue.get(timeout=_SLASH_WORKER_TIMEOUT_S)
except queue.Empty:
raise RuntimeError("slash worker timed out")
if msg is None:
break
if msg.get("id") != rid:
continue
if not msg.get("ok"):
raise RuntimeError(msg.get("error", "slash worker failed"))
return str(msg.get("output", "")).rstrip()
raise RuntimeError(
f"slash worker closed pipe{': ' + chr(10).join(self.stderr_tail[-8:]) if self.stderr_tail else ''}"
)
def close(self):
if getattr(self, "_closed", False):
return
self._closed = True
proc = self.proc
try:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=1)
except Exception:
proc.kill()
with contextlib.suppress(Exception):
proc.wait(timeout=1) # reap the zombie SIGKILL leaves behind
except Exception:
with contextlib.suppress(Exception):
proc.kill()
proc.wait(timeout=1)
finally:
for stream in (proc.stdin, proc.stdout, proc.stderr):
with contextlib.suppress(Exception):
stream.close()
def _display_cfg() -> dict:
"""``display`` section of the behavioral config, or ``{}`` when absent/malformed."""
display = _load_cfg().get("display")
return display if isinstance(display, dict) else {}
def _load_busy_input_mode() -> str:
raw = str(_display_cfg().get("busy_input_mode", "") or "").strip().lower()
return raw if raw in {"queue", "steer", "interrupt"} else "interrupt"
def _load_interim_assistant_messages() -> bool:
"""``display.interim_assistant_messages`` (default true); when false no ``interim_assistant_callback``
is installed, so tool-call/verify-on-stop interim text never becomes ``message.interim`` (gateway parity)."""
return is_truthy_value(_display_cfg().get("interim_assistant_messages", True))
def _shutdown_sessions() -> None:
# Durable-first: flush every un-flushed transcript (bounded budget) BEFORE the slow
# per-session teardown, so a supervisor SIGKILL mid-shutdown can no longer lose them.
with contextlib.suppress(Exception):
_flush_sessions_before_exit()
with contextlib.suppress(Exception):
_release_gateway_wake_owner()
with _sessions_lock:
sids = list(_sessions)
for sid in sids:
_close_session_by_id(sid, end_reason="tui_shutdown")
# Session reaping / flushing knobs (implementation: session_reaper.py). TTL is the last-resort
# net for disconnect paths that slip past the WS finally; hours-scale because last_active freezes
# during a long turn and on passive viewing — running/pending/starting/live-transport are hard exemptions.
_SESSION_TTL_S = max(0.0, env_float("HERMES_TUI_SESSION_TTL_S", float(6 * 3600)))
_REAPER_SCAN_S = 300.0
# Flush-on-kill budget + periodic incremental flush (piggybacks the reaper scan) so a
# SIGTERM/SIGKILL mid-update loses at most one flush interval of session state.
_EXIT_FLUSH_BUDGET_S = max(0.0, env_float("HERMES_TUI_EXIT_FLUSH_BUDGET_S", 5.0))
_INCREMENTAL_FLUSH_INTERVAL_S = max(0.0, env_float("HERMES_TUI_SESSION_FLUSH_INTERVAL_S", _REAPER_SCAN_S))
def _start_idle_reaper() -> None:
def _loop():
while True:
time.sleep(_REAPER_SCAN_S)
with contextlib.suppress(Exception):
_reap_idle_sessions()
threading.Thread(target=_loop, daemon=True).start()
atexit.register(_shutdown_sessions)
_start_idle_reaper()
# ── Plumbing ──────────────────────────────────────────────────────────
def _get_db():
global _db, _db_error
if _db is None:
from hermes_state import get_shared_session_db
try:
_db, _db_error = get_shared_session_db(), None
except Exception as exc:
_db_error = str(exc)
logger.warning("TUI session store unavailable — continuing without state.db features: %s", exc)
return None
return _db
def _db_for_profile(profile: str | None = None):
"""(db, owns_handle) for ``params.profile`` (app-global remote mode): launch/own profile → the
shared ``_get_db()`` handle (left open); another profile → a dedicated handle the caller must
``close()`` (see :func:`_profile_db`). ``db`` is None when unavailable."""
profile_home = _profile_home(profile)
if profile_home is None:
return _get_db(), False
try:
from hermes_state import get_shared_session_db
return get_shared_session_db(Path(profile_home) / "state.db"), True
except Exception as exc:
logger.warning("TUI profile session store unavailable for %s: %s", profile, exc)
return None, False
def _transfer_db_to_agent(agent, db) -> bool:
"""Hand a DEDICATED profile ``state.db`` handle to *agent* (``AIAgent.close()`` then releases it).
True only when the transfer happened; False (agent not holding *this* handle — build failed
before ``_make_agent`` or it got a different db) tells the caller the handle is still its own
to close. The shared launch handle never transfers: it outlives every agent, and ownership
would let session.close() tear down the process-wide database."""
if agent is None or db is None:
return False
try:
if getattr(agent, "_session_db", None) is not db:
return False
if db is _get_db():
logger.warning(
"Refused transfer of the shared launch SessionDB to a session "
"agent — the caller's owns_db gate should have prevented this.")
return False
agent._owns_session_db = True
return True
except Exception:
return False
def _open_profile_session_db(profile_home):
"""Open a DEDICATED handle on ``profile_home``'s ``state.db`` — FAIL CLOSED.
A silent fallback to the launch ``state.db`` would bleed the session's rows into the wrong
profile's store exactly when the profile store is briefly unopenable (locked, mid-restore), so
callers let the raised error abort the agent build (deferred builds → ``agent_error``)."""
from hermes_state import get_shared_session_db
db_path = Path(profile_home) / "state.db"
try:
return get_shared_session_db(db_path)
except Exception as exc:
raise RuntimeError(f"profile session store unavailable: {db_path}: {exc}") from exc
@contextlib.contextmanager
def _profile_db(params: dict | None = None):
"""Yield the SessionDB for ``params['profile']`` (None when unavailable); closes dedicated
profile handles, leaves the launch-profile shared handle open."""
profile = (params.get("profile") or "").strip() or None if isinstance(params, dict) else None
db, owns = _db_for_profile(profile)
try:
yield db
finally:
if owns and db is not None:
with contextlib.suppress(Exception):
db.close()
def _response_profile_name(profile: str | None = None) -> str:
"""Profile name for session.* payloads: the requested real non-launch profile, else the launch one."""
name = (profile or "").strip()
return name if name and _profile_home(name) is not None else _current_profile_name()
def _db_unavailable_error(rid, *, code: int):
detail = _db_error or "state.db unavailable"
return _err(rid, code, f"state.db unavailable: {detail}")
# ── per-session profile scoping (global remote mode) ───────────────────────────
# The desktop's app-global remote mode points every profile at this backend, so calls carry
# ``profile``: open that profile's db and bind its HERMES_HOME (ContextVar override) for the
# call so config/skills/model/persistence resolve to it. Omitted/own profile → launch profile.
def _profile_home(profile: str | None) -> Path | None:
"""Resolve a named profile's home on THIS host, or None for the launch profile."""
name = (profile or "").strip()
if not name:
return None
try:
from hermes_cli import profiles as profiles_mod
home = Path(profiles_mod.get_profile_dir(name))
except Exception:
return None
if home.resolve() == Path(_hermes_home).resolve():
return None # already the launch profile: no override needed
if (home / "state.db").exists() or home.exists():
_served_profile_homes.add(home) # the change watcher must stat every served sibling store too
return home
return None
# Profile homes served besides the launch home — the only extra stores the sessions watcher
# probes. Empty on single-profile installs, so their watcher stays byte-identical.
_served_profile_homes: set[Path] = set()
def _profile_scoped(handler):
"""Bind ``params['profile']``'s HERMES_HOME around a handler (pets/projects resolve via
``get_hermes_home``, so app-global remote mode still hits the focused profile). No-op for launch."""
def wrapper(rid, params):
home = _profile_home(params.get("profile") if isinstance(params, dict) else None)
if home is None:
return handler(rid, params)
token = set_hermes_home_override(home)
try:
return handler(rid, params)
finally:
reset_hermes_home_override(token)
return wrapper
# Placeholder ``terminal.cwd`` values that don't name a real directory — resolved to the home dir at
# runtime, so they must NOT be treated as an explicit workspace (mirrors gateway/run.py's config bridge).
_CWD_PLACEHOLDERS = {".", "auto", "cwd"}
def _configured_cwd_from_cfg(cfg: dict | None) -> str | None:
"""Absolute, existing ``terminal.cwd`` from a config mapping; None for placeholders/missing/invalid."""
terminal_cfg = cfg.get("terminal") if isinstance(cfg, dict) else None
raw = str(terminal_cfg.get("cwd") or "").strip() if isinstance(terminal_cfg, dict) else ""
if not raw or raw in _CWD_PLACEHOLDERS:
return None
resolved = os.path.abspath(os.path.expanduser(raw))
return resolved if os.path.isdir(resolved) else None
def _profile_configured_cwd(profile_home: Path | None) -> str | None:
"""A non-launch profile's ``terminal.cwd`` from ITS config.yaml (fail-open → None).
The process-global ``TERMINAL_CWD`` belongs to the *launch* profile; a session bound to
another profile must take its workspace from that profile's config, not the stale env var.
load_config() resolves the ACTIVE profile, so read the file directly + the _load_cfg pipeline.
"""
if profile_home is None:
return None
try:
from hermes_cli.config import read_user_config_raw
p = Path(profile_home) / "config.yaml"
return _configured_cwd_from_cfg(_expand_cfg(_apply_managed(read_user_config_raw(p)))) if p.exists() else None
except Exception:
return None
def _launch_configured_cwd() -> str | None:
"""Launch profile's ``terminal.cwd`` from config.yaml: the dashboard's in-memory gateway gets no
bridged ``TERMINAL_CWD`` env (only the Node PTY child does), so a fresh /chat would start in
``os.getcwd()`` unless config is read directly."""
try:
return _configured_cwd_from_cfg(_load_cfg())
except Exception:
return None
def _default_session_cwd() -> str:
"""Fallback cwd for a session with no explicit / stored / profile cwd (mirrors the launch-config-aware
tail of :func:`_completion_cwd`, so created AND resumed sessions land in the configured ``terminal.cwd``)."""
return _launch_configured_cwd() or os.getenv("TERMINAL_CWD") or os.getcwd()
def write_json(obj: dict) -> bool:
"""Emit one JSON frame via the most-specific transport.
Precedence: (1) event frames with a session id → that session's transport, so async events
reach the owning client even from threads with no contextvar binding; (2) the transport bound
on the current context (:func:`dispatch`); (3) the module stdio transport (historical
behaviour; tests monkey-patch ``_real_stdout``). Every event frame gets a per-session
monotonic ``seq`` + replay-ring entry (event_replay) so ``session.events.since`` can resume."""
from tui_gateway.event_replay import _stamp_event
_stamp_event(obj)
if obj.get("method") == "event":
params = obj.get("params")
sid = ((params or {}).get("session_id")) if isinstance(params, dict) else ""
if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None:
return t.write(obj)
return (current_transport() or _stdio_transport).write(obj)
def _event_frame(event: str, sid: str, payload: dict | None = None) -> dict:
params: dict = {"type": event, "session_id": sid}
if payload is not None:
params["payload"] = payload
return {"jsonrpc": "2.0", "method": "event", "params": params}
def _emit(event: str, sid: str, payload: dict | None = None):
write_json(_event_frame(event, sid, payload))
# Live WS peer transports (maintained by tui_gateway.ws): the only route for session-less
# background events, which write_json would otherwise drop on stdio. See _broadcast_global_event.
_live_transports: set[Transport] = set()
_live_transports_lock = threading.Lock()
def register_live_transport(transport: Transport | None) -> None:
"""Track a connected client transport for global broadcasts. Idempotent."""
if transport is None:
return
with _live_transports_lock:
_live_transports.add(transport)
def unregister_live_transport(transport: Transport | None) -> None:
"""Stop tracking a transport (call on disconnect). Idempotent."""
with _live_transports_lock:
_live_transports.discard(transport)
def _broadcast_global_event(event: str, payload: dict | None = None) -> None:
"""Fan a session-less, surface-global event (``skin.changed``) to every connected client.
Background emitters (skin watcher) bottom out at stdio in ``write_json``'s ladder, so WS peers
would never see the frame. No registered transports (stdio TUI, tests) → plain ``_emit``."""
with _live_transports_lock:
targets = list(_live_transports)
if not targets:
_emit(event, "", payload)
return
frame = _event_frame(event, "", payload)
for transport in targets:
try:
transport.write(frame)
except Exception: # one wedged peer must not stall the rest; disconnect teardown unregisters it
logger.debug("global-event broadcast write failed type=%s", event, exc_info=True)
def _approval_request_payload(data: dict | None) -> dict:
"""Build the client-safe representation of a pending approval."""
payload = dict(data or {})
if "choices" not in payload:
choices = ["once"]
if not payload.get("smart_denied") and payload.get("allow_session") is not False:
choices.append("session")
if payload.get("allow_permanent") is not False:
choices.append("always")
payload["choices"] = choices + ["deny"]
if "command" in payload:
from gateway.run import _redact_approval_command
payload["command"] = _redact_approval_command(payload.get("command"))
return payload
def _pending_clarify_request_payload(sid: str) -> dict | None:
"""Read-only snapshot of the clarify prompt still blocking a session, if any.
A client whose transport was detached when `clarify.request` was emitted would otherwise never
see the question (the agent thread stays parked until timeout). Same replay contract as
`pending_approval`: the registry stays authoritative; `clarify.respond` with the embedded
request_id resolves it."""
with _prompt_lock:
for rid, (owner_sid, _ev) in _pending.items():
if owner_sid != sid:
continue
event, prompt_payload = _pending_prompt_payloads.get(rid, ("", {}))
if event == "clarify.request":
snapshot = dict(prompt_payload)
# Batch clarify: replay the answers locked so far so a reconnecting
# client restores its per-question ✓ state.
batch = _batch_clarify.get(rid)
if batch is not None and batch["answers"]:
snapshot["answers"] = dict(batch["answers"])
return snapshot
session = _sessions.get(sid)
if session is not None:
with session.get("history_lock", threading.Lock()):
pending = session.get("_compute_host_pending_clarify")
if isinstance(pending, dict):
return dict(pending)
return None
def _pending_approval_request_payload(session_key: str) -> dict | None:
"""Read the oldest unresolved approval in a session, if there is one."""
try:
from tools.approval import get_pending_gateway_approval
approval = get_pending_gateway_approval(session_key)
except Exception:
logger.debug("failed to read pending approval for %s", session_key, exc_info=True)
return None
return _approval_request_payload(approval) if approval else None
def _emit_approval_request(sid: str, data: dict | None) -> None:
"""Emit ``approval.request`` with the command redacted: the payload is built from the RAW
command, so a credential-shaped value Tirith flagged would otherwise echo verbatim to the
TUI (third egress transport alongside chat platforms and the SSE/API stream)."""
_emit("approval.request", sid, _approval_request_payload(data))
def _status_update(sid: str, kind: str, text: str | None = None):
body = (text if text is not None else kind).strip()
if not body:
return
out_kind = kind if text is not None else "status"
# Auto-compaction arrives as a generic "lifecycle" status; re-tag so drivers can show a
# summarizing indicator — otherwise idle/preflight compaction looks like a hung turn.
if out_kind == "lifecycle":
from agent.conversation_compression import is_compaction_progress_status
if is_compaction_progress_status(body):
out_kind = "compacting"
_emit("status.update", sid, {"kind": out_kind, "text": body})
def _estimate_image_tokens(width: int, height: int) -> int:
"""Rough attachment-display estimate: 512px tiles at ~85 tokens/tile (cross-provider hint)."""
if width <= 0 or height <= 0:
return 0
return max(1, (width + 511) // 512) * max(1, (height + 511) // 512) * 85
def _image_meta(path: Path) -> dict:
meta = {"name": path.name}
with contextlib.suppress(Exception):
from PIL import Image
with Image.open(path) as img:
width, height = img.size
meta.update(width=int(width), height=int(height), token_estimate=_estimate_image_tokens(int(width), int(height)))
return meta
def _ok(rid, result: dict) -> dict:
return {"jsonrpc": "2.0", "id": rid, "result": result}
def _err(rid, code: int, msg: str, data=None) -> dict:
error = {"code": code, "message": msg}
if data is not None:
error["data"] = data
return {"jsonrpc": "2.0", "id": rid, "error": error}
def method(name: str):
def dec(fn):
_methods[name] = fn
return fn
return dec
def _normalize_request(req: Any) -> tuple[Any, str, dict] | dict:
"""Validate a JSON-RPC request enough for safe local dispatch."""
if not isinstance(req, dict):
return _err(None, -32600, "invalid request: expected an object")
rid, method = req.get("id"), req.get("method")
if not isinstance(method, str) or not method:
return _err(rid, -32600, "invalid request: method must be a non-empty string")
params = req.get("params", {})
if params is None:
params = {}
elif not isinstance(params, dict):
return _err(rid, -32602, "invalid params: expected an object")
return rid, method, params
def handle_request(req: dict) -> dict | None:
normalized = _normalize_request(req)
if isinstance(normalized, dict):
return normalized
rid, method, params = normalized
fn = _methods.get(method)
if not fn:
return _err(rid, -32601, f"unknown method: {method}")
token = _current_rpc_method.set(method)
try:
return fn(rid, params)
finally:
_current_rpc_method.reset(token)
def _current_session_steer_authority(session_id: str) -> tuple[Transport | None, dict | None]:
"""Unforgeable steering authority for this RPC context: the public session id is only a lookup
hint; authority is the identity of BOTH the ContextVar-bound transport and the live in-memory
record under that id, so transport rebinding, removal or id reuse invalidates an earlier generation."""
transport = current_transport()
if transport is None or not session_id:
return None, None
expected_session = _current_runtime_session_record.get()
with _sessions_lock:
session = _sessions.get(session_id)
if (
session is None
or (expected_session is not None and session is not expected_session)
or session.get("transport") is not transport):
return None, None
return transport, session
def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
"""Route inbound RPCs — long handlers to the pool (returns None; the worker writes its own
response via the bound transport), everything else inline (returns the response dict).
*transport* pins every write of this request — events included — to that transport;
omitted → the module stdio transport (``tui_gateway.entry`` behaviour)."""
t = transport or _stdio_transport
token = bind_transport(t)
try:
normalized = _normalize_request(req)
if isinstance(normalized, dict):
return normalized
_rid, method, _params = normalized
if method not in _LONG_HANDLERS:
return handle_request(req)
ctx = contextvars.copy_context() # the pool worker must see the bound transport
def run():
try:
resp = handle_request(req)
except Exception as exc:
resp = _err(req.get("id"), -32000, f"handler error: {exc}")
if resp is not None:
t.write(resp)
_pool.submit(lambda: ctx.run(run))
return None
finally:
reset_transport(token)
def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None:
ready = session.get("agent_ready")
if ready is not None and not ready.wait(timeout=timeout):
return _err(rid, 5032, "agent initialization timed out")
err = session.get("agent_error")
return _err(rid, 5032, err) if err else None
# The deferred prompt path waits in short slices so a cancel is honored promptly and a slow
# build is reported to the client exactly once.
_AGENT_BUILD_WAIT_SLICE = 5.0
_AGENT_BUILD_SLOW_NOTICE_AFTER = 30.0
_AGENT_BUILD_SLOW_NOTICE_KEY = "agent-build-slow"
def _agent_build_wait_cap() -> float:
"""Seconds a submitted prompt waits for the deferred build before failing; ``agent.build_wait_timeout``
overrides the 600s default (raise it for many slow MCP servers / high-latency provider metadata)."""
with contextlib.suppress(Exception):
raw = (_load_cfg().get("agent") or {}).get("build_wait_timeout")
if raw is not None and float(raw) > 0:
return float(raw)
return 600.0
def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None:
"""Patient ``_wait_agent`` for the deferred prompt.submit path.
prompt.submit already answered ``{"status": "streaming"}`` and the user's first message IS the
turn in flight, while a cold deferred build routinely outlives the flat 30s ceiling — timing
out here silently discarded that message. So: wait in short slices (cancel honored promptly),
notify the client once (keyed) past ``_AGENT_BUILD_SLOW_NOTICE_AFTER``, and fail only when the
build thread died without signalling ready or the bounded cap expired on a hung build.
Returns None on success OR cancel mid-wait (the caller's cancel branch owns that messaging)."""
ready = session.get("agent_ready")
if ready is None:
return None
start = time.monotonic()
cap = _agent_build_wait_cap()
notified_slow = False
while not ready.wait(timeout=_AGENT_BUILD_WAIT_SLICE):
with session["history_lock"]:
cancelled = session.get("_turn_cancel_requested") or not session.get("running")
if cancelled:
return None
waited = time.monotonic() - start
if waited >= cap:
return _err(
rid, 5032,
f"agent initialization timed out after {int(waited)}s — "
"your message was not sent; retry once the session is ready")
build_thread = session.get("_agent_build_thread")
if build_thread is not None and not build_thread.is_alive() and not ready.is_set():
# _build's finally guarantees ready.set(); dead thread + unset ready = died hard.
return _err(rid, 5032, session.get("agent_error") or "agent initialization failed before completing")
if not notified_slow and waited >= _AGENT_BUILD_SLOW_NOTICE_AFTER:
notified_slow = True # one keyed, replace-in-place notice (toast / status bar)
_emit("notification.show", sid, {
"text": (
"Still starting the agent (tool discovery / model "
"setup) — your message will be sent as soon as it's "
"ready."),
"level": "info", "kind": "agent", "ttl_ms": None,
"key": _AGENT_BUILD_SLOW_NOTICE_KEY, "id": _AGENT_BUILD_SLOW_NOTICE_KEY,
})
if notified_slow:
_emit("notification.clear", sid, {"key": _AGENT_BUILD_SLOW_NOTICE_KEY})
err = session.get("agent_error")
return _err(rid, 5032, err) if err else None
def _bind_build_profile_scopes(profile_home: str) -> "_TurnScopes":
"""Bind a session profile's HERMES_HOME / secret / terminal scopes for an agent build.
Fail-open per scope (the build must not die on a scope helper); the terminal installer itself
fails closed (malformed policy → refusal scope) so _make_agent's terminal probing / cwd hints
resolve the routed profile, never the launch process."""
scopes = _TurnScopes()
scopes.home = set_hermes_home_override(profile_home)
with contextlib.suppress(Exception):
from agent.secret_scope import build_profile_secret_scope, set_secret_scope
scopes.secret = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
try:
from tools.terminal_scope import install_profile_terminal_scope
scopes.terminal = install_profile_terminal_scope(Path(profile_home))
except Exception:
scopes.terminal = None
return scopes
def _release_build_profile_scopes(scopes: "_TurnScopes") -> None:
if scopes.home is not None:
reset_hermes_home_override(scopes.home)
if scopes.secret is not None:
with contextlib.suppress(Exception):
from agent.secret_scope import reset_secret_scope
reset_secret_scope(scopes.secret)
if scopes.terminal is not None:
with contextlib.suppress(Exception):
from tools.terminal_scope import reset_terminal_scope
reset_terminal_scope(scopes.terminal)
def _deferred_build_agent_kwargs(current: dict, session_db) -> dict:
"""_make_agent kwargs for a deferred (first-prompt) build.
A lazy-resumed (watch) session carries the stored conversation id so the upgrade continues it.
A cold deferred resume restores the full persisted runtime identity (as the eager resume's
_stored_session_runtime_overrides splat does) so the build can't drop the provider ("No LLM
provider configured"). No stored runtime, or its provider no longer resolves → the model/effort/
tier the desktop picked for THIS session, else the configured default — never "Unknown provider".
"""
kw = {"session_db": session_db, "context_cwd_is_launch_artifact": _context_cwd_is_launch_artifact(current)}
if resume_sid := current.get("resume_session_id"):
kw["session_id"] = resume_sid
kw["platform_override"] = _session_source(current)
resume_overrides = current.get("resume_runtime_overrides")
if isinstance(resume_overrides, dict) and resume_overrides and _overrides_have_routable_provider(resume_overrides):
kw.update(resume_overrides)
else:
if override := current.get("model_override"):
kw["model_override"] = override
if (reasoning := current.get("create_reasoning_override")) is not None:
kw["reasoning_config_override"] = reasoning
if (tier := current.get("create_service_tier_override")) is not None:
kw["service_tier_override"] = tier
return kw
def _wire_session_agent(sid: str, key: str, agent) -> bool:
"""Post-build wiring for a session agent; returns whether the approval notify got registered.
Approval prompts route to the client; the self-improvement review's "💾 …" summary is emitted
as review.summary (the TUI has no print surface), honoring display.memory_notifications."""
notify_registered = False
with contextlib.suppress(Exception):
from tools.approval import load_permanent_allowlist, register_gateway_notify
register_gateway_notify(key, lambda data: _emit_approval_request(sid, data))
notify_registered = True
load_permanent_allowlist()
_wire_callbacks(sid)
with contextlib.suppress(Exception): # bare agents without the attribute must not break startup
agent.background_review_callback = lambda message, _sid=sid: _emit("review.summary", _sid, {"text": str(message)})
agent.memory_notifications = _load_memory_notifications()
return notify_registered
def _start_session_services(sid: str, key: str, current: dict) -> None:
"""Start the notification poller and fire the session-reset boundary hook."""
with _sessions_lock:
if sid in _sessions:
_sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
_notify_session_boundary("on_session_reset", key, _session_source(current))
def _await_resume_history(sid: str, current: dict) -> bool:
"""Block on a cold resume's transcript hydration; False when this record was replaced meanwhile."""
history_ready = current.get("resume_history_ready")
if history_ready is None:
return True
if not history_ready.wait(timeout=300.0):
raise TimeoutError("session history hydration timed out")
if history_error := current.get("resume_history_error"):
raise RuntimeError(str(history_error))
with _sessions_lock:
return _sessions.get(sid) is current
def _attach_built_agent(current: dict, agent) -> None:
"""Attach a freshly built agent to its live record (session DB row deferred to first run_conversation())."""
# Bot Mode gate hint: the DB title lands post-first-turn but the system prompt builds at
# turn START, so hand the agent its title.
if _title_hint := str(current.get("pending_title") or "").strip():
agent._session_title_hint = _title_hint
current["agent"] = agent
_session_todo_state(current)
# Baseline for the per-turn config sync (profile home override still active).
current["config_model_seen"] = _config_model_target()
def _announce_built_agent(sid: str, key: str, current: dict, agent) -> None:
"""Post-wiring tail of a build: credits seed, session services, session.info, late MCP catch-up."""
# Credits notices at session OPEN (after notice_callback is wired) so depletion warnings show at "ready".
with contextlib.suppress(Exception):
from agent.credits_tracker import seed_credits_at_session_start
seed_credits_at_session_start(agent)
_start_session_services(sid, key, current)
info = _session_info(agent, current)
if cfg_warn := _probe_config_health(_load_cfg()):
info["config_warning"] = cfg_warn
logger.warning(cfg_warn)
_emit("session.info", sid, info)
# MCP servers slower than _make_agent's bounded discovery wait are missing from the tool
# list; catch up once they land (cache-safe: pre-first-turn only).
_schedule_mcp_late_refresh(sid, agent)
def _finish_agent_build(sid: str, key: str, current: dict, *, notify_registered: bool, scopes, session_db) -> None:
"""Release build scopes and settle ownership of the late notify registration + dedicated db handle."""
if scopes is not None:
_release_build_profile_scopes(scopes)
# _attach_worker already closed the worker if this session was reaped mid-build; only the
# late notify registration can still leak (session.close unregistered before _build registered).
with _sessions_lock:
replaced = _sessions.get(sid) is not current
if replaced and notify_registered:
with contextlib.suppress(Exception):
from tools.approval import unregister_gateway_notify
unregister_gateway_notify(key)
# Dedicated profile handle: hand it to the agent that will actually be torn down, or close
# it when no such agent exists (build failed, or `replaced`: this agent is discarded and
# _teardown_session never reaches it).
if session_db is not None:
built = None if replaced else current.get("agent")
if not _transfer_db_to_agent(built, session_db):
with contextlib.suppress(Exception):
session_db.close()
def _start_agent_build(sid: str, session: dict) -> None:
"""Start building the real AIAgent for a TUI session, once.
Deferred until the first prompt (or any command that needs the agent) so the composer is
responsive instead of blocked on tool discovery / model metadata; the ready/error event
contract for the frontend is unchanged."""
ready = session.get("agent_ready")
if ready is None:
return
# A lazy watch session spectating an in-flight child must stay lazy so the subagent
# live-mirror keeps flowing (the mirror bails once agent is set); incidental RPCs resolve
# through _sess() and would otherwise upgrade it mid-stream. Lifts once the child completes.
if session.get("lazy") and _child_run_active(str(session.get("session_key") or "")):
return
lock = session.setdefault("agent_build_lock", threading.Lock())
with lock:
if ready.is_set() or session.get("agent_build_started"):
return
session["agent_build_started"] = True
session.pop("lazy", None) # now genuinely mid-construction: restore the "still starting" eviction exemption
key = session["session_key"]
def _build() -> None:
with _sessions_lock:
current = _sessions.get(sid)
if current is None:
ready.set()
return
notify_registered = False
scopes = None
session_db = None
profile_home = current.get("profile_home")
try:
if not _await_resume_history(sid, current):
return
tokens = _set_session_context(key)
# Build against the session's profile (global-remote): bind its HERMES_HOME so
# config/skills/model resolve to it, and hand the agent that profile's db. The handle
# is DEDICATED and ours until _transfer_db_to_agent in the finally; FAIL CLOSED on open
# failure rather than binding the launch DB and bleeding rows into the wrong state.db.
if profile_home:
scopes = _bind_build_profile_scopes(profile_home)
session_db = _open_profile_session_db(profile_home)
try:
from tui_gateway.entry import ensure_mcp_discovery_started
ensure_mcp_discovery_started()
except Exception:
logger.warning("MCP discovery startup failed", exc_info=True)
try:
agent = _make_agent(sid, key, **_deferred_build_agent_kwargs(current, session_db))
finally:
_clear_session_context(tokens)
_attach_built_agent(current, agent)
# No eager slash-worker pre-warm (slash.exec spawns on demand): each worker forks the
# full stdio MCP fleet, and live-transport sessions are never reaped, so fleets would accumulate.
notify_registered = _wire_session_agent(sid, key, agent)
_announce_built_agent(sid, key, current, agent)
except Exception as e:
current["agent_error"] = str(e)
_emit("error", sid, {"message": f"agent init failed: {e}"})
finally:
_finish_agent_build(
sid, key, current, notify_registered=notify_registered, scopes=scopes, session_db=session_db)
ready.set()
build_thread = threading.Thread(target=_build, daemon=True)
# Handle for _wait_agent_for_prompt: a dead build thread with agent_ready still unset means
# the build died hard — waiters must not sit out the full cap on a corpse.
session["_agent_build_thread"] = build_thread
build_thread.start()
def _sess_nowait(params, rid):
sid = params.get("session_id") or ""
s = _sessions.get(sid)
if s:
return (s, None)
# Stale runtime id (orphan-reaped, LRU-evicted, idle-TTL torn down); the client should
# session.resume the STORED id. Logged so "message vanished" reads as "arrived and was rejected".
logger.warning(
"session-scoped RPC rejected: method=%s session_id=%r not in memory "
"(detached/reaped runtime; client should resume the stored session), rid=%r",
_current_rpc_method.get() or "?", sid, rid)
return (None, _err(rid, 4001, "session not found"))
def _sess(params, rid):
s, err = _sess_building(params, rid)
return (None, err) if err else (s, _wait_agent(s, rid))
def _sess_building(params, rid):
"""Resolve a session and warm its agent build WITHOUT waiting for it.
For the attach RPCs (image/file/pdf attach, clipboard.paste, image.detach), which only touch
record fields populated at creation. They run inline on the socket reader thread, so waiting on
a cold build there stalled the paste AND every RPC behind it ("text is instant, images hang").
"""
s, err = _sess_nowait(params, rid)
if err:
return (None, err)
_start_agent_build(params.get("session_id") or "", s)
return (s, None)
# ── Config I/O ────────────────────────────────────────────────────────
_DASHBOARD_TURN_ISOLATION_DEFAULT = False
_DASHBOARD_COMPUTE_HOST_HEARTBEAT_SECS_DEFAULT = 15
_DASHBOARD_COMPUTE_HOST_RESPAWN_MAX_DEFAULT = 3
def _coerce_int_config_value(value: Any, default: int, *, min_value: int) -> int:
try:
coerced = int(value)
except (TypeError, ValueError):
return default
return coerced if coerced >= min_value else default
def _load_dashboard_process_isolation_config(cfg: dict | None = None) -> dict[str, Any]:
"""Dashboard process-isolation config with read-site defaults: ``_load_cfg()`` does not
deep-merge DEFAULT_CONFIG, so the Phase-0 defaults live here to stay in step with the REST editor."""
root = _load_cfg() if cfg is None else cfg
dashboard = root.get("dashboard") if isinstance(root, dict) else {}
if not isinstance(dashboard, dict):
dashboard = {}
return {
"turn_isolation": is_truthy_value(dashboard.get("turn_isolation"), default=_DASHBOARD_TURN_ISOLATION_DEFAULT),
"compute_host_heartbeat_secs": _coerce_int_config_value(
dashboard.get("compute_host_heartbeat_secs"), _DASHBOARD_COMPUTE_HOST_HEARTBEAT_SECS_DEFAULT, min_value=1,
),
"compute_host_respawn_max": _coerce_int_config_value(
dashboard.get("compute_host_respawn_max"), _DASHBOARD_COMPUTE_HOST_RESPAWN_MAX_DEFAULT, min_value=0,
),
}
def _active_config_path() -> Path:
"""config.yaml of the per-session profile override (session.resume) when bound, else the launch home."""
override = get_hermes_home_override()
return Path(override if isinstance(override, str) and override else _hermes_home) / "config.yaml"
def _load_cfg_raw() -> dict:
"""The active profile's config.yaml EXACTLY as written — the write-back primitive.
ONLY for read→mutate→``_save_cfg`` round-trips and raw inspection: defaults, the managed overlay
or ``${VAR}`` expansion applied here would be persisted into the user's file on the next save.
Behavioral reads use :func:`_load_cfg`. Cache keyed on the resolved path so profiles don't clobber.
"""
global _cfg_cache, _cfg_mtime, _cfg_path
try:
p = _active_config_path()
mtime = p.stat().st_mtime if p.exists() else None
with _cfg_lock:
if _cfg_cache is not None and _cfg_mtime == mtime and _cfg_path == p:
return copy.deepcopy(_cfg_cache)
if p.exists():
from hermes_cli.config import read_user_config_raw
data = read_user_config_raw(p)
else:
data = {}
with _cfg_lock: # cache the RAW config: _save_cfg writes _cfg_cache back to disk
_cfg_cache = copy.deepcopy(data)
_cfg_mtime = mtime
_cfg_path = p
return data
except Exception:
return {}
def _expand_cfg(cfg: dict) -> dict:
"""``${ENV_VAR}`` expansion (same as ``load_config_readonly``); non-dict results keep the input."""
from hermes_cli.config import _expand_env_vars
expanded = _expand_env_vars(cfg)
return expanded if isinstance(expanded, dict) else cfg
def _load_cfg() -> dict:
"""Behavioral config read: raw user file + managed overlay + ${VAR} expansion.
Same read-side pipeline as ``hermes_cli.config.load_config_readonly`` minus the DEFAULT_CONFIG
merge (callers treat a missing key as "unset"; merging would break ``_load_cfg() == {}``
sentinels). Never pass the result to ``_save_cfg`` — use ``_load_cfg_raw()`` for write-back."""
cfg = _apply_managed(_load_cfg_raw())
with contextlib.suppress(Exception):
cfg = _expand_cfg(cfg)
return cfg
def _apply_managed(cfg: dict) -> dict:
"""Overlay administrator-pinned managed-scope values (read-side only, fail-open): this backend
builds config independently of load_config, so a managed skin / reasoning_effort / service_tier
/ provider_routing would otherwise be silently ignored."""
try:
from hermes_cli import managed_scope
return managed_scope.apply_managed_overlay(cfg if isinstance(cfg, dict) else {})
except Exception:
return cfg
def _save_cfg(cfg: dict):
global _cfg_cache, _cfg_mtime, _cfg_path
from utils import atomic_roundtrip_yaml_save
path = _active_config_path()
# Comment-, ordering- and Unicode-preserving write (a plain safe_dump clobbered hand-written
# configs); fails closed on an unreadable existing config.yaml like atomic_config_write.
atomic_roundtrip_yaml_save(path, cfg)
with _cfg_lock:
_cfg_cache, _cfg_path = copy.deepcopy(cfg), path
try:
_cfg_mtime = path.stat().st_mtime
except Exception:
_cfg_mtime = None
def _session_for_key(session_key: str) -> dict | None:
"""First live record with this session_key (snapshot under the lock: pool handlers mutate ``_sessions``)."""
with _sessions_lock:
return next((s for s in list(_sessions.values()) if s.get("session_key") == session_key), None)
def _cwd_for_session_key(session_key: str) -> str:
"""Reverse-map session_key to the session's logical cwd ("" when unknown)."""
sess = _session_for_key(session_key) if session_key else None
return str(sess.get("cwd") or "") if sess is not None else ""
def _set_session_context(session_key: str, cwd: str | None = None, *, ui_session_id: str = "") -> list:
try:
from gateway.session_context import set_session_vars
# Ephemeral task ids aren't in `_sessions` (reverse-map → "" would clear the cwd override);
# callers that know the workspace pass it.
resolved = cwd if cwd is not None else _cwd_for_session_key(session_key)
source = _resolve_session_platform()
browser_control_principal = ""
browser_control_transport_family = ""
# Live conversation id for subprocess HERMES_SESSION_ID: an explicitly empty contextvar is
# authoritative for the subprocess-env bridge (no os.environ fallback), so never leave it "".
# Prefer the agent's durable session_id, then session_key (same derivation as session-finalize).
session_id = session_key
if (sess := _session_for_key(session_key)) is not None:
source = _session_source(sess)
session_id = getattr(sess.get("agent"), "session_id", None) or session_key
identity = getattr(sess.get("transport"), "auth_identity", None)
if _methods_browser_control._is_authenticated_identity(identity):
browser_control_principal = _methods_browser_control._principal_digest(identity)
browser_control_transport_family = _methods_browser_control._CLOUD_TRANSPORT_FAMILY
return set_session_vars(
session_key=session_key, session_id=session_id, source=source,
browser_control_principal=browser_control_principal,
browser_control_transport_family=browser_control_transport_family, cwd=resolved,
ui_session_id=ui_session_id, cron_session="")
except Exception:
return []
def _clear_session_context(tokens: list) -> None:
if not tokens:
return
with contextlib.suppress(Exception):
from gateway.session_context import clear_session_vars
clear_session_vars(tokens)
def _enable_gateway_prompts() -> None:
"""Route approvals through gateway callbacks instead of CLI input()."""
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_INTERACTIVE"] = "1"
# ── Blocking prompt factory ──────────────────────────────────────────
# Blocking bridges whose `*.respond` tolerates a late reply (allow_expired=True): on timeout the
# tool returns empty, but a slow renderer can still answer and would otherwise hit a raw 4009,
# so `.expire` tells it to tear the card down.
_EXPIRING_REQUESTS = frozenset({
"secret.request", "sudo.request", "clarify.request", "terminal.read.request",
"preview.read.request", "preview.act.request", "window.read.request", "mcp.setup.request",
"tour.request",
})
def _block(
event: str, sid: str, payload: dict, timeout: float | None = 300, batch_qids: list[str] | None = None,
) -> str:
rid = uuid.uuid4().hex[:8]
ev = threading.Event()
with _prompt_lock:
_pending[rid] = (sid, ev)
payload["request_id"] = rid
_pending_prompt_payloads[rid] = (event, dict(payload))
if batch_qids:
# Multi-question clarify: per-question answers accumulate here (update-in-place until
# every qid is locked). Locked answers survive a timeout — see the batch read-out below.
_batch_clarify[rid] = {"qids": list(batch_qids), "answers": {}}
answered = False
batch_answers: dict | None = None
try:
_emit(event, sid, payload)
# Natural Event semantics: None → wait forever (clarify_timeout <= 0, released only by a
# real answer or session.interrupt), 0 → return immediately, > 0 → bounded wait.
answered = ev.wait(timeout)
finally:
with _prompt_lock:
_pending.pop(rid, None)
_pending_prompt_payloads.pop(rid, None)
answer_present = rid in _answers
answer = _answers.pop(rid, "")
batch_state = _batch_clarify.pop(rid, None)
if batch_state is not None:
batch_answers = dict(batch_state["answers"])
expire = lambda: _emit(f"{event.removesuffix('.request')}.expire", sid, {"request_id": rid})
if batch_qids is not None:
# Cancel-all (respond with no question_id) resolves via _answers with an empty string —
# that stays a plain cancel, not a partial result.
if answer_present:
return answer
result: dict[str, object] = {"answers": batch_answers or {}}
if not answered:
# Deadline hit: keep whatever was locked, tell the tool the rest are absences (not
# skips), and still fire the expire notification so live cards tear down.
result["timed_out"] = True
expire()
return json.dumps(result, ensure_ascii=False)
if not answered and not answer_present and event in _EXPIRING_REQUESTS:
expire()
return answer
def _clarify_timeout_seconds() -> float | None:
"""Clarify wait for the TUI/desktop bridge from the canonical config (gateway/CLI parity); 300s
historical default if config can't be read; ``<= 0`` = unlimited → None (never auto-skip)."""
try:
from tools.clarify_gateway import get_clarify_timeout
timeout = get_clarify_timeout()
return timeout if timeout > 0 else None
except Exception:
return 300
def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str:
"""Bridge the clarify tool callback onto _block.
Single-question payloads keep their exact historical shape (``multi_select`` only when True,
so older renderers never see a new field). Batch calls emit one clarify.request with only the
wire fields (qid/question/choices/multi_select) — the tool-side entries also carry
result-assembly keys (id, choices_offered) the renderer must not see."""
if questions:
wire = [
{"qid": e["qid"], "question": e["question"], "choices": e["choices"], "multi_select": bool(e["multi_select"])}
for e in questions
]
return _block(
"clarify.request", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(),
batch_qids=[entry["qid"] for entry in questions])
payload = {"question": q, "choices": c, "multi_select": True} if multi_select else {"question": q, "choices": c}
return _block("clarify.request", sid, payload, timeout=_clarify_timeout_seconds())
# A tour action is a DOM operation the renderer answers in milliseconds; the generous deadline
# exists only because a preview tour's first action injects the engine into a live page.
_TOUR_TIMEOUT_S = 45
# Until a session's client has proven it answers at all, hold it to a deadline a working
# renderer cannot miss. See _tour_request.
_TOUR_PROBE_TIMEOUT_S = 10
_TOUR_BRIDGE_UNAVAILABLE = json.dumps(
{
"success": False,
"error": (
"No Hermes Desktop window answered the tour request. The tour is "
"driven by the desktop app's renderer, which updates separately "
"from this backend, so an app build older than the tour tool has "
"nothing listening. Update the Hermes Desktop app and start a new "
"session. Do not retry tour in this session."),
})
def _tour_request(sid: str, payload: dict) -> str:
"""Bridge the tour tool callback onto _block without paying for a client that cannot answer.
Renderer and backend update on different clocks: against an older app nobody calls
``tour.respond`` and each action would block the full deadline, stacking per turn. So the
session's first action gets the short probe deadline; an unanswered probe marks the bridge
unavailable for that session; once a client has answered, actions get the full deadline. The
verdict lives on the session record, so a new session re-probes."""
session = _sessions.get(sid) or {} # detached caller: throwaway record, plain bridge, unprobed
state = session.get("tour_bridge")
if state == "unanswered":
return _TOUR_BRIDGE_UNAVAILABLE
answer = _block(
"tour.request", sid, dict(payload),
timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S)
if answer:
session["tour_bridge"] = "answered"
elif state != "answered":
session["tour_bridge"] = "unanswered"
return answer or _TOUR_BRIDGE_UNAVAILABLE
def _clear_pending(sid: str | None = None) -> None:
"""Release pending prompts with an empty answer: only *sid*'s (session.interrupt must not cancel
clarify/sudo/secret prompts on unrelated sessions), or every one when *sid* is None (shutdown)."""
with _prompt_lock:
for rid, (owner_sid, ev) in list(_pending.items()):
if sid is None or owner_sid == sid:
_answers[rid] = ""
ev.set()
# ── Agent factory ────────────────────────────────────────────────────
def _env_model_seed() -> str:
"""The launch-scoped model seed (``hermes --tui -m``, hosted provisioning); "" when unset."""
return (os.environ.get("HERMES_MODEL", "") or os.environ.get("HERMES_INFERENCE_MODEL", "")).strip()
def _resolve_model() -> str:
if env := _env_model_seed():
return env
m = _load_cfg().get("model", "")
if isinstance(m, dict):
return str(m.get("default", "") or "").strip()
if isinstance(m, str) and m:
return m.strip()
# No env seed and no config preference: the cost-safe silent default (catalog-labeled,
# cache-only read), never an expensive flagship the user didn't pick.
try:
from hermes_cli.models import get_preferred_silent_default_model
return get_preferred_silent_default_model()
except Exception:
return "z-ai/glm-5.2"
def _resolve_session_platform() -> str:
"""Platform tag for a tui_gateway-routed session: ``HERMES_DESKTOP=1`` without
``HERMES_DESKTOP_TERMINAL`` → "desktop" (chat panel; the agent then suggests TUI-only slash
commands), else "tui" (embedded terminal pane or standalone ``hermes --tui``)."""
desktop = is_truthy_value(os.environ.get("HERMES_DESKTOP"))
return "desktop" if desktop and not is_truthy_value(os.environ.get("HERMES_DESKTOP_TERMINAL")) else "tui"
def _resolve_session_source(explicit: str | None) -> str:
"""Session DB ``source``: an explicit caller value (plugin session tagged ``"telegram"``) is
never rewritten; only empty/None falls back to the env-resolved platform."""
return explicit or _resolve_session_platform()
def _resolve_agent_platform(source: str | None) -> str:
return _resolve_session_source(source)
def _config_model_target() -> tuple[str, str]:
"""(model, provider) selected by config.yaml — and ONLY config.
Never reads HERMES_MODEL / HERMES_INFERENCE_MODEL: that launch-scoped seed fed into the
per-turn sync would be replayed as a /model switch and persisted globally, or pin the session
so dashboard/CLI model changes never reach an open chat. Empty model = "no preference" → no-op sync.
"""
cfg_model = _load_cfg().get("model")
if isinstance(cfg_model, dict):
provider = str(cfg_model.get("provider") or "").strip()
return str(cfg_model.get("default", "") or "").strip(), "" if provider.lower() == "auto" else provider
return (cfg_model.strip() if isinstance(cfg_model, str) else ""), ""
def _resolve_startup_runtime() -> tuple[str, str | None]:
model = _resolve_model()
if explicit_provider := os.environ.get("HERMES_TUI_PROVIDER", "").strip():
return model, explicit_provider
explicit_model = _env_model_seed()
if not explicit_model:
return model, None
with contextlib.suppress(Exception):
from hermes_cli.models import detect_static_provider_for_model
cfg = _load_cfg().get("model") or {}
current_provider = (
(str(cfg.get("provider") or "").strip().lower() if isinstance(cfg, dict) else "")
or os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip().lower()
or "auto")
if detected := detect_static_provider_for_model(explicit_model, current_provider):
provider, detected_model = detected
return detected_model, provider
return model, None
# Bare billing buckets are not routable provider identities; restoring one as a session
# provider override breaks resume. ``openrouter`` is deliberately NOT in this set (fully
# routable) — agent_init's fail-fast gate is a different set that skips it.
from hermes_state import _BARE_BILLING_PROVIDERS
def _is_routable_provider(provider: str) -> bool:
try:
from hermes_cli.runtime_provider import is_routable_provider
return is_routable_provider(provider)
except Exception:
return False
def _overrides_have_routable_provider(overrides: dict) -> bool:
"""Whether persisted runtime overrides still name a routable provider (renamed/removed →
"Unknown provider" at agent init). Empty counts as NOT routable, so the caller falls back to the
session's picked model / configured default instead of a provider-less snapshot."""
provider = str(overrides.get("provider_override") or "").strip()
if not provider:
provider = str((overrides.get("model_override") or {}).get("provider") or "").strip()
return bool(provider) and _is_routable_provider(provider)
def _parse_model_config(raw, *, quiet: bool = False) -> dict:
"""A row's ``model_config`` (dict or JSON text) as a dict; ``{}`` when absent/invalid."""
if isinstance(raw, dict):
return raw
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except Exception:
if not quiet:
raise
logger.debug("failed to parse stored session model_config", exc_info=True)
return {}
def _stored_session_runtime_overrides(row: dict | None) -> dict:
"""Runtime fields persisted with a stored session (model column, ``billing_provider``, JSON ``model_config``).
``session.resume`` is session-scoped: reopening an older chat restores the model/provider/
reasoning THAT chat used, not the global model most recently selected elsewhere.
Plugin-owned Bot-Mode sessions are exempt and always rebuild from the member profile's CURRENT
config (a stale provider pin left room bots failing "out of Nous credits" after a profile
switch). Signals, in order: explicit ``room_plumbing`` / ``follow_profile_config`` markers;
the legacy hidden + "Group:" title shape; the title exactly "Bot Chat" (UNIQUE(title))."""
if not row:
return {}
model_config = _parse_model_config(row.get("model_config"), quiet=True)
_row_title = str(row.get("title") or "").strip()
if (
model_config.get("room_plumbing")
or (row.get("hidden") and _row_title.startswith("Group:"))
or model_config.get("follow_profile_config")
or _row_title == "Bot Chat"):
return {}
overrides: dict = {}
model = str(row.get("model") or model_config.get("model") or "").strip()
# ``billing_provider`` is only the billing bucket — for a custom endpoint the bare class
# "custom", which agent_init treats as non-routable ("No LLM provider configured" on resume).
# Only restore an explicit provider; otherwise resume falls back to the configured default.
provider = str(model_config.get("provider") or "").strip()
billing_provider = str(model_config.get("billing_provider") or row.get("billing_provider") or "").strip()
if not provider and billing_provider.lower() not in _BARE_BILLING_PROVIDERS:
provider = billing_provider
base_url = str(model_config.get("base_url") or "").strip()
api_mode = str(model_config.get("api_mode") or "").strip()
reasoning_config = model_config.get("reasoning_config")
service_tier = str(model_config.get("service_tier") or "").strip()
# Heal a stale provider name persisted by an older build (renamed/removed custom provider →
# "Unknown provider"): recover the durable ``custom:<name>`` key from the stored base_url, then
# from the entry serving the stored model; when nothing names a real entry, drop the provider.
if provider and not _is_routable_provider(provider):
healed = None
try:
from hermes_cli.runtime_provider import canonical_custom_identity
healed = canonical_custom_identity(base_url=base_url or None, model=model or None)
except Exception:
logger.debug("custom provider identity recovery failed", exc_info=True)
if healed:
logger.info("healed stale session provider %r to %r", provider, healed)
provider = healed
base_url = "" # the healed identity owns a registered endpoint; the snapshot URL must not override it
else:
provider = ""
if model:
# Same dict-shaped override live /model switches use, so a DB-restored session keeps custom
# endpoint metadata across resume and rebuilds (/new). Raw api_key is never persisted/restored.
overrides["model_override"] = {
"model": model, "provider": provider or None, "base_url": base_url or None, "api_mode": api_mode or None,
}
if provider:
overrides["provider_override"] = provider
if isinstance(reasoning_config, dict):
overrides["reasoning_config_override"] = reasoning_config
if service_tier.lower() == "normal":
# None = "inherit the profile" at _make_agent; "" = real override "no priority tier".
overrides["service_tier_override"] = ""
elif service_tier:
overrides["service_tier_override"] = service_tier
return overrides
def _runtime_model_config(agent, existing: dict | None = None) -> dict:
"""Merge the agent's CURRENT runtime identity onto the row's persisted ``model_config``.
Falsy agent attributes DELETE the key rather than skip the write, so a stale value can never
survive the merge: ``_persist_live_session_runtime`` writes the model column separately and
resume reads provider/endpoint from this JSON — a stale provider would silently route the
resumed chat to the wrong endpoint while the model column claimed the new one."""
config = dict(existing or {})
model = str(getattr(agent, "model", "") or "").strip()
provider = str(getattr(agent, "provider", "") or "").strip()
base_url = str(getattr(agent, "base_url", "") or "").strip()
if provider.lower() == "custom":
# ``agent.provider`` resolves every named custom entry to the literal "custom", losing the
# entry identity (api_key is never persisted, so resume couldn't re-resolve credentials).
# Recover the canonical ``custom:<name>`` key from the endpoint URL, else the configured provider.
try:
from hermes_cli.runtime_provider import canonical_custom_identity
provider = canonical_custom_identity(base_url=base_url, model=model or None) or provider
except Exception:
logger.debug("custom provider identity lookup failed", exc_info=True)
reasoning_config = getattr(agent, "reasoning_config", None)
live = {
"model": model, "provider": provider, "base_url": base_url,
"api_mode": str(getattr(agent, "api_mode", "") or "").strip(),
# An empty dict is still a real (present) reasoning config.
"reasoning_config": reasoning_config if isinstance(reasoning_config, dict) else None,
"service_tier": getattr(agent, "service_tier", None),
}
for key, value in live.items():
if value or isinstance(value, dict):
config[key] = value
else:
config.pop(key, None)
return config
def _persist_live_session_runtime(session: dict | None) -> None:
"""Persist active session runtime so future resumes restore the same footer."""
if not session:
return
agent = session.get("agent")
session_key = str(session.get("session_key") or "").strip()
if agent is None or not session_key:
return
db = getattr(agent, "_session_db", None) or _get_db()
if db is None:
return
try:
row = db.get_session(session_key) or {}
model_config = _runtime_model_config(agent, _parse_model_config(row.get("model_config")))
create_service_tier_override = session.get("create_service_tier_override")
if create_service_tier_override is not None:
# _runtime_model_config sees agent.service_tier=None for explicit normal and would
# otherwise erase the distinction on every live metadata persist.
model_config["service_tier"] = create_service_tier_override or "normal"
model = str(getattr(agent, "model", "") or "").strip()
if hasattr(db, "update_session_meta"):
db.update_session_meta(session_key, json.dumps(model_config), model or None)
elif model and hasattr(db, "update_session_model"):
db.update_session_model(session_key, model)
except Exception:
logger.debug("failed to persist live session runtime", exc_info=True)
def _persist_live_session_system_prompt(session: dict | None) -> None:
"""Refresh the stored system prompt after a live runtime identity change."""
if not session:
return
agent = session.get("agent")
session_key = str(session.get("session_key") or "").strip()
if agent is None or not session_key or not hasattr(agent, "_build_system_prompt"):
return
db = getattr(agent, "_session_db", None) or _get_db()
if db is None or not hasattr(db, "update_system_prompt"):
return
# Re-bind HERMES_HOME to the session's profile (the build's finally reset it; the rebuilt
# prompt would use the root profile's SOUL.md/skills) and the session context (on the RPC
# thread _SESSION_CWD is unset, so the prompt would persist the process TERMINAL_CWD).
profile_home = session.get("profile_home")
home_token = set_hermes_home_override(profile_home) if profile_home else None
session_tokens = _set_session_context(session_key, cwd=_session_cwd(session))
try:
prompt = agent._build_system_prompt(None)
agent._cached_system_prompt = prompt
db.update_system_prompt(getattr(agent, "session_id", None) or session_key, prompt)
except Exception:
logger.warning(
"failed to persist live session system prompt for session %s", session_key,
exc_info=True)
finally:
_clear_session_context(session_tokens)
if home_token is not None:
reset_hermes_home_override(home_token)
# Stable leading text of the model-switch marker (builder + dedup); only the newest marker is meaningful.
_MODEL_SWITCH_MARKER_PREFIX = "[System: The active model for this chat has changed to "
def _is_model_switch_marker(entry: Any) -> bool:
"""Whether a history entry is a (self-replacing) model-switch marker."""
if not isinstance(entry, dict):
return False
content = entry.get("content")
return isinstance(content, str) and content.startswith(_MODEL_SWITCH_MARKER_PREFIX)
def _is_pivot_marker(entry: Any) -> bool:
"""Whether a history entry is a ``role=user`` pivot the gateway splices in mid-turn (model switch
or personality change) — either can be the sole reason turn-start and current history differ.
Only the model-switch marker is self-replacing, so :func:`_append_model_switch_marker` dedups narrower."""
return _is_model_switch_marker(entry) or (isinstance(entry, dict) and entry.get("display_kind") == "personality_switch")
def _append_model_switch_marker(session: dict | None, *, model: str, provider: str) -> None:
"""Record a real system-history pivot after a live model switch.
Only the newest marker is kept: each switch strips prior model-switch markers first, so N
switches leave one marker rather than N stale ones re-sent on every API call; self-healing
across resumes because the next switch collapses whatever a reload brought back."""
if not session:
return
session_key = str(session.get("session_key") or "").strip()
if not session_key:
return
provider_part = f" via provider {provider}" if provider else ""
marker = (
f"{_MODEL_SWITCH_MARKER_PREFIX}{model}{provider_part}. From this point forward, use this runtime "
"metadata when answering questions about what model/provider is active.]")
# A user message, not system: strict OpenAI-compatible providers (vLLM, Qwen) reject
# non-leading system messages.
entry = {"role": "user", "content": marker, "display_kind": "model_switch"}
with session.get("history_lock") or contextlib.nullcontext():
history = session.setdefault("history", [])
history[:] = [h for h in history if not _is_model_switch_marker(h)]
history.append(entry)
session["history_version"] = int(session.get("history_version", 0)) + 1
try:
agent = session.get("agent")
db = getattr(agent, "_session_db", None) if agent is not None else None
if db is None:
_ensure_session_db_row(session)
with _session_db(session) as db:
if db is not None:
db.append_message(session_id=session_key, role="user", content=marker, display_kind="model_switch")
else:
db.append_message(session_id=session_key, role="user", content=marker, display_kind="model_switch")
except Exception:
logger.debug("failed to persist model switch marker", exc_info=True)
def _write_config_key(key_path: str, value):
# Write-back round-trip: raw read is mandatory — saving the managed-overlaid / env-expanded
# view would persist those values into the file.
cfg = current = _load_cfg_raw()
*parents, leaf = key_path.split(".")
for key in parents:
if not isinstance(current.get(key), dict):
current[key] = {}
current = current[key]
current[leaf] = value
_save_cfg(cfg)
_STATUSBAR_MODES = frozenset({"off", "top", "bottom"})
_APPROVAL_MODES = frozenset({"manual", "smart", "off"})
# Appearance switches the renderer owns but the AGENT must see (each gates a tool's `check_fn`),
# so the toggle must reach whichever gateway the app talks to. `config.set` answers 4002 for
# unlisted keys — a mirrored switch missing here writes nothing and its tool stays dark.
_DISPLAY_TOGGLE_KEYS = frozenset({"display.message_reactions", "display.in_app_tips", "display.in_app_tours"})
_BOOL_WORDS = {
"1": True, "on": True, "true": True, "yes": True, "0": False, "off": False, "false": False, "no": False,
}
def _load_approval_mode() -> str:
"""Effective ``approvals.mode`` via ``tools.approval._get_approval_mode`` so it cannot drift from
the gate's own view (a raw-config re-read missed the managed overlay and ``${VAR}`` expansion)."""
from tools.approval import _get_approval_mode
mode = _get_approval_mode()
return mode if mode in _APPROVAL_MODES else "manual"
def _coerce_statusbar(raw) -> str:
if raw is False:
return "off"
return s if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES else "top"
_MOUSE_TRACKING_ALIASES = {
"0": "off", "1": "all", "all": "all", "any": "all", "button": "buttons", "buttons": "buttons",
"click": "buttons", "false": "off", "full": "all", "no": "off", "off": "off", "on": "all",
"scroll": "wheel", "true": "all", "wheel": "wheel", "yes": "all",
}
def _display_mouse_tracking(display: dict) -> str:
"""display.mouse_tracking → ``off|wheel|buttons|all``. Booleans keep their legacy meaning
(True → all, False → off); ``wheel`` (DEC 1000+1006) is the tmux-friendly subset without hover
events that trigger prompt-row clipboard probes. Legacy ``tui_mouse`` only when ``mouse_tracking`` is absent."""
if not isinstance(display, dict):
return "all"
raw = display.get("mouse_tracking") if "mouse_tracking" in display else display.get("tui_mouse", True)
if raw is False or raw == 0:
return "off"
if isinstance(raw, str):
return _MOUSE_TRACKING_ALIASES.get(raw.strip().lower(), "all")
return "all"
def _load_reasoning_config(model: str = "") -> dict | None:
"""Reasoning effort via the shared chokepoint :func:`hermes_constants.resolve_reasoning_config`
(per-model override > global ``agent.reasoning_effort``; YAML False = disabled)."""
from hermes_constants import resolve_reasoning_config
return resolve_reasoning_config(_load_cfg(), model)
_SERVICE_TIER_ALIASES = {"fast": "priority", "priority": "priority", "on": "priority", "auto": "auto", "cold": "cold"}
def _load_service_tier() -> str | None:
raw = str((_load_cfg().get("agent") or {}).get("service_tier", "") or "").strip().lower()
return _SERVICE_TIER_ALIASES.get(raw)
def _load_provider_routing() -> dict:
"""OpenRouter ``provider_routing`` prefs (gateway/CLI parity — without them OpenRouter picks an
effectively random provider even when the user configured routing)."""
try:
return _load_cfg().get("provider_routing", {}) or {}
except Exception:
return {}
def _load_show_reasoning() -> bool:
# Fallback True — keep in sync with DEFAULT_CONFIG display.show_reasoning (no DEFAULT_CONFIG merge here).
return bool(_display_cfg().get("show_reasoning", True))
def _load_memory_notifications() -> str:
"""``display.memory_notifications`` (``off`` / ``on`` default / ``verbose``; bool normalized) — gates
the "💾 Self-improvement review" summary, gateway/CLI parity (this backend used to ignore ``off``)."""
raw = _display_cfg().get("memory_notifications")
if isinstance(raw, bool):
return "on" if raw else "off"
return str(raw).lower() if raw else "on"
_TOOL_PROGRESS_MODES = frozenset({"off", "new", "all", "verbose"})
def _load_tool_progress_mode() -> str:
env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower()
if env in _TOOL_PROGRESS_MODES:
return env
raw = _display_cfg().get("tool_progress", "all")
if raw is False:
return "off"
if raw is True:
return "all"
mode = str(raw or "all").strip().lower()
return mode if mode in _TOOL_PROGRESS_MODES else "all"
def _gui_surface_toolsets(platform: str) -> set[str]:
"""Toolsets that exist because of the CLIENT, not the host — both off ``_HERMES_CORE_TOOLS``, so this
is the one gate exposing them. ``platform`` is the SESSION's source, never a process env var: the
desktop may drive a URL/cloud backend where ``HERMES_DESKTOP`` is unset (see AGENTS.md surface rule)."""
return {"project", "desktop_ui"} if platform == "desktop" else {"project"}
def _enabled_mcp_server_names() -> tuple[set[str], set[str]]:
"""(enabled, disabled) MCP server names from raw config; empty on any failure."""
try:
from hermes_cli.config import read_raw_config
from hermes_cli.tools_config import _parse_enabled_flag
raw_cfg = read_raw_config()
mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {}
enabled, disabled = set(), set()
for name, server_cfg in mcp_servers.items():
if isinstance(server_cfg, dict):
on = _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
(enabled if on else disabled).add(str(name))
return enabled, disabled
except Exception:
return set(), set()
def _tui_notice(text: str) -> None:
print(text, file=sys.stderr, flush=True)
def _resolve_explicit_toolsets(explicit: list[str], validate_toolset) -> list[str] | None | bool:
"""Resolve a HERMES_TUI_TOOLSETS pin: list, None for "all", False when nothing was valid."""
built_in = [name for name in explicit if validate_toolset(name)]
unresolved = [name for name in explicit if name not in built_in]
if unresolved:
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
plugin_valid = [name for name in unresolved if validate_toolset(name)]
except Exception:
plugin_valid = []
if plugin_valid:
built_in.extend(plugin_valid)
unresolved = [name for name in unresolved if name not in plugin_valid]
if any(name in {"all", "*"} for name in built_in):
if ignored := [name for name in explicit if name not in {"all", "*"}]:
_tui_notice(
"[tui] HERMES_TUI_TOOLSETS=all enables every toolset; "
f"ignoring additional entries: {', '.join(ignored)}")
return None
if not unresolved:
return built_in
mcp_names, mcp_disabled = _enabled_mcp_server_names()
mcp_valid = [name for name in unresolved if name in mcp_names]
disabled = [name for name in unresolved if name in mcp_disabled]
unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled]
if unknown:
_tui_notice(f"[tui] ignoring unknown HERMES_TUI_TOOLSETS entries: {', '.join(unknown)}")
if disabled:
_tui_notice(
"[tui] ignoring disabled MCP servers in HERMES_TUI_TOOLSETS "
"(set enabled: true in config.yaml to use): "
f"{', '.join(disabled)}")
return (built_in + mcp_valid) or False
def _load_enabled_toolsets(platform: str | None = None) -> list[str] | None:
"""The agent's toolsets for this desktop/TUI session (None = all).
Order: an explicit HERMES_TUI_TOOLSETS pin; else the coding posture (agent/coding_context.py
collapses to the coding toolset + enabled MCP servers inside a code workspace); else the
configured CLI toolsets. The client-surface (pane/project) toolsets are folded in here because
this resolver runs only on the surface that can answer them."""
session_platform = platform or _resolve_session_platform()
explicit = [item.strip() for item in os.environ.get("HERMES_TUI_TOOLSETS", "").split(",") if item.strip()]
fallback_notice = None
if not explicit:
with contextlib.suppress(Exception):
from agent.coding_context import coding_selection
selection = coding_selection(platform=session_platform)
if selection is not None:
return sorted({*selection, *_gui_surface_toolsets(session_platform)})
try:
from toolsets import validate_toolset
except Exception:
validate_toolset = None
if explicit and validate_toolset is not None:
resolved = _resolve_explicit_toolsets(explicit, validate_toolset)
if resolved is not False:
return resolved
fallback_notice = "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets"
try:
from hermes_cli.config import load_config
from hermes_cli.tools_config import _get_platform_tools
cfg = load_config()
# include_default_mcp_servers=True is the runtime variant (the agent must be able to call
# default MCP servers); the config-editing variant would silently drop MCP tools from the TUI.
enabled = _get_platform_tools(cfg, "cli", include_default_mcp_servers=True)
if fallback_notice is not None:
_tui_notice(fallback_notice)
return sorted(enabled | _gui_surface_toolsets(session_platform)) if enabled else None
except Exception:
if fallback_notice is not None:
_tui_notice(
"[tui] no valid HERMES_TUI_TOOLSETS entries and configured CLI toolsets could not be loaded; enabling all toolsets"
)
return None
def _session_tool_progress_mode(sid: str) -> str:
return str(_sessions.get(sid, {}).get("tool_progress_mode", "all") or "all")
def _session_verbose(sid: str) -> bool:
return _session_tool_progress_mode(sid) == "verbose"
def _tool_progress_enabled(sid: str) -> bool:
return _session_tool_progress_mode(sid) != "off"
def _tool_lifecycle_required_for_ui(name: str) -> bool:
"""Tool events that are interactive UI, not optional chrome: Desktop renders clarify / setup_mcp
cards from the tool-call part; suppressing them with progress off would leave only the sidebar dot."""
return name in ("clarify", "setup_mcp")
def _restart_slash_worker(sid: str, session: dict):
worker = session.get("slash_worker")
if worker is None:
return # never spawned one; spawning here would fork the per-worker MCP fleet for nothing
with contextlib.suppress(Exception):
worker.close()
try:
new_worker = _SlashWorker(
session["session_key"], getattr(session.get("agent"), "model", _resolve_model()),
profile_home=session.get("profile_home"))
except Exception:
session["slash_worker"] = None
return
# Store-iff-still-mapped: the post-turn restart races a close_on_disconnect reap, and a
# bare store would orphan the fresh worker.
_attach_worker(sid, session, new_worker)
def _get_usage(agent) -> dict:
g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0)
usage = {
"model": getattr(agent, "model", "") or "",
"input": g("session_input_tokens", "session_prompt_tokens"),
"output": g("session_output_tokens", "session_completion_tokens"),
"reasoning": g("session_reasoning_tokens"), "prompt": g("session_prompt_tokens"),
"completion": g("session_completion_tokens"), "total": g("session_total_tokens"),
"calls": g("session_api_calls"),
}
comp = getattr(agent, "context_compressor", None)
if comp:
# context_used is *current-window* occupancy. Never fall back to usage["total"] (cumulative
# lifetime: an external engine without last_prompt_tokens showed 1.9m/120k clamped to 100%).
# A falsy last_prompt_tokens emits NO gauge rather than a fabricated one; the -1 "compression
# just ran" sentinel is clamped to 0 (matches cli.py _get_status_bar_snapshot).
last_prompt = max(0, getattr(comp, "last_prompt_tokens", 0) or 0)
ctx_max = getattr(comp, "context_length", 0) or 0
if ctx_max and last_prompt:
usage["context_used"] = last_prompt
usage["context_max"] = ctx_max
usage["context_percent"] = max(0, min(100, round(last_prompt / ctx_max * 100)))
usage["compressions"] = getattr(comp, "compression_count", 0) or 0
# Cache-hit ratio + rolling latency/tps (CLI status-bar parity): hit = cache_read / prompt_tokens;
# latency/tps read the per-call deque history. Omitted, not fabricated, when there is no data
# (Codex reports no latency; zero cache reads shows no hit% rather than an alarming 0).
with contextlib.suppress(Exception):
_prompt_total = int(getattr(agent, "session_prompt_tokens", 0) or 0)
_cache_read = int(getattr(agent, "session_cache_read_tokens", 0) or 0)
if _prompt_total > 0 and _cache_read > 0:
usage["cache_hit_pct"] = max(0, min(100, round(_cache_read / _prompt_total * 100)))
with contextlib.suppress(Exception): # a status-bar readout must never break usage reporting
_lhist = list(getattr(agent, "_api_latency_history", []) or [])
_ohist = list(getattr(agent, "_api_output_history", []) or [])
_n = min(len(_lhist), len(_ohist))
if _n:
_lhist = _lhist[-_n:]
_ohist = _ohist[-_n:]
_avg_lat = sum(_lhist) / _n
_total_lat = sum(_lhist)
_avg_vel = (sum(_ohist) / _total_lat) if _total_lat > 0 else None
# Guard NaN/negative/absurd values from odd provider timings.
if _avg_lat == _avg_lat and 0 < _avg_lat < 1e6:
usage["avg_latency_s"] = round(float(_avg_lat), 1)
if _avg_vel is not None and _avg_vel == _avg_vel and 0 < _avg_vel < 1e6:
usage["avg_tps"] = round(float(_avg_vel), 1)
# Live count of background/async subagents (CLI status bar ⛓ parity, same async_delegation registry).
with contextlib.suppress(Exception):
from tools.async_delegation import active_count as _async_active_count
usage["active_subagents"] = _async_active_count()
# Dev-only live credits-spent readout, gated on HERMES_DEV_CREDITS so the payload stays clean otherwise.
if is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")):
with contextlib.suppress(Exception):
spent = agent.get_credits_spent_micros()
if spent is not None:
usage["dev_credits_spent_micros"] = int(spent)
return usage
def _probe_credentials(agent) -> str:
"""Light credential check at session creation — warning or ''. (``no-key-required`` is a valid
sentinel for keyless custom providers; only a genuinely missing key warns.)"""
with contextlib.suppress(Exception):
if not (getattr(agent, "api_key", "") or ""):
provider = getattr(agent, "provider", "") or ""
return f"No API key configured for provider '{provider}'. First message will fail."
return ""
def _probe_config_health(cfg: dict) -> str:
"""Flag bare YAML keys (`agent:` with no value → None) that silently drop nested settings,
and a ``display.personality`` naming no known overlay. Returns warning or ''."""
if not isinstance(cfg, dict):
return ""
warnings: list[str] = []
if null_keys := sorted(k for k, v in cfg.items() if v is None):
keys = ", ".join(f"`{k}`" for k in null_keys)
warnings.append(
f"config.yaml has empty section(s): {keys}. Remove the line(s) or set them to `{{}}` — "
f"empty sections silently drop nested settings.")
display_cfg = cfg.get("display")
if isinstance(display_cfg, dict):
personality = str(display_cfg.get("personality", "") or "").strip().lower()
if personality and personality not in {"default", "none", "neutral"}:
with contextlib.suppress(Exception):
from hermes_cli.personality import available_personalities
if personality not in available_personalities(cfg):
warnings.append(
f"`display.personality: {personality}` does not match any "
"built-in or `agent.personalities` entry; personality "
"overlay will be skipped.")
return " ".join(warnings).strip()
def _current_profile_name() -> str:
try:
from hermes_cli.profiles import get_active_profile_name
return get_active_profile_name() or "default"
except Exception:
return "default"
# Monotonic GUI<->backend contract version: the desktop refuses a backend reporting less (or none)
# with a one-click "update to align" prompt. Bump whenever the desktop's backend contract changes.
# v2 file.attach RPC; v3 approvals.mode RPCs + session.info reconciliation; v4 session.create
# fast=false = explicit normal-tier override; v5 ws_max_size raised for >16 MiB file.attach frames;
# v6 plugins.manage rows carry the canonical registry key (toggles key-addressed).
DESKTOP_BACKEND_CONTRACT = 6
def _session_usage_snapshot(session: dict | None) -> dict:
agent = (session or {}).get("agent")
mirror_usage = _metadata_mirror(session).get("usage")
if (session or {}).get("_compute_host_active") and isinstance(mirror_usage, dict):
return dict(mirror_usage)
if agent is not None:
return _get_usage(agent)
return dict(mirror_usage) if isinstance(mirror_usage, dict) else {}
def _project_info_for_cwd(cwd: str) -> dict | None:
"""The first-class Project owning ``cwd`` (per-profile projects.db, the store the desktop's project
tree caches) so TUI status, desktop status bar and ``/status`` name the workspace identically.
Only explicit named projects resolve; an auto-discovered repo root falls back to the cwd leaf."""
if not str(cwd or "").strip():
return None
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as conn:
project = pdb.project_for_path(conn, cwd)
if project is None:
return None
return {"id": project.id, "slug": project.slug, "name": project.name, "primary_path": project.primary_path}
except Exception:
logger.debug("failed to resolve project for cwd", exc_info=True)
return None
def _turn_started_at(session: dict | None) -> float | None:
"""Epoch seconds the current turn started, or None when idle — lets the desktop keep the
turn-elapsed timer across session switches (cold resume) instead of resetting to 0:00."""
inflight = (session or {}).get("inflight_turn")
return float(inflight["started_at"]) if isinstance(inflight, dict) and inflight.get("started_at") else None
def _effective_approval_state(session_key: str) -> tuple[bool, str]:
"""(yolo, approval_mode): the same three sources check_all_command_guards() ORs — approvals.mode=off,
the process --yolo env, the per-session flag. The session flag alone would show YOLO "off" while
config silently auto-approves every dangerous command."""
try:
from tools.approval import _YOLO_MODE_FROZEN, is_session_yolo_enabled
session_yolo = bool(is_session_yolo_enabled(session_key)) if session_key else False
approval_mode = _load_approval_mode()
return bool(_YOLO_MODE_FROZEN) or session_yolo or approval_mode == "off", approval_mode
except Exception:
return False, "manual"
def _session_info(agent, session: dict | None = None) -> dict:
if session is None:
session = next((c for c in _sessions.values() if c.get("agent") is agent), None)
sess = session or {}
mirror = _metadata_mirror(session)
cwd = _display_session_cwd(session)
session_key = str(sess.get("session_key") or getattr(agent, "session_id", "") or "")
personality = sess.get("personality", _display_cfg().get("personality") or "")
reasoning_config = getattr(agent, "reasoning_config", None)
reasoning_effort = ""
if isinstance(reasoning_config, dict):
# Disabled must differ from unset ("" = provider default), or the desktop adopts "" after
# the first turn and loses "thinking off".
reasoning_effort = "none" if reasoning_config.get("enabled") is False else str(reasoning_config.get("effort", "") or "")
service_tier = getattr(agent, "service_tier", None) or mirror.get("service_tier") or ""
yolo, approval_mode = _effective_approval_state(session_key)
# A switch queued mid-turn applies at the next turn start, so agent.model still reads the OLD
# model; report the pending pick so the end-of-turn settle doesn't blip the UI back first.
pending_switch = sess.get("pending_model_switch") or {}
pending_model = str(pending_switch.get("display_model") or "").strip()
pending_provider = str(pending_switch.get("display_provider") or "").strip()
info: dict = {
"model": pending_model or mirror.get("model", getattr(agent, "model", "")),
"provider": pending_provider or mirror.get("provider", getattr(agent, "provider", "")),
"reasoning_effort": reasoning_effort,
"service_tier": service_tier,
"fast": service_tier == "priority",
"yolo": yolo,
"approval_mode": approval_mode,
"tools": dict(mirror.get("tools") or {}) if isinstance(mirror.get("tools"), dict) else {},
"skills": dict(mirror.get("skills") or {}) if isinstance(mirror.get("skills"), dict) else {},
"cwd": cwd,
"branch": _git_branch_for_cwd(cwd),
"project": _project_info_for_cwd(cwd),
"terminal_backend": _effective_terminal_backend(),
"personality": str(personality or ""),
"running": bool(sess.get("running")),
"turn_started_at": _turn_started_at(session),
"title": _session_live_title(sess, session_key) if session_key else "",
"stored_session_id": session_key or "",
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
"version": "",
"release_date": "",
"update_behind": None,
"update_command": "",
"usage": _session_usage_snapshot(session),
"profile_name": (
_response_profile_name(Path(session["profile_home"]).name)
if isinstance(session, dict) and session.get("profile_home")
else _current_profile_name()),
}
with contextlib.suppress(Exception):
from hermes_cli import __version__, __release_date__
info["version"] = __version__
info["release_date"] = __release_date__
live_agent = agent is not None and not sess.get("_compute_host_active")
if live_agent:
with contextlib.suppress(Exception):
from model_tools import get_toolset_for_tool
info["tools"] = {}
for t in getattr(agent, "tools", []) or []:
name = t["function"]["name"]
info["tools"].setdefault(get_toolset_for_tool(name) or "other", []).append(name)
with contextlib.suppress(Exception):
from hermes_cli.banner import get_available_skills
info["skills"] = get_available_skills()
try:
from tools.mcp_tool import get_mcp_status
info["mcp_servers"] = get_mcp_status()
except Exception:
info["mcp_servers"] = []
with contextlib.suppress(Exception):
info["system_prompt"] = (
mirror.get("system_prompt") if "system_prompt" in mirror else getattr(agent, "_cached_system_prompt", "") or ""
)
with contextlib.suppress(Exception):
from hermes_cli.banner import get_update_result
from hermes_cli.config import recommended_update_command
info["update_behind"] = get_update_result(timeout=0.5)
info["update_command"] = recommended_update_command()
if live_agent and (warn := _probe_credentials(agent)):
info["credential_warning"] = warn
return info
def _tool_ctx(name: str, args: dict) -> str:
"""Argument preview for a tool row — never a phrased label: clients own their phrasing (the TUI
wraps it as ``Terminal("...")``, the desktop prepends a localized verb), so ``build_tool_label``
here would stutter ("Running Running …") and leak a display label into the desktop's ``args.context``."""
try:
from agent.display import build_tool_preview
return build_tool_preview(name, args, max_len=80) or ""
except Exception:
return ""
def _emit_session_info_for_session(sid: str, session: dict) -> None:
agent = session.get("agent")
if agent is not None or _metadata_mirror(session):
with contextlib.suppress(Exception):
_emit("session.info", sid, _session_info(agent, session))
def broadcast_session_info() -> None:
"""Re-emit ``session.info`` to every live session — for approvals-config writers that bypass the
self-re-emitting ``config.set`` RPC (REST config saves, the ``/approvals`` slash mirror). Only
reaches THIS process; a spawned ``tui_gateway.entry`` child gateway has its own ``_sessions``."""
with _sessions_lock:
sessions = list(_sessions.items())
for sid, sess in sessions:
_emit_session_info_for_session(sid, sess)
def _schedule_mcp_late_refresh(sid: str, agent) -> None:
"""Refresh a session's tool snapshot when MCP discovery lands late.
``_make_agent`` waits only a bounded ``mcp_discovery_timeout``, so a slow server (HTTP MCP on
first connect) lands after the build and its tools are missing all session. A daemon joins
discovery, then does the same rebuild ``/reload-mcp`` performs and re-emits ``session.info``.
Cache safety: only while pre-first-turn (nothing cached to invalidate); afterwards the snapshot
stays frozen and late tools need an explicit, consent-gated ``/reload-mcp``."""
try:
from tui_gateway.entry import mcp_discovery_in_flight, join_mcp_discovery
except Exception:
return
if not mcp_discovery_in_flight():
return
def _wait_then_refresh() -> None:
# Bounded but generous — a server still not connected after this is genuinely slow/dead.
if not join_mcp_discovery(timeout=30.0):
return
with _sessions_lock:
session = _sessions.get(sid)
if session is None or session.get("agent") is not agent:
return # closed/reset while we waited
if int(getattr(agent, "_user_turn_count", 0) or 0) > 0 or int(getattr(agent, "_api_call_count", 0) or 0) > 0:
return # conversation started: a rebuild would invalidate the cached prompt prefix
try:
from tools.mcp_tool import refresh_agent_mcp_tools
added = refresh_agent_mcp_tools(agent, quiet_mode=True)
except Exception as exc:
logger.warning("Late MCP refresh: tool snapshot rebuild failed for %s: %s", sid, exc)
return
if not added:
return # discovery added nothing → don't churn the client
info = _session_info(agent, session)
_emit("session.info", sid, info) # outside the lock — write_json must not block under _sessions_lock
threading.Thread(target=_wait_then_refresh, name=f"tui-mcp-late-refresh-{sid}", daemon=True).start()
class _RuntimeFallbackResolution(NamedTuple):
runtime: dict
selected_model: str | None
used_fallback: bool
def _resolve_runtime_with_fallback(resolve_kwargs: dict | None = None) -> _RuntimeFallbackResolution:
"""Resolve the primary runtime or one complete provider/model fallback.
Setup-time auth fallback only accepts entries with both fields: provider-only entries are
skipped so the unavailable primary model can never leak into a different runtime.
``used_fallback`` stays explicit rather than overloading a nullable model as control flow."""
from hermes_cli.auth import AuthError
from hermes_cli.runtime_provider import resolve_runtime_provider
try:
return _RuntimeFallbackResolution(resolve_runtime_provider(**(resolve_kwargs or {})), None, False)
except AuthError as primary_exc:
for entry in _load_fallback_model() or []:
if not isinstance(entry, dict):
continue
fb_provider = str(entry.get("provider") or "").strip()
fb_model = str(entry.get("model") or "").strip()
if not fb_provider or not fb_model:
continue
try:
from hermes_cli.fallback_config import resolve_entry_api_key
fb_kwargs: dict = {"requested": fb_provider, "target_model": fb_model}
if entry.get("base_url"):
fb_kwargs["explicit_base_url"] = entry["base_url"]
if fb_api_key := resolve_entry_api_key(entry):
fb_kwargs["explicit_api_key"] = fb_api_key
runtime = resolve_runtime_provider(**fb_kwargs)
logging.getLogger(__name__).warning(
"Primary auth failed (%s), falling back to %s model %s", primary_exc, fb_provider, fb_model)
return _RuntimeFallbackResolution(runtime, fb_model, True)
except Exception:
continue
raise
def _resolve_agent_model_runtime(model_override, provider_override) -> tuple[str, dict]:
"""(model, runtime) for a new agent; a per-session override (in-session /model switch or a
resumed row's persisted runtime) wins over global config/env.
Rows persisted before the custom-provider identity fix stored the resolved provider "custom",
which no named ``providers:`` entry matches — recover the identity from the persisted base_url
or the rebuild surfaces as "No LLM provider configured". Persisted base_url/api_key/api_mode are
honored only while the original runtime is used; they must not leak into a fallback pair.
"""
if isinstance(model_override, dict) and model_override.get("model"):
model = str(model_override.get("model") or "")
requested_provider = model_override.get("provider") or provider_override or None
override_base_url = model_override.get("base_url")
resolve_kwargs = {}
if str(requested_provider or "").strip().lower() == "custom":
from hermes_cli.runtime_provider import canonical_custom_identity
if recovered := canonical_custom_identity(base_url=override_base_url or None, model=model or None):
requested_provider = recovered
if override_base_url:
# Failing identity recovery, still hand the base_url to the direct-alias branch so
# pool/env credentials resolve for it.
resolve_kwargs["explicit_base_url"] = override_base_url
resolve_kwargs["requested"] = requested_provider
resolve_kwargs["target_model"] = model or None
overrides = {
"base_url": override_base_url, "api_key": model_override.get("api_key"), "api_mode": model_override.get("api_mode"),
}
else:
model, requested_provider = _resolve_startup_runtime()
if isinstance(model_override, str) and model_override:
model = model_override
if provider_override:
requested_provider = provider_override
resolve_kwargs = {"requested": requested_provider, "target_model": model or None}
overrides = {}
resolution = _resolve_runtime_with_fallback(resolve_kwargs)
runtime = resolution.runtime
if resolution.used_fallback:
if not resolution.selected_model:
raise RuntimeError("Auth fallback resolved without a model")
return resolution.selected_model, runtime
runtime.update({k: v for k, v in overrides.items() if v})
return model, runtime
def _startup_system_prompt(cfg: dict, task_id: str) -> str:
"""Config ephemeral system prompt + the HERMES_TUI_SKILLS preload block. Hard-fails only when EVERY
requested skill is missing (cli.py parity): a typo'd name must not auto-block the Kanban task."""
from hermes_cli.config import resolve_ephemeral_system_prompt_from_config
system_prompt = resolve_ephemeral_system_prompt_from_config(cfg)
startup_skills = _parse_tui_skills_env()
if not startup_skills:
return system_prompt
from agent.skill_commands import build_preloaded_skills_prompt
skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt(startup_skills, task_id=task_id)
if missing_skills:
missing_display = ", ".join(missing_skills)
if not loaded_skills:
raise ValueError(f"Unknown skill(s): {missing_display}")
logger.warning(
"Unknown skill(s) requested, skipping: %s. Continuing with: %s. "
"List available skills with `hermes skills list`.",
missing_display, ", ".join(loaded_skills))
if skills_prompt:
system_prompt = "\n\n".join(part for part in (system_prompt, skills_prompt) if part).strip()
return system_prompt
def _make_agent(
sid: str, key: str, session_id: str | None = None, session_db=None,
model_override: dict | str | None = None, provider_override: str | None = None,
reasoning_config_override: dict | None = None, service_tier_override: str | None = None,
platform_override: str | None = None, context_cwd_is_launch_artifact: bool | None = None):
# AC-4 test seam: dead unless armed by the isolated certify harness.
from tui_gateway.synthetic_turn import maybe_build_synthetic_agent
synthetic = maybe_build_synthetic_agent(session_id or key, model_override)
if synthetic is not None:
return synthetic
from run_agent import AIAgent
# MCP discovery runs in a background daemon thread so a dead server can't freeze the shell; the
# agent snapshots its tool list once, so briefly (bounded) wait for in-flight discovery.
# Dashboard /api/ws uses hermes_cli.mcp_startup; TUI stdio keeps the tui_gateway.entry thread.
for _mod in ("hermes_cli.mcp_startup", "tui_gateway.entry"):
with contextlib.suppress(Exception):
importlib.import_module(_mod).wait_for_mcp_discovery()
cfg = _load_cfg()
system_prompt = _startup_system_prompt(cfg, session_id or key)
model, runtime = _resolve_agent_model_runtime(model_override, provider_override)
_pr = _load_provider_routing()
platform = _resolve_agent_platform(platform_override)
ignore_rules = is_truthy_value(os.environ.get("HERMES_IGNORE_RULES"))
agent = AIAgent(
model=model,
max_iterations=_cfg_max_turns(cfg, 500),
provider=runtime.get("provider"),
base_url=runtime.get("base_url"),
api_key=runtime.get("api_key"),
api_mode=runtime.get("api_mode"),
acp_command=runtime.get("command"),
acp_args=runtime.get("args"),
credential_pool=runtime.get("credential_pool"),
quiet_mode=True,
verbose_logging=False, # DEBUG agent logging; independent of tool_progress_mode
reasoning_config=(
reasoning_config_override if reasoning_config_override is not None else _load_reasoning_config(str(model or ""))
),
service_tier=service_tier_override if service_tier_override is not None else _load_service_tier(),
enabled_toolsets=_load_enabled_toolsets(platform),
# OpenRouter provider_routing prefs (gateway + CLI parity).
providers_allowed=_pr.get("only"),
providers_ignored=_pr.get("ignore"),
providers_order=_pr.get("order"),
provider_sort=_pr.get("sort"),
provider_require_parameters=_pr.get("require_parameters", False),
provider_data_collection=_pr.get("data_collection"),
platform=platform,
session_id=session_id or key,
session_db=session_db if session_db is not None else _get_db(),
ephemeral_system_prompt=system_prompt or None,
checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")),
pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")),
skip_context_files=ignore_rules,
skip_memory=ignore_rules,
fallback_model=_load_fallback_model(),
**_agent_cbs(sid))
if context_cwd_is_launch_artifact is None:
with _sessions_lock:
context_session = _sessions.get(sid)
context_cwd_is_launch_artifact = _context_cwd_is_launch_artifact(context_session)
agent._context_cwd_is_launch_artifact = bool(context_cwd_is_launch_artifact)
return agent
def _hydrate_session_cwd(sid: str, key: str, session_db, profile_home: str | None) -> None:
"""Adopt the stored row's cwd, or persist the fresh session's cwd (+ schedule git meta) when the row has none."""
owns_db = False
if session_db is not None:
db = session_db
elif profile_home:
try:
db = _open_profile_session_db(profile_home)
owns_db = True
except Exception:
# FAIL CLOSED (same class as the deferred-build bind): a named-profile session must never
# touch the launch state.db — skip cwd hydration (the row lands on the agent's own
# lazy-create once the store recovers).
logger.warning(
"profile session store unavailable for %s — skipping cwd "
"hydration instead of touching the launch state.db",
profile_home, exc_info=True)
db = None
else:
db = _get_db()
try:
if db is not None:
row = db.get_session(key) if hasattr(db, "get_session") else None
if row and row.get("cwd"):
with _sessions_lock:
if sid in _sessions:
_sessions[sid]["cwd"] = row["cwd"]
else:
try:
_cwd = _sessions[sid]["cwd"]
if hasattr(db, "update_session_cwd"):
_persist_session_cwd_and_schedule_git_meta(_sessions[sid], _cwd, db=db)
except Exception:
logger.debug("failed to persist resumed session cwd", exc_info=True)
finally:
if owns_db and db is not None:
with contextlib.suppress(Exception):
db.close()
def _init_session(
sid: str, key: str, agent, history: list, cols: int = 80, cwd: str | None = None,
session_db=None, source: str | None = None, profile_home: str | None = None,
explicit_cwd: bool = False):
now = time.time()
with _sessions_lock:
_sessions[sid] = {
"agent": agent, "session_key": key, "history": history, "history_lock": threading.Lock(),
"history_version": 0, "inflight_turn": None, "created_at": now, "last_active": now,
"running": False, "attached_images": [], "image_counter": 0, "cwd": cwd or _completion_cwd(),
"explicit_cwd": bool(explicit_cwd), "cols": cols, "slash_worker": None,
"show_reasoning": _load_show_reasoning(), "source": _resolve_session_source(source),
"tool_progress_mode": _load_tool_progress_mode(), "edit_snapshots": {}, "tool_started_at": {},
# Profile-scoped HERMES_HOME (None = launch profile); SessionBranch copies the parent's
# so the child stays on the same state.db.
"profile_home": profile_home,
# In-session /model switch, honored on rebuild (/new, resume) so it never leaks into
# siblings via process-global env vars.
"model_override": None,
# Async events go to the transport that created the session (stdio for Ink, WS for the dashboard).
"transport": current_transport() or _stdio_transport,
}
_session_todo_state(_sessions[sid])
_hydrate_session_cwd(sid, key, session_db, profile_home)
_register_session_cwd(_sessions[sid])
_wire_session_agent(sid, key, agent) # no eager slash-worker pre-warm (see _start_agent_build)
_start_session_services(sid, key, _sessions.get(sid, {}))
_emit("session.info", sid, _session_info(agent, _sessions.get(sid, {})))
_schedule_mcp_late_refresh(sid, agent)
def _new_session_key() -> str:
return f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
def _with_checkpoints(session, fn):
return fn(session["agent"]._checkpoint_mgr, _session_cwd(session))
def _resolve_checkpoint_hash(mgr, cwd: str, ref: str) -> str:
try:
checkpoints = mgr.list_checkpoints(cwd)
idx = int(ref) - 1
except ValueError:
return ref
if 0 <= idx < len(checkpoints):
return checkpoints[idx].get("hash", ref)
raise ValueError(f"Invalid checkpoint number. Use 1-{len(checkpoints)}.")
# ── Methods: session ─────────────────────────────────────────────────
def _lazy_resume_info(cwd: str, *, model: str = "", provider: str = "", profile: str | None = None) -> dict:
"""session.info for a not-yet-built session (session.create's shape); tools/skills land with the deferred build."""
info = {
"cwd": cwd, "branch": _git_branch_for_cwd(cwd), "project": _project_info_for_cwd(cwd),
"model": model or _resolve_model(), "tools": {}, "skills": {}, "lazy": True,
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
"profile_name": _response_profile_name(profile),
}
if provider:
info["provider"] = provider
return info
def _deferred_session_record(
session_key: str, *, cols: int, cwd: str, history: list, lease, source: str = "tui",
close_on_disconnect: bool = False, display_history_prefix: list | None = None,
profile_home: Path | None = None, lazy: bool = False, model_override=None,
resume_runtime_overrides: dict | None = None, todo_state: dict | None = None,
explicit_cwd: bool = False) -> dict:
"""A live-session record whose AIAgent is built later (lazy watch / cold resume) — _init_session's shape minus the agent."""
now = time.time()
return {
"agent": None, "agent_error": None, "agent_ready": threading.Event(), "attached_images": [],
"close_on_disconnect": close_on_disconnect, "active_session_lease": lease, "cols": cols,
"created_at": now, "cwd": cwd, "display_history_prefix": display_history_prefix or [],
"edit_snapshots": {}, "explicit_cwd": bool(explicit_cwd), "history": history,
"history_lock": threading.Lock(), "history_version": 0, "image_counter": 0,
"inflight_turn": None, "last_active": now, "lazy": lazy, "model_override": model_override,
"pending_title": None,
"profile_home": str(profile_home) if profile_home is not None else None,
"resume_runtime_overrides": resume_runtime_overrides, "resume_session_id": session_key,
"running": False, "session_key": session_key, "show_reasoning": _load_show_reasoning(),
"slash_worker": None, "source": source, "tool_progress_mode": _load_tool_progress_mode(),
"tool_started_at": {}, "todo_state": todo_state,
"transport": current_transport() or _stdio_transport,
}
_ANY_PROFILE = object() # default: match a live session regardless of profile
def _live_profile_matches(session: dict, profile_home) -> bool:
"""True when ``session`` belongs to ``profile_home`` (None = launch profile; a record with no
``profile_home`` is the launch profile's). ``_ANY_PROFILE`` disables the check."""
if profile_home is _ANY_PROFILE:
return True
return (session.get("profile_home") or None) == (str(profile_home) if profile_home else None)
def _claim_or_reuse_live(sid: str, session_key: str, record: dict, lease) -> tuple[str, dict] | None:
"""Register ``record`` as the live session for ``session_key`` under the resume lock, or — if a
concurrent resume already won — release ``lease`` and return the winner for the caller to reuse."""
# The record carries the home this resume resolved; a live runtime of the same stored id under
# ANOTHER profile is not a winner to reuse.
profile_home = record.get("profile_home")
with _session_resume_lock:
live = _find_live_session_by_key(session_key, profile_home)
if live is not None:
if lease is not None:
lease.release()
# The winner is being reattached by this resume: a pending ws-orphan reap must not
# fire against the reclaimed client (storm killer — see _cancel_ws_orphan_reap).
_cancel_ws_orphan_reap(live[0])
return live
with _sessions_lock:
_sessions[sid] = record
_register_session_cwd(_sessions[sid])
# A PRIOR runtime for this stored id may still be sentinel-parked with a reap Timer armed;
# cancel + finalize it quietly so the reap doesn't broadcast session.reclaimed (storm).
_cancel_ws_orphan_reap(sid)
stale = _claim_parked_runtimes(session_key, keep_sid=sid, profile_home=profile_home)
_finalize_superseded_runtimes(stale) # slow finalization stays OUTSIDE _session_resume_lock
return None
def _claim_parked_runtimes(session_key: str, *, keep_sid: str, profile_home=_ANY_PROFILE) -> list[tuple[str, dict]]:
"""Claim sentinel-parked stale runtimes of ``session_key`` for supersession: older records for the
same stored id still parked on the detached-WS sentinel get their orphan-reap Timer cancelled and
are popped from ``_sessions`` here (under the caller's _session_resume_lock), then finalized by
:func:`_finalize_superseded_runtimes` after the lock is released."""
stale: list[tuple[str, dict]] = []
with _sessions_lock:
candidates = [
(old_sid, old)
for old_sid, old in list(_sessions.items())
if old_sid != keep_sid
and not old.get("_finalized")
and _session_lookup_key(old, fallback=old_sid) == session_key
and _live_profile_matches(old, profile_home)
and old.get("transport") is _detached_ws_transport
]
for old_sid, _old in candidates:
_cancel_ws_orphan_reap(old_sid)
popped = _pop_session_by_id(old_sid)
if popped is not None:
stale.append((old_sid, popped))
return stale
def _finalize_superseded_runtimes(stale: list[tuple[str, dict]]) -> None:
"""Quietly finalize runtimes claimed by :func:`_claim_parked_runtimes` with end_reason
``superseded_by_resume`` — deliberately NOT in _RECLAIM_END_REASONS (no ``session.reclaimed``
broadcast, which fed the reap->broadcast->resume loop) but IN _RECOVERABLE_END_REASONS so
canonical Bot Chat resurrection still applies."""
for old_sid, popped in stale:
try:
_teardown_popped_session(popped, end_reason="superseded_by_resume")
except Exception:
logger.exception("superseded runtime teardown failed sid=%s", old_sid)
def _schedule_agent_build(sid: str, delay: float = 0.05) -> None:
"""Pre-warm a deferred session's agent off the response path (session.create + cold resume; _sess() also builds on demand)."""
def _run():
session = _sessions.get(sid)
if session is not None:
_start_agent_build(sid, session)
timer = threading.Timer(delay, _run)
timer.daemon = True
timer.start()
def _load_resume_transcript(db, stored_id: str) -> tuple[list, list, list]:
"""(raw_history, display_history, ancestor_prefix) for a cold resume.
The ancestor prefix is an in-memory convenience (the transcript is REST-paginated): the full
lineage is materialized only while it fits sessions.max_resume_messages, else the tip alone."""
from hermes_state import SessionResumeTooLargeError
prefix_fits = True
guard = getattr(db, "assert_resume_safe", None)
if callable(guard):
try:
guard(stored_id)
except SessionResumeTooLargeError as exc:
prefix_fits = False
logger.info(
"resume %s: compression lineage exceeds the resume limit (%s); hydrating the tip segment only",
stored_id, exc)
except Exception:
logger.debug("resume lineage guard failed; loading full lineage", exc_info=True)
if prefix_fits:
raw_history, display_history = db.get_resume_conversations(stored_id)
return raw_history, display_history, db.get_ancestor_display_prefix(stored_id)
raw_history = db.get_messages_as_conversation(stored_id, repair_alternation=True, include_row_ids=True)
return raw_history, raw_history, []
def _schedule_resume_hydration(sid: str, stored_id: str, db, *, close_db: bool = False) -> None:
"""Load a cold resume's transcript off the JSON-RPC response path."""
def _run() -> None:
session = _sessions.get(sid)
try:
if session is None:
return
_emit("session.resume_progress", sid, {"phase": "history", "status": "loading"})
db.reopen_session(stored_id)
raw_history, display_history, prefix = _load_resume_transcript(db, stored_id)
history = sanitize_replay_history(raw_history)
if _sessions.get(sid) is not session:
return
with session["history_lock"]:
session["history"] = history
session["display_history_prefix"] = prefix
session["resume_hydrating"] = False
session["resume_message_count"] = len(display_history)
# Deferred resumes answered before the transcript existed; cache the derived todo
# snapshot now so later payload attaches carry it.
todo_state = _todo_state_from_history(history)
if todo_state is not None and session.get("todo_state") is None:
session["todo_state"] = todo_state
session["resume_history_ready"].set()
_emit(
"session.resume_progress", sid,
{"message_count": len(display_history), "phase": "history", "status": "complete"})
_maybe_schedule_auto_continue(sid, session, stored_id)
_start_agent_build(sid, session)
except Exception as exc:
if _sessions.get(sid) is not session:
return
message = f"resume failed: {exc}"
session["resume_hydrating"] = False
session["resume_history_error"] = message
session["agent_error"] = message
session["resume_history_ready"].set()
session["agent_ready"].set()
_emit("session.resume_progress", sid, {"message": message, "phase": "history", "status": "failed"})
_emit("error", sid, {"message": message})
with _sessions_lock:
discarded = _sessions.pop(sid, None) if _sessions.get(sid) is session else None
lease = (discarded or {}).get("active_session_lease")
if lease is not None:
lease.release()
finally:
if close_db and hasattr(db, "close"):
try:
db.close()
except Exception:
logger.debug("failed to close resume db for %s", sid, exc_info=True)
threading.Thread(target=_run, daemon=True).start()
def _session_pending_kind(sid: str) -> str:
for rid, (owner_sid, _ev) in list(_pending.items()):
if owner_sid == sid:
event, _payload = _pending_prompt_payloads.get(rid, ("input.request", {}))
return str(event).removesuffix(".request")
return ""
def _session_live_status(sid: str, session: dict) -> str:
if _session_pending_kind(sid):
return "waiting"
ready = session.get("agent_ready")
# Unset + build never started = a lazy watch session idling, not one stuck mid-construction.
if ready is not None and not ready.is_set() and session.get("agent_build_started"):
return "starting"
return "working" if session.get("running") else "idle"
def _message_preview(history: list) -> str:
for msg in reversed(history or []):
text = _content_display_text(msg.get("content", msg.get("text", ""))).strip()
if text:
return " ".join(text.split())[:160]
return ""
def _session_live_title(session: dict, key: str) -> str:
title = str(session.get("pending_title") or "").strip()
with contextlib.suppress(Exception):
with _session_db(session) as db:
if db is not None:
title = str(db.get_session_title(key) or title or "").strip()
return title
def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict:
key = _session_lookup_key(session, fallback=sid)
agent = session.get("agent")
history = list(session.get("history") or [])
status = _session_live_status(sid, session)
inflight = _inflight_snapshot(session)
queued = _queued_prompt_snapshot(session)
preview = _message_preview(history)
if queued:
preview = " ".join(str(queued.get("user") or preview).split())[:160]
elif inflight:
preview = " ".join(str(inflight.get("assistant") or inflight.get("user") or preview).split())[:160]
now = time.time()
return {
"current": sid == current_sid, "id": sid,
"last_active": float(session.get("last_active") or session.get("created_at") or now),
"message_count": len(history),
"model": str(getattr(agent, "model", "") or _resolve_model()), "preview": preview,
"session_key": key, "started_at": float(session.get("created_at") or now), "status": status,
"title": _session_live_title(session, key),
}
def _session_lookup_key(session: dict, *, fallback: str = "") -> str:
agent = session.get("agent")
return str(getattr(agent, "session_id", None) or session.get("session_key") or fallback or "")
def _find_live_session_by_key(session_key: str, profile_home=_ANY_PROFILE) -> tuple[str, dict] | None:
# Timestamp-based stored ids can exist in several profiles' stores; a bare-id match would hand
# profile B's resume profile A's runtime, so profile-aware callers match on (profile_home, key).
for sid, session in list(_sessions.items()):
if session.get("_finalized"):
continue
if _session_lookup_key(session, fallback=sid) == session_key and _live_profile_matches(session, profile_home):
return sid, session
return None
def _fallback_session_info(session: dict) -> dict:
agent = session.get("agent")
if agent is not None:
return _session_info(agent)
# The SESSION's own workspace, not the gateway launch dir (that painted the wrong project in the
# desktop Files pane). `branch` is always emitted ("" outside git) so a client clears a stale label.
# `desktop_contract`: a lazy session is still served by THIS backend; missing reads as 0 = "out of date".
cwd = _session_cwd(session)
return {
"cwd": cwd, "branch": _git_branch_for_cwd(cwd), "project": _project_info_for_cwd(cwd), "lazy": True,
"model": _resolve_model(), "skills": {}, "tools": {}, "desktop_contract": DESKTOP_BACKEND_CONTRACT,
}
def _reconcile_display_with_live(db_display: list[dict], in_memory: list[dict]) -> list[dict]:
"""Merge the persisted DISPLAY lineage with the in-memory live history.
``db_display`` is verbatim and candidate-inclusive (verification rows the model history collapses
out via ``repair_message_sequence``) but can lag the newest turn by a flush; ``in_memory``
(``display_history_prefix + session["history"]``) is the recency authority but the collapsed
*model* projection. Keep the DB display as base and append only the in-memory tail past the
last DB row's ``(role, text)`` anchor, so the verification answer survives a warm switch AND
a not-yet-flushed live turn is not dropped."""
if not db_display:
return in_memory
if not in_memory:
return db_display
def _key(msg: dict) -> tuple:
return (msg.get("role"), _coerce_message_text(msg.get("content")))
anchor = _key(db_display[-1])
last_shared = -1
for idx, msg in enumerate(in_memory):
if isinstance(msg, dict) and _key(msg) == anchor:
last_shared = idx
if last_shared == -1:
return db_display # DB tail not in memory (DB ahead, or diverged) — trust it over duplicating
return list(db_display) + list(in_memory[last_shared + 1 :])
def _live_visible_history(session: dict, db, in_memory_fallback: list[dict]) -> list[dict]:
"""User-visible DISPLAY projection for a live/warm session: the persisted display lineage (same
read as resume/REST, so the two payloads agree — "substantive answer vanishes on switch")
reconciled with the fresh in-memory tail; in-memory when the DB/session_key is unavailable."""
key = session.get("session_key")
if db is not None and key:
try:
# include_compacted: a compacted session's archived turns are still the user's
# conversation; without them a warm switch repainted the chat as summary + tail only.
display = db.get_messages_as_conversation(
key, include_ancestors=True, include_row_ids=True, include_compacted=True)
return _reconcile_display_with_live(display, in_memory_fallback)
except Exception:
logger.debug("live display projection read failed", exc_info=True)
return in_memory_fallback
def _live_session_payload(
sid: str, session: dict, *, cols: int | None = None, touch: bool = False,
transport: Transport | None = None, omit_messages: bool = False) -> dict:
with session["history_lock"]:
if cols is not None:
session["cols"] = cols
if transport is not None:
session["transport"] = transport
# Every transport that has shown this session (pop-out windows resume the same sid);
# the last viewer becomes the transport on disconnect instead of the drop sentinel.
session.setdefault("viewers", {})[transport] = time.time()
if transport is not _detached_ws_transport:
_cancel_ws_orphan_reap(sid) # the client is back — a pending ws-orphan reap must not fire
if touch:
session["last_active"] = time.time()
in_memory_history = list(session.get("display_history_prefix") or []) + list(session.get("history") or [])
inflight = _inflight_snapshot(session)
queued = _queued_prompt_snapshot(session)
running = bool(session.get("running"))
turn_started_at = _turn_started_at(session)
# Persisted display lineage (candidate-inclusive) so this matches resume + REST; via the session's
# profile-aware DB, not the launch ``_get_db()``. The DB has its own lock — read outside the
# history lock. ``omit_messages`` skips the read entirely (fast path for counts/status).
if omit_messages:
history = in_memory_history
else:
with _session_db(session) as db:
history = _live_visible_history(session, db, in_memory_history)
payload = {
"info": _fallback_session_info(session), "message_count": len(history),
"messages": [] if omit_messages else _history_to_messages(history),
"messages_omitted": omit_messages, "running": running, "turn_started_at": turn_started_at,
"session_id": sid, "session_key": _session_lookup_key(session, fallback=sid),
"started_at": float(session.get("created_at") or time.time()),
"status": _session_live_status(sid, session),
}
if inflight:
payload["inflight"] = inflight
if queued:
payload["queued"] = queued
if approval := _pending_approval_request_payload(str(session.get("session_key") or "")):
payload["pending_approval"] = approval
if clarify := _pending_clarify_request_payload(sid):
payload["pending_clarify"] = clarify
return _attach_todo_state(payload, session)
def _main_runtime_from_agent(agent) -> dict | None:
"""Aux-client main_runtime override from a live agent, so a one-shot inherits the session's
provider/model/credentials instead of the cheapest auto-detected backend."""
if agent is None:
return None
runtime: dict = {}
for field in ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode"):
value = getattr(agent, field, None)
if isinstance(value, str) and value.strip():
runtime[field] = value.strip()
elif field == "api_key" and callable(value):
runtime[field] = value
return runtime or None
# Pet helpers are fail-open throughout: a decode hiccup degrades to a static fallback rather than
# breaking the (cosmetic) pet surface.
def _pet_frame_counts(spritesheet) -> dict:
"""Real (padding-trimmed) frame count per state for the desktop canvas ({} → static ``framesPerState``)."""
try:
from agent.pet import render
return render.state_frame_counts(str(spritesheet))
except Exception: # noqa: BLE001
return {}
_pet_payload_cache_lock = threading.Lock()
_pet_payload_cache: dict[tuple, dict] = {}
def _pet_sheet_revision(spritesheet) -> str:
"""Stable revision id for one spritesheet file."""
try:
stat = spritesheet.stat()
return f"{stat.st_mtime_ns}:{stat.st_size}"
except Exception: # noqa: BLE001
return "0:0"
def _pet_payload_cache_key(pet, *, scale: float) -> tuple | None:
"""Cache key for the expensive sprite payload build."""
try:
stat = pet.spritesheet.stat()
except Exception: # noqa: BLE001
return None
return (str(pet.spritesheet), stat.st_mtime_ns, stat.st_size, pet.slug, pet.display_name, round(scale, 4))
def _clone_pet_payload(payload: dict) -> dict:
"""Shallow-clone cached payloads so callers can't mutate shared state."""
out = dict(payload)
for key, kind in (("framesByState", dict), ("framesByRow", dict), ("stateRows", list)):
if isinstance(payload.get(key), kind):
out[key] = kind(payload[key])
return out
def _pet_row_frame_counts(spritesheet) -> dict:
"""Real frame count per concrete spritesheet row name."""
try:
from PIL import Image
from agent.pet import constants, render
with Image.open(spritesheet) as opened:
image = opened.convert("RGBA")
W, H = constants.FRAME_W, constants.FRAME_H
cols = max(1, image.width // W)
row_count = max(1, image.height // H)
rows = constants.state_rows_for_grid(row_count)
out: dict[str, int] = {}
for row_idx, name in enumerate(rows[:row_count]):
top = row_idx * H
count = 0
for col in range(cols):
if render._frame_is_blank(image.crop((col * W, top, col * W + W, top + H))):
break
count += 1
out[name] = count
return out
except Exception: # noqa: BLE001
return {}
def _pet_cfg() -> dict:
"""``display.pet`` from the canonical config ({} on any failure)."""
try:
from hermes_cli.config import load_config
cfg = load_config()
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
return display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
except Exception: # noqa: BLE001
return {}
def _pet_config_scale() -> float:
"""Configured ``display.pet.scale`` (or the engine default), never raises."""
from agent.pet import constants
try:
return float(_pet_cfg().get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
except Exception: # noqa: BLE001
return constants.DEFAULT_SCALE
def _pet_sprite_payload(pet, *, scale: float) -> dict:
"""Renderer payload (spritesheet bytes + geometry) for *pet* — one shape for ``pet.info`` (active
mascot) and ``pet.hatch`` (unadopted preview)."""
import base64
from agent.pet import constants
cache_key = _pet_payload_cache_key(pet, scale=scale)
if cache_key is not None:
with _pet_payload_cache_lock:
cached = _pet_payload_cache.get(cache_key)
if cached is not None:
return _clone_pet_payload(cached)
raw = pet.spritesheet.read_bytes()
suffix = pet.spritesheet.suffix.lower()
mime = "image/png" if suffix == ".png" else "image/webp"
payload = {
"slug": pet.slug, "displayName": pet.display_name, "mime": mime,
"spritesheetBase64": base64.standard_b64encode(raw).decode("ascii"),
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet), "frameW": constants.FRAME_W,
"frameH": constants.FRAME_H, "framesPerState": constants.FRAMES_PER_STATE,
"framesByState": _pet_frame_counts(pet.spritesheet),
"framesByRow": _pet_row_frame_counts(pet.spritesheet), "loopMs": constants.LOOP_MS,
"scale": scale, "stateRows": _pet_state_rows(pet.spritesheet),
}
if cache_key is not None:
with _pet_payload_cache_lock:
_pet_payload_cache[cache_key] = payload
while len(_pet_payload_cache) > 8:
_pet_payload_cache.pop(next(iter(_pet_payload_cache)))
return _clone_pet_payload(payload)
def _pet_active_selection():
"""Resolve configured active pet + scale from config."""
from agent.pet import constants, store
pet_cfg = _pet_cfg()
enabled = is_truthy_value(pet_cfg.get("enabled"), default=False)
configured_slug = str(pet_cfg.get("slug", "") or "")
pet = store.resolve_active_pet(configured_slug) if enabled else None
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
return enabled, pet, scale
def _pet_state_rows(spritesheet) -> list[str]:
"""Row taxonomy for the concrete active pet sheet (legacy 8-row petdex atlas or current 9-row
atlas); the desktop canvas indexes it with the same `PetState` names the Python renderer uses."""
from agent.pet import constants
try:
from PIL import Image
with Image.open(spritesheet) as image:
row_count = max(1, image.height // constants.FRAME_H)
return list(constants.state_rows_for_grid(row_count))
except Exception: # noqa: BLE001
return list(constants.STATE_ROWS)
def _pet_gen_root():
"""Profile-scoped staging dir for in-progress generation drafts."""
root = get_hermes_home() / "cache" / "pet-gen"
root.mkdir(parents=True, exist_ok=True)
return root
def _pet_gen_sweep(root, *, max_age_s: float = 3600.0) -> None:
"""Drop stale draft staging dirs so cache never grows unbounded."""
import shutil
try:
now = time.time()
for child in root.iterdir():
if child.is_dir() and now - child.stat().st_mtime > max_age_s:
shutil.rmtree(child, ignore_errors=True)
except Exception as exc: # noqa: BLE001 - cleanup is best-effort
logger.debug("pet-gen sweep failed: %s", exc)
def _pet_png_data_uri(path, *, max_px: int = 160) -> str:
"""Downscaled PNG data URI for a draft image (small preview payload)."""
import base64, io
from PIL import Image
with Image.open(path) as opened:
img = opened.convert("RGBA")
img.thumbnail((max_px, max_px), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.standard_b64encode(buf.getvalue()).decode("ascii")
# Cooperative cancellation for pet generation: Stop aborts the RPC, but the pool job keeps
# running unless pet.cancel flips its token (polled between provider calls).
_pet_cancel_lock = threading.Lock()
_pet_cancelled: set[str] = set()
_PET_REFERENCE_MIME_EXT = {"png": "png", "jpeg": "jpg", "jpg": "jpg", "webp": "webp", "gif": "gif"}
try:
_PET_REFERENCE_MAX_BYTES = max(1, int(os.environ.get("HERMES_PET_REFERENCE_MAX_BYTES") or str(16 * 1024 * 1024)))
except (TypeError, ValueError):
_PET_REFERENCE_MAX_BYTES = 16 * 1024 * 1024
def _pet_reference_images_from_data_url(ref_raw: str, stage) -> list:
"""Decode + validate a reference-image data URL into the stage dir."""
import base64, binascii
import re as _re
match = _re.match(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$", ref_raw, _re.DOTALL)
if not match:
raise ValueError("invalid reference image format")
mime = match.group(1).lower()
ext = _PET_REFERENCE_MIME_EXT.get(mime)
if ext is None:
raise ValueError("unsupported reference image type")
payload = "".join(match.group(2).split())
approx = (len(payload) * 3) // 4
if approx > _PET_REFERENCE_MAX_BYTES:
raise ValueError("reference image too large")
try:
raw = base64.b64decode(payload, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("invalid reference image data") from exc
if len(raw) > _PET_REFERENCE_MAX_BYTES:
raise ValueError("reference image too large")
ref_path = stage / f"reference.{ext}"
ref_path.write_bytes(raw)
return [ref_path]
def _pet_cancel_arm(token: str) -> None:
"""Clear a stale cancel flag at the start of a generate/hatch run."""
with _pet_cancel_lock:
_pet_cancelled.discard(token)
_pet_cancel_release = _pet_cancel_arm
def _pet_cancel_request(token: str) -> None:
with _pet_cancel_lock:
_pet_cancelled.add(token)
def _pet_is_cancelled(token: str) -> bool:
with _pet_cancel_lock:
return token in _pet_cancelled
# ── Spawn-tree snapshots: TUI-written, disk-persisted ────────────────
# The TUI owns subagent state (the /agents overlay; registry in tools/delegate_tool); on
# turn-complete it posts the final tree here and /replay fetches by session_id + filename.
# Layout: $HERMES_HOME/spawn-trees/<session_id>/<timestamp>.json
def _spawn_trees_root():
root = get_hermes_home() / "spawn-trees"
root.mkdir(parents=True, exist_ok=True)
return root
def _spawn_tree_session_dir(session_id: str):
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) or "unknown"
d = _spawn_trees_root() / safe
d.mkdir(parents=True, exist_ok=True)
return d
# Per-session append-only index (one JSON object per line) so `spawn_tree.list` needn't read every
# full snapshot. It is a cache: a lost line just means list() falls back to a directory scan.
_SPAWN_TREE_INDEX = "_index.jsonl"
def _append_spawn_tree_index(session_dir, entry: dict) -> None:
try:
with (session_dir / _SPAWN_TREE_INDEX).open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except OSError as exc:
logger.debug("spawn_tree index append failed: %s", exc) # never block the save
def _read_spawn_tree_index(session_dir) -> list[dict]:
index_path = session_dir / _SPAWN_TREE_INDEX
if not index_path.exists():
return []
out: list[dict] = []
try:
with index_path.open("r", encoding="utf-8") as f:
for line in f:
if line := line.strip():
with contextlib.suppress(json.JSONDecodeError):
out.append(json.loads(line))
except OSError:
return []
return out
# ── Methods: prompt ──────────────────────────────────────────────────
_GOAL_COMPRESSION_RECOVERY_ATTEMPTS = "_goal_compression_recovery_attempts"
_GOAL_COMPRESSION_RECOVERY_LIMIT = 1
# Captured at import time: tests monkeypatch threading.Thread with a synchronous stub, and this
# ticker only exits once `stop` is set AFTER run_conversation returns — inline it would spin forever.
_RealThread = threading.Thread
def _start_usage_ticker(sid: str, agent, interval: float = 1.0) -> tuple[threading.Event, threading.Thread]:
"""Push live ``session.usage`` snapshots every ``interval`` s while a turn runs (otherwise the
status-bar context figure freezes until ``message.complete``). The caller must set the Event AND
join the thread before emitting ``message.complete``: a late tick would roll the client's final
usage back to a stale snapshot."""
stop = threading.Event()
# Dedup baseline sampled BEFORE the thread starts (the client already has the turn-start
# values); a late-scheduled thread would otherwise absorb the first counter growth and never emit it.
try:
baseline: dict | None = _get_usage(agent)
except Exception:
baseline = None
def _loop() -> None:
last = baseline
while not stop.wait(interval):
with contextlib.suppress(Exception):
usage = _get_usage(agent)
if usage == last:
continue # counters frozen (one long API call in flight): don't re-render the status bar
last = usage
if stop.is_set():
break # turn ended while snapshotting; message.complete carries the authoritative usage
_emit("session.usage", sid, {"usage": usage})
thread = _RealThread(target=_loop, daemon=True)
thread.start()
return stop, thread
# ── Methods: respond ─────────────────────────────────────────────────
def _respond(rid, params, key, *, allow_expired=False):
r = params.get("request_id", "")
question_id = str(params.get("question_id") or "")
with _prompt_lock:
entry = _pending.get(r)
if not entry:
if allow_expired and r:
return _ok(rid, {"status": "expired"})
return _err(rid, 4009, f"no pending {key} request")
_, ev = entry
batch = _batch_clarify.get(r)
if batch is not None and question_id:
# Per-question lock; update-in-place so a locked answer stays editable until every
# qid is locked (the Confirm click).
if question_id not in batch["qids"]:
return _err(rid, 4002, f"unknown question_id {question_id!r}")
batch["answers"][question_id] = params.get(key, "")
remaining = [qid for qid in batch["qids"] if qid not in batch["answers"]]
if not remaining:
ev.set()
return _ok(rid, {"status": "ok", "remaining": remaining})
_answers[r] = params.get(key, "")
ev.set()
return _ok(rid, {"status": "ok"})
# ── Methods: tools & system ──────────────────────────────────────────
def _session_processes(session: dict) -> list:
"""Background processes owned by this session (registry session_key match)."""
from tools.process_registry import process_registry
key = str(session.get("session_key") or "")
owned = []
for entry in process_registry.list_sessions():
proc = process_registry.get(entry["session_id"])
if proc is None or str(getattr(proc, "session_key", "") or "") != key:
continue
# The 200-char list preview is too thin for the desktop's inline terminal viewer.
entry["output_tail"] = (proc.output_buffer or "")[-4000:]
owned.append(entry)
return owned
# Serialize reload.mcp (runs on the pool): overlapping shutdown+discover pairs would leave the
# registry half-built.
_mcp_reload_lock = threading.Lock()
# Bumped per SUCCESSFUL reload; a follower skips only if it advanced while it waited (a leader
# that threw leaves it unchanged → follower reloads itself).
_mcp_reload_gen = 0
# The mcp_rev the last successful reload actually LOADED (re-hashed after discovery). A follower
# coalesces only when its requested rev matches; otherwise the config changed under the leader.
_mcp_reload_loaded_rev = ""
# Bounded convergence for a config edit racing a slow reload: the leader re-hashes after
# discovery and repeats until the hash is stable.
_MCP_RELOAD_MAX_PASSES = 3
def _compute_mcp_rev() -> str:
"""Hash of the MCP-relevant config sections: mcp_servers (definitions — omitting it meant an
edited server never bumped the rev and never connected) + mcp (settings) + tools (enables).
``config.get mtime`` ships it so cosmetic writes don't trigger reloads; ``reload.mcp`` uses it
for revision-aware coalescing. "" = unknown (fail open)."""
try:
cfg = _load_cfg()
rev_src = json.dumps(
{"mcp": cfg.get("mcp"), "mcp_servers": cfg.get("mcp_servers"), "tools": cfg.get("tools")},
sort_keys=True, default=str)
return hashlib.sha1(rev_src.encode()).hexdigest()[:12]
except Exception:
return ""
def _finish_reload(rid, params: dict, *, coalesced: bool) -> dict:
"""Shared tail for both reload paths: honor ``always`` (persist the confirm opt-out) and return the ok payload."""
if bool(params.get("always", False)):
try:
from cli import save_config_value as _save_cfg
_save_cfg("approvals.mcp_reload_confirm", False)
except Exception as _exc:
logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc)
payload = {"status": "reloaded", "loaded_rev": _mcp_reload_loaded_rev}
if coalesced:
payload["coalesced"] = True
return _ok(rid, payload)
_TUI_HIDDEN: frozenset[str] = frozenset({"sethome", "set-home", "commands", "approve", "deny"})
_TUI_EXTRA: list[tuple[str, str, str]] = [
("/density", "Toggle compact display mode", "TUI"),
("/logs", "Show recent gateway log lines", "TUI"),
("/mouse", "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]", "TUI"),
("/sessions", "Switch between live TUI sessions", "TUI"),
]
# Commands that queue onto _pending_input in the CLI; the slash worker has no reader for that
# queue, so slash.exec routes them to command.dispatch instead.
_PENDING_INPUT_COMMANDS: frozenset[str] = frozenset({
"retry", "queue", "q", "steer", "plan", "goal", "loop", "proactive", "moa", "undo", "learn",
"init", "compress", "compact",
})
_WORKER_BLOCKED_COMMANDS: frozenset[str] = frozenset({"snapshot", "snap"})
def _skill_usage_lookup():
"""``(usage, origin)`` callables for the skill-command catalog: ``usage(name)`` = observed activity
count (use + view + patch); ``origin(name)`` = "hub" / "bundled" / "local" (what ``/api/skills``
reports as ``provenance``, "local" spelled "agent"). Any failure degrades to 0 / "local"."""
try:
from tools.skill_usage import (
_read_bundled_manifest_names, _read_hub_installed_names, activity_count, load_usage)
records, bundled, hub = load_usage(), _read_bundled_manifest_names(), _read_hub_installed_names()
except Exception as e:
logger.debug("skill usage lookup unavailable: %s", e)
return (lambda _name: 0), (lambda _name: "local")
def usage(name: str) -> int:
try:
return activity_count(records.get(name) or {})
except Exception:
return 0
def origin(name: str) -> str:
return "hub" if name in hub else "bundled" if name in bundled else "local"
return usage, origin
_SLASH_COMPLETION_LIMIT = 30
def _rank_slash_completions(items: list[dict], usage, origin_of, *, browsing: bool, score_of=None) -> list[dict]:
"""Rank and bound slash completions the way the menu should read.
Registry commands keep their order; only the skill block is reordered: fuzzy ``score_of`` first
(a name match beats a description match), then most-used, then A-Z. The limit is spent PER KIND:
commands come before the first skill, so a flat cut on a large install offered no skill at all.
``browsing`` (bare ``/``) drops never-used bundled skills as noise; a typed query is SEARCHING and
a search that hides a match is broken — nothing is pruned there, only reordered."""
def name_of(item: dict) -> str:
return str(item.get("text", "")).strip().lstrip("/").lower()
commands = [item for item in items if item.get("kind") != "skill"]
skills = [item for item in items if item.get("kind") == "skill"]
if browsing:
skills = [item for item in skills if origin_of(name_of(item)) != "bundled" or usage(name_of(item)) > 0]
if score_of is not None:
skills.sort(key=lambda item: (score_of(item), -usage(name_of(item)), name_of(item)))
else:
skills.sort(key=lambda item: (-usage(name_of(item)), name_of(item)))
return commands[:_SLASH_COMPLETION_LIMIT] + skills[:_SLASH_COMPLETION_LIMIT]
# argv shapes that must not run headless in the gateway process → user hint.
_CLI_EXEC_BLOCKED = {
("setup",): "`hermes setup` needs a full terminal — run it outside the TUI",
("gateway",): "`hermes gateway` is long-running — run it in another terminal",
("sessions", "browse"): "`hermes sessions browse` is interactive — use /resume here, or run browse in another terminal",
("config", "edit"): "`hermes config edit` needs $EDITOR in a real terminal",
}
def _cli_exec_blocked(argv: list[str]) -> str | None:
"""Return user hint if this argv must not run headless in the gateway process."""
if not argv:
return "bare `hermes` is interactive — use `/hermes chat -q …` or run `hermes` in another terminal"
head = tuple(a.lower() for a in argv[:2])
return _CLI_EXEC_BLOCKED.get(head[:1]) or _CLI_EXEC_BLOCKED.get(head)
def _resolve_name(name: str) -> str:
try:
from hermes_cli.commands import resolve_command
r = resolve_command(name)
return r.name if r else name
except Exception:
return name
_paste_counter = 0
# mcp.servers.* handlers (methods_tools) resolve these BARE through this namespace.
from .mcp_rpc_helpers import ( # noqa: E402, F401
reset_profile as _mcp_reset_profile,
summarize_server as _mcp_summarize_server)
# ── Split @method handler modules (see method_ctx.py) ────────────────
# Imported last so every global the handlers close over exists; register() rebinds them onto this namespace.
from . import ( # noqa: E402
methods_voice as _methods_voice,
methods_browser as _methods_browser,
methods_slash as _methods_slash,
methods_complete_helpers as _methods_complete_helpers,
session_auto_continue as _session_auto_continue,
agent_callbacks as _agent_callbacks,
session_history as _session_history,
prompt_attachments as _prompt_attachments,
session_notifications as _session_notifications,
tool_progress as _tool_progress,
change_watcher as _change_watcher,
session_compression as _session_compression,
model_switch as _model_switch,
compute_host_bridge as _compute_host_bridge,
session_workdir as _session_workdir,
session_lifecycle as _session_lifecycle,
session_reaper as _session_reaper,
methods_browser_control as _methods_browser_control,
methods_bot_relay as _methods_bot_relay,
methods_complete as _methods_complete,
methods_config as _methods_config,
methods_config_set as _methods_config_set,
methods_images as _methods_images,
methods_profiles as _methods_profiles,
methods_prompt as _methods_prompt,
methods_session as _methods_session,
methods_tools as _methods_tools,
prompt_turn as _prompt_turn,
billing_view as _billing_view,
methods_projects as _methods_projects)
for _m in (
_session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch,
_session_compression, _change_watcher, _tool_progress, _session_notifications,
_prompt_attachments, _session_history, _agent_callbacks, _session_auto_continue,
_methods_complete_helpers, _methods_slash, _methods_voice, _methods_browser,
_methods_browser_control, _methods_session, _methods_prompt, _methods_config,
_methods_config_set, _methods_complete, _methods_tools, _methods_profiles, _methods_images,
_methods_bot_relay, _prompt_turn, _billing_view, _methods_projects):
_m.register(sys.modules[__name__])
del _m