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 re import subprocess import sys import threading import time import uuid from datetime import datetime, timezone # noqa: F401 (timezone: split modules) 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, get_process_hermes_home, profile_name_for_home, reset_hermes_home_override, set_hermes_home_override) from hermes_cli.env_loader import load_hermes_dotenv from utils import file_signature, is_truthy_value from hermes_state_ids import new_session_id from tools.environments.local import hermes_subprocess_env from agent.fast_mode import STATIC_TIERS from agent.replay_cleanup import canonicalize_replay_history from agent.reasoning_effort import clamp_effort, route_supported_efforts 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 from tui_gateway._env import env_float, env_int from tui_gateway.turn_marker import clear_turn_marker, marker_writer_state, read_turn_marker, record_turn_start # noqa: F401 from tui_gateway.contracts import registry as _contracts # User-facing copy shared with the split method modules (they close over this namespace). from tui_gateway.user_messages import ( # noqa: F401 AGENT_BUILD_ABANDONED, AGENT_MISSING_FOR_TURN, AGENT_STILL_STARTING, agent_init_failed_message, busy_message, resume_failed_message, turn_error_text) from tui_gateway.transport import (FanoutTransport, StdioTransport, Transport, bind_transport, current_transport, reset_transport) logger = logging.getLogger(__name__) _hermes_home = _HERMES_HOME_AT_IMPORT = 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 the crash log + one-line stderr summary. _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: 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 sys.excepthook = _panic_hook threading.excepthook = lambda args: _record_crash( "thread exception", args.exc_type, args.exc_value, args.exc_traceback, thread_name=args.thread.name) 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] = {} _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 _cfg_cache: dict | None = None _cfg_sig: tuple | None = None _cfg_path = None # Idempotency registry for session.create: maps client-supplied key → sid so a # retried create (e.g. response lost in transit) returns the same session # instead of spawning a duplicate child. Entries expire with the session. _idempotency_keys: dict[str, tuple[str, float]] = {} _IDEMPOTENCY_KEY_TTL = 300.0 # 5 min: longer than any realistic retry window _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.`` 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(): raw = None with contextlib.suppress(Exception): from hermes_cli.config import load_config raw = (load_config().get("dashboard") or {}).get(cfg_key) with contextlib.suppress(ValueError, TypeError): return max(0.0, float(raw) if raw is not None else default) return max(0.0, default) # When a WebSocket client (the dashboard's embedded-chat tab / desktop app) disconnects, ``tui_gateway.ws`` # detaches the transport but intentionally leaves the session parked so a quick reconnect can reattach it # (see ws.py). That park is unbounded, though: a browser refresh spins up a brand-new ``session.create`` # (new sid + a fresh _SlashWorker via _deferred_build) and never reattaches the OLD sid, so the old # session's slash-worker subprocess lingers forever — one leaked python process per refresh (#38591 # fallout). After this grace window, an orphaned WS session is interrupted if it is still running, then # reaped once the normal turn-finalization path settles. Set to 0 to disable (park forever, pre-fix # behaviour). 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 reap Timer whose wall-clock wait outlasted its awake-time (monotonic) wait by more than # this fired early because the host slept through it (#44183); re-arm for the remaining # awake grace instead of reaping. Large enough to ignore timer jitter and NTP slew, small # relative to any real sleep. _WS_ORPHAN_REAP_SLEEP_SLACK_S = 0.5 # A detached RUNNING turn is interrupted only once its activity clock (API waits, stream tokens, tool # heartbeats) idled this long; 600s = the turn-liveness watchdog so "wedged" means the same. 0 disables. _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 # Interrupt-then-reap poll budget: a turn that never settles (thread hung in a syscall) would # reschedule the 1s poll forever; after this many polls, log loudly and force-reap. # If an interrupted turn never settles (agent thread hung in a syscall, supervisor lost), each 1s poll would # otherwise reschedule forever — trading the old leak-one-worker bug for leak-one-session-plus-timer-chain # (review finding, PR #90373). After this many polls we log loudly and force-reap, mirroring the # pre-existing stuck-`running` safety net's role of breaking the deadlock. _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, so only THESE go to a small thread pool; everything else # stays inline so fast-path ordering stays sane (write_json is _stdout_lock-guarded). 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.* = 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; session.save = a full stored-session read + JSON # render (up to sessions.max_export_messages rows, ~0.8s at the default cap). _LONG_HANDLERS = frozenset({ "session.foreign.list", "session.foreign.preview", "session.foreign.import", "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", "free_tier.provision", "voice.toggle", "voice.record", "voice.tts", "wake.start", "wake.status", "session.active_list", "session.branch", "session.compress", "session.list", "session.resume", "session.save", "session.workspace.move", "shell.exec", "skills.manage", "slash.exec", "command.dispatch", # /goal draft invokes the auxiliary model; never block the RPC reader "shared_metrics.set", # consent reconcile waits on the metrics store's write lock }) _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 record 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 (managed-first policy for the Browser Use CLI), venv bin and ~/.local/bin to PATH so slash_worker children resolve Hermes-managed CLIs under the Desktop's minimal PATH. The PM store dirs go in front of all of them: a user's node/uv in ~/.local/bin never wins.""" managed_bin = "" with contextlib.suppress(Exception): managed_bin = str(Path(get_hermes_home()) / "bin") venv_bin = str(Path(sys.executable).parent) # /bin (POSIX) or /Scripts (Windows) parts = [p for p in (managed_bin, venv_bin, str(Path.home() / ".local" / "bin"), env.get("PATH") or "") if p] env["PATH"] = os.pathsep.join(parts) with contextlib.suppress(Exception): import pm env["PATH"] = pm.store_first_path(env["PATH"]) return env class _SlashWorker: """Persistent HermesCLI subprocess for slash commands.""" def __init__(self, session_key: str, model: str, profile_home: str | None = None, provider: str | None = None): self._lock = threading.Lock() self._seq = 0 self.stderr_tail: list[str] = [] self.stdout_queue: queue.Queue[dict | None] = queue.Queue() # ``--provider`` pins the child to the parent agent's virtual provider: without it the # worker re-resolves provider from config, so a MoA session (provider=moa, model=) # dispatched its preset NAME to the configured real provider and 402/503'd (#57283). argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key, "--parent-pid", str(os.getpid())] \ + (["--model", model] if model else []) \ + (["--provider", provider] if provider else []) self._closed = False from hermes_cli._subprocess_compat import windows_hide_flags # slash_worker runs the Hermes agent → needs provider credentials. Tier-1 secrets # (gateway/GitHub/infra) are still stripped (#29157). Global-remote / multi-profile sessions: the # worker must resolve config/skills/state against the session's profile home, not the gateway's # launch HERMES_HOME (#40677). from tools.environments.local import served_profile_child_env from agent.secret_scope import is_multiplex_active # The worker runs the agent → needs provider credentials; tier-1 secrets (gateway/GitHub/ # infra) are still stripped. A served profile's worker gets THAT profile's home + secrets and # none of the launch profile's .env / TERMINAL_* residue, exactly what a standalone # `hermes -p X` would load itself. The launch profile is a profile too: once the process hosts # a second home (multiplex flipped), its worker must name its own home or the fail-closed # no-target/no-scope path raises UnscopedSecretError (#115427). env = _prepend_tool_paths(served_profile_child_env( target_home=profile_home or (_hermes_home if is_multiplex_active() else None), inherit_credentials=True)) # Internal slash workers must import the same checkout as their parent. module_root = str(Path(__file__).resolve().parent.parent) env["PYTHONPATH"] = os.pathsep.join( part for part in (module_root, env.get("PYTHONPATH", "")) if part ) # 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. # Prepend the Hermes venv bin dir and the user-local bin dir to PATH so slash_worker child processes # can resolve Hermes-managed CLIs (browser-use, uvx) even when the parent gateway was launched with # a minimal PATH (e.g. by the Desktop/Dashboard app). See #83845. 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: """Run one command; return its output text. A command like /prompt may also have parked a next-turn prompt (a "seed") on the worker CLI; it rides back on the reply's ``seed`` field and is retrieved separately via ``pop_seed()``. """ 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")) self._last_seed = str(msg.get("seed", "") or "") 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 pop_seed(self) -> str: """Return and clear the seed from the last ``run()`` (empty when none).""" seed, self._last_seed = getattr(self, "_last_seed", ""), "" return seed 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 transcripts (bounded budget) BEFORE the slow teardown so a supervisor SIGKILL can't lose them. for step in (_flush_sessions_before_exit, _release_gateway_wake_owner, _stop_turns_before_exit): with contextlib.suppress(Exception): step() with _sessions_lock: sids = list(_sessions) for sid in sids: _close_session_by_id(sid, end_reason="tui_shutdown") # Session reaping / flushing knobs (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): 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 _launch_home() -> Path: """The launch profile's home at call time: the patched ``_hermes_home`` when a test changed it, else the live process home — resolved through :func:`get_process_hermes_home`, which honours ``HERMES_HOME`` but ignores the context-local override. The desktop multiplex cron ticker sets that override per profile at startup, and a first touch inside a foreign window would bind process-wide launch state (the shared ``state.db`` handle, the launch ``.env`` secrets) to another profile (#102526). Resolving here rather than at import time lets a harness that redirects ``HERMES_HOME`` after import be honoured (#112692).""" home = _hermes_home if _hermes_home != _HERMES_HOME_AT_IMPORT else get_process_hermes_home() return Path(home) def _launch_state_db_path() -> Path: """Launch profile's ``state.db`` (see :func:`_launch_home`).""" return _launch_home() / "state.db" def _get_db(): global _db, _db_error if _db is None: from hermes_state_registry import acquire try: # Launch home, never the context-local override (#102526); resolved at first # use, not import time (#112692). See _launch_state_db_path. _db, _db_error = acquire(_launch_state_db_path()), 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 _transfer_db_to_agent(agent, db) -> bool: """Hand a DEDICATED profile ``state.db`` handle to *agent* (``AIAgent.close()`` then releases it). False = agent not holding *this* handle (build failed before ``_make_agent`` or got a different db): the caller still owns it. The shared launch handle never transfers — it outlives every agent, and ownership would let session.close() tear down the process-wide database.""" with contextlib.suppress(Exception): if agent is None or db is None or getattr(agent, "_session_db", None) is not db: return False # Defense in depth (#91610): the shared launch handle must never transfer. Identity alone passes for # it — a launch-profile agent IS holding that handle — and ownership would make session.close() tear # down the process-wide database every other session shares. Refuse it explicitly even if a caller # invokes the transfer incorrectly; the caller's own `owns_db` gate is the first line of defense. 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 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 rows into the wrong profile's store exactly when the profile store is briefly unopenable (locked, mid-restore); callers let the error abort the build (→ ``agent_error``).""" from hermes_state_registry import acquire db_path = Path(profile_home) / "state.db" try: return acquire(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, *, writer: bool = False): """Yield the SessionDB for ``params['profile']`` (None when unavailable); closes dedicated profile handles, leaves the launch-profile shared handle open. Foreign-profile handles are read-only unless ``writer=True``: that store belongs to ITS gateway/dashboard, and a writer here would take its write lock per RPC. Mirrors hermes_cli.web_routers.profiles._read_profile_db.""" profile = (params.get("profile") or "").strip() or None if isinstance(params, dict) else None # Launch/own profile → the shared _get_db() handle (left open); another profile → a dedicated # handle closed below (app-global remote mode). db is None when unavailable. if (profile_home := _profile_home(profile)) is None: db, owns = _get_db(), False else: try: if writer: from hermes_state_registry import acquire db = acquire(Path(profile_home) / "state.db") else: from hermes_cli.web_server_sessions import _open_session_db_at_path db = _open_session_db_at_path(Path(profile_home) / "state.db", read_only=True) owns = True except Exception as exc: logger.warning("TUI profile session store unavailable for %s: %s", profile, exc) db, owns = None, False try: yield db finally: if owns and db is not None: with contextlib.suppress(Exception): db.close() def _canonical_profile_request(name: str) -> str: """Canonicalize profile basenames emitted by older session-info payloads. ``Path(default_home).name`` was historically sent as a profile id. Those basenames are installation details — unless a real named profile of that name exists (``hermes`` is a legal id), in which case it wins; other unknown names keep failing closed in ``_profile_home``. """ if name.casefold() in {".hermes", "hermes"}: from hermes_cli import profiles as profiles_mod # Check the profiles root directly: get_profile_dir rejects "hermes" as a # reserved name, but a pre-reserved-list install may still carry that dir. if not (profiles_mod._get_profiles_root() / profiles_mod.normalize_profile_name(name)).is_dir(): return "default" return name 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 = _canonical_profile_request((profile or "").strip()) if not name: return _current_profile_name() try: return name if _profile_home(name) is not None else _current_profile_name() except ProfileUnavailableError: return _current_profile_name() def _db_unavailable_error(rid, *, code: int): from hermes_state_user_copy import describe_storage_failure, storage_failure_details failure = describe_storage_failure(_db_error) return _err( rid, code, f"Session storage is unavailable: {failure.gloss}. {failure.action}", data={"code": failure.code, "cause": failure.cause, "details": storage_failure_details(_db_error)}) # ── Per-session profile scoping: 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) so config/skills/model/persistence resolve to it. Omitted/own profile → launch profile. class ProfileUnavailableError(FileNotFoundError): """An explicit ``profile`` param names no live profile on this host. Raised out of the method (never a silent fall-back to the launch profile); ``handle_request`` turns it into JSON-RPC 4064 so a client holding a deleted profile gets a typed error instead of a ws dispatch crash (#107829).""" def _profile_home(profile: str | None) -> Path | None: """Resolve a named profile's home on THIS host, or None for the launch profile.""" if not (name := _canonical_profile_request((profile or "").strip())): return None from hermes_cli import profiles as profiles_mod try: home = Path(profiles_mod.get_profile_dir(name)) except ValueError: home = None if home is None or not home.is_dir(): raise ProfileUnavailableError(f"Profile '{name}' does not exist.") if home.resolve() == Path(_hermes_home).resolve(): return None # already the launch profile (no override needed) if home not in _served_profile_homes: # This process now hosts a second profile home: freeze the launch env as the launch # profile's own and flip get_secret() to fail closed, so an unscoped read for a # secondary raises instead of returning the launch profile's os.environ value # (tui_gateway/launch_profile_policy.py). Must run before any secondary code. from tui_gateway.launch_profile_policy import activate_multi_profile_hosting activate_multi_profile_hosting() _served_profile_homes.add(home) # the change watcher must stat every served sibling store too return home # 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 full runtime scope (HERMES_HOME + secrets + terminal policy) around a handler, so config.yaml ``${VAR}`` refs, provider credential checks and ``.env`` writes resolve to THAT profile (app-global remote mode hits the focused profile). Home alone left ``get_secret`` on the launch process's ``os.environ``: ``config.get full`` for a secondary shipped the default profile's expanded secrets and ``config.set`` published a secondary's ``.env`` edit into the shared process env. Launch profile: unscoped while this is a single-profile process (legacy ``os.environ`` precedence, systemd / ``op run`` injection); once multiplexing is active it binds its own scope from the env frozen at activation (``_session_profile_runtime_scope``), never ambient state a secondary context might have poisoned (#107422). No ``profile`` param but a ``session_id`` naming a live session: that session's ``profile_home`` is the scope. The TUI and the Desktop's ambient dispatcher send session-bound RPCs with the session id alone, so a ``config.set`` from a focused worker session otherwise persisted into the LAUNCH profile's config.yaml while the worker's stayed unchanged (#85669). """ def wrapper(rid, params): p = params if isinstance(params, dict) else {} if str(p.get("profile") or "").strip(): home = _profile_home(p.get("profile")) profile_home = str(home) if home else None else: session = _sessions.get(str(p.get("session_id") or "")) profile_home = session.get("profile_home") if isinstance(session, dict) else None with _session_profile_runtime_scope({"profile_home": profile_home or None}): return handler(rid, params) return wrapper # Placeholder ``terminal.cwd`` values (resolved to the home dir at runtime) — never 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 profile's ``terminal.cwd`` from ITS config.yaml (fail-open → None): the process-global ``TERMINAL_CWD`` belongs to the *launch* profile, and load_config() resolves the ACTIVE profile, so read the requested file through the same effective-config pipeline as ``_load_cfg``. A new session bound to another profile must take its workspace from THAT profile's config, not the stale env var (issue #40334). Returns an absolute, existing directory, or None for placeholders / missing / invalid paths. """ if profile_home is None: return None with contextlib.suppress(Exception): from hermes_cli.config_effective import load_user_config_effective p = Path(profile_home) / "config.yaml" return _configured_cwd_from_cfg(load_user_config_effective(p)) if p.exists() else None 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 otherwise start in ``os.getcwd()``.""" # Read the launch file by path. ``_load_cfg`` follows the active HERMES_HOME # override, which may belong to a different profile-scoped RPC. return _profile_configured_cwd(Path(_hermes_home)) def _default_session_cwd() -> str: """Fallback cwd when no explicit / stored / profile cwd (mirrors :func:`_completion_cwd`'s tail 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: (1) event frames with a session id → that session's transport (async events reach the owner even from threads with no contextvar binding); (2) the context-bound transport (:func:`dispatch`); (3) module stdio (tests monkey-patch ``_real_stdout``). Every event frame gets a per-session monotonic ``seq`` + replay-ring entry so ``session.events.since`` can resume.""" from tui_gateway.event_replay import _stamp_event from tui_gateway.hosted_room_member_activity import project_room_member_activity _stamp_event(obj) params = obj.get("params") if obj.get("method") == "event" or (isinstance(obj.get("id"), str) and "method" in obj): # Event notifications AND server→client requests carry ``params.session_id``; both route to the # owning session's transport. A room member's hidden session has no transport: its frames would # die at stdio below. project_room_member_activity(obj, _sessions) 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: _contracts.check_payload(event, payload) params: dict = {"type": event, "session_id": sid, **({"payload": payload} if payload is not None else {})} return {"jsonrpc": "2.0", "method": "event", "params": params} def _emit(event: str, sid: str, payload: dict | None = None) -> bool: from agent.notification_presentation import event_presentation_muted if event_presentation_muted(event, sid): return False return write_json(_event_frame(event, sid, payload)) from tui_gateway import server_requests as _server_requests # noqa: E402 _server_requests.bind_sinks(lambda frame: write_json(frame), lambda event, sid, payload: _emit(event, sid, payload), lambda sid: _session_client_answers_requests(sid), lambda sid: _session_answering_clients(sid)) # 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() # True only when real stdout IS the JSON-RPC client channel (``tui_gateway.entry.main``, the stdio TUI). # `hermes serve` / dashboard processes speak JSON-RPC over WS only: their stdout is captured into # desktop.log, so a peer-less global broadcast (the change watcher keeps ticking after the last WS client # leaves) must be dropped there, not printed. _stdio_is_rpc_channel = False def register_live_transport(transport: Transport | None) -> None: """Track a connected client transport for global broadcasts. Idempotent.""" if transport is not None: 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) _server_requests.forget(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 bottom out at stdio in ``write_json``'s ladder. No registered transports → ``_emit`` when stdout is the stdio TUI's JSON-RPC channel, else dropped (nobody is listening; stdout is a log sink).""" with _live_transports_lock: targets = list(_live_transports) if not targets: if _stdio_is_rpc_channel: return _emit(event, "", payload) logger.debug("global-event broadcast dropped (no connected client) type=%s", event) return None 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 _open_requests(sid: str) -> list[dict]: """Server→client requests still waiting on *sid*'s renderer, for reconnect snapshots (``session.resume`` / ``session.activate`` / ``session.events.since``). A client detached when the request frame was written would otherwise never see it (agent parked until timeout). Under turn isolation the compute-host child owns the request; the parent mirrors it from the relayed frame (compute_host_bridge).""" from tui_gateway import server_requests reqs = server_requests.open_requests(sid) if reqs: return reqs if (session := _sessions.get(sid)) is not None: with session.get("history_lock", threading.Lock()): mirrored = session.get("_compute_host_open_request") return [dict(mirrored)] if isinstance(mirrored, dict) else [] return [] def _pending_connection_request_payload(sid: str) -> dict | None: """The open connection operation on *sid* as its ``connection.request`` payload, so a client that missed the event (or restarted) restores the card with the server's deadline.""" from tools.connectors import live session = _sessions.get(sid) operation = (live.current(str(session.get("session_key") or ""), profile_home=session.get("profile_home")) if session else None) return operation.request_payload() if operation is not None else 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: """Send an ``approval`` server request with the command redacted: a credential-shaped value Tirith flagged would otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream). See #48456, #50767. The wait is owned by ``tools.approval``'s queue (its own timeout, ``/approve all``, coalescing), so the request is queue-backed: the response resolves the queue entry, and the entry's own resolution (any surface, timeout, interrupt) withdraws the request with ``request.cancel``.""" from tui_gateway import server_requests from tools import approval as _approval payload = _approval_request_payload(data) request_id = str(payload.get("request_id") or "") session_key = str((_sessions.get(sid) or {}).get("session_key") or "") def on_result(result: dict | None) -> None: if result is None: # No client can answer this prompt: the request was never sent (the only attached client predates # server→client requests) or the client answered -32601 (no handler). Without withdrawing the # queue entry the agent would idle for the whole approvals.timeout with no prompt anywhere # (#112548). A withdrawal, not a deny: nobody refused the command. if request_id: _approval.withdraw_gateway_approval(session_key, request_id, "the attached client cannot answer approval requests " "(update the Hermes app)") return choice = str(result.get("choice") or "deny") _approval.resolve_gateway_approval(session_key, choice, resolve_all=bool(result.get("all")), request_id=request_id or None) settle = server_requests.send_async("approval", sid, payload, on_result) # The wait can end between the frame going out and the hook attaching (the client answered by RPC, another # surface resolved it): withdraw now, or the request stays in ``open_requests`` for every later resume. if request_id and not _approval.register_gateway_settle(session_key, request_id, settle): settle("resolved") def _status_update(sid: str, kind: str, text: str | None = None): if not (body := (text if text is not None else kind).strip()): 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. # See #97239. 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 _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 = (int(v) for v in img.size) # Rough attachment-display token estimate: 512px tiles at ~85 tokens/tile (cross-provider hint). tiles = max(1, (width + 511) // 512) * max(1, (height + 511) // 512) if width > 0 and height > 0 else 0 meta.update(width=width, height=height, token_estimate=tiles * 85) 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, **({"data": data} if data is not None else {})} return {"jsonrpc": "2.0", "id": rid, "error": error} def register_method(name: str, fn) -> None: """The ONE registration seam (``@method`` here and ``HandlerRegistry.install`` for the split modules). ``tests/tui_gateway/contracts/test_generated.py::test_every_method_has_a_contract`` and the generator's ``assert_complete`` fail when a registered name has no contract.""" _methods[name] = fn def method(name: str): def dec(fn): register_method(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 not None and not isinstance(params, dict): return _err(rid, -32602, "invalid params: expected an object") return rid, method, params if params is not None else {} 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 requires the ContextVar-bound transport to be ATTACHED to the live in-memory record under that id, so transport detachment, session 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) # Membership, not slot identity: a mirrored session stores a FanoutTransport in the slot, so slot # identity alone would reject every client, the peer that commissioned the subagent included. # Authority is membership in the slot, which also grants it to any client attached to mirror the # session (see tests/tui_gateway/test_multi_client_fanout.py). if (session is None or (expected_session is not None and session is not expected_session) or not _session_transport_contains(session, transport)): return None, None return transport, session 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_STILL_STARTING) return _err(rid, 5032, err) if (err := session.get("agent_error")) 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 deferred prompt.submit: the client already got ``streaming`` and the first message IS the turn, while a cold build routinely outlives the flat 30s ceiling (timing out silently discarded it). Waits in short slices (cancel honored promptly), notifies once (keyed) past ``_AGENT_BUILD_SLOW_NOTICE_AFTER``, fails only on a dead build thread or the bounded cap. Returns None on success OR cancel mid-wait (the caller's cancel branch owns that messaging). The flat 30s ``_wait_agent`` ceiling was a message-eating cliff (#63078): ``prompt.submit`` has already returned ``{"status": "streaming"}``, the user's first message IS the turn in flight, and the deferred agent build (MCP discovery with per-server retry backoff, synchronous model-metadata HTTP, skills scanning) routinely outlives 30 seconds on cold starts. On timeout the old path emitted an error EVENT and returned without ever calling ``_run_prompt_submit`` — the first message was permanently discarded while the build finished successfully in the background, leaving the blank first session. """ ready = session.get("agent_ready") if ready is None: return None start, cap, notified_slow = time.monotonic(), _agent_build_wait_cap(), 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}) return _err(rid, 5032, err) if (err := session.get("agent_error")) else None def _bind_build_profile_scopes(profile_home: "str | None") -> "_TurnScopes | None": """Bind a session profile's HERMES_HOME / secret / terminal scopes for an agent build. ``None`` is the launch profile: its own launch-env secret scope (live env while single-profile, frozen once multiplexing is active — a hosted-room turn for a default member otherwise died at build with ``UnscopedSecretError`` because the launch profile was treated as "no scope"). 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.""" scopes = _TurnScopes() with contextlib.suppress(Exception): return _profile_runtime_scope_tokens(profile_home) if profile_home: # secret/terminal helper failed: keep at least the home + terminal refusal scope scopes.home = set_hermes_home_override(profile_home) with contextlib.suppress(Exception): from tools.terminal_scope import install_profile_terminal_scope scopes.terminal = install_profile_terminal_scope(Path(profile_home)) return scopes def _release_build_profile_scopes(scopes: "_TurnScopes | None") -> None: with contextlib.suppress(Exception): _release_profile_runtime_scope_tokens(scopes) 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 (like the eager resume's overrides splat) so the build can't drop the provider. No stored runtime, or an unroutable provider → this session's picked model/effort/tier, else the default.""" kw = {"session_db": session_db, "context_cwd_is_launch_artifact": _context_cwd_is_launch_artifact(current), "platform_override": _session_source(current), "cwd_override": _session_cwd(current)} if resume_sid := current.get("resume_session_id"): kw["session_id"] = resume_sid 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 kw.update({k: v for k, v in (("reasoning_config_override", current.get("create_reasoning_override")), ("service_tier_override", current.get("create_service_tier_override"))) if v is not None}) return kw def _wire_session_agent(sid: str, key: str, agent) -> bool: """Post-build wiring; returns whether the approval notify got registered. Approval prompts route to the client; the self-improvement "💾 …" summary is emitted as review.summary (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 (rec := _sessions.get(sid)) is not None: rec["_notif_stop"] = _start_notification_poller(sid, rec) _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(sid: str, current: dict, agent) -> bool: """Attach a freshly built agent to its live record (session DB row deferred to first run_conversation()). False when ``session.close`` popped this record mid-build: teardown saw ``agent=None`` and closed nothing, so the caller owns closing the orphan (#49852).""" # Bot Mode gate hint: the DB title lands post-first-turn but the system prompt builds at turn START. if _title_hint := str(current.get("pending_title") or "").strip(): agent._session_title_hint = _title_hint # Under the same lock session.close takes to pop the record: no window between "still live" and "attached". with _sessions_lock: if _sessions.get(sid) is not current: return False current["agent"] = agent # A workspace move can land while construction is still in flight. _register_session_cwd(current) _session_todo_state(current) # Baseline for the per-turn config sync (profile home override still active). current["config_model_seen"] = _config_model_target() return True 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 (notice_callback already 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) _schedule_mcp_late_refresh(sid, agent) # servers slower than the bounded discovery wait land here 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) # Reaped after the agent was attached: _attach_worker closed the worker; only a 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 be torn down, else close it (build # failed, or `replaced`: this agent is discarded and _teardown_session never reaches it). if session_db is not None and not _transfer_db_to_agent(None if replaced else current.get("agent"), 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 needing the agent) so the composer isn't blocked on tool discovery / model metadata; the ready/error event contract 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 (it bails once agent is set); incidental RPCs via _sess() would upgrade it mid-stream. if session.get("lazy") and _child_run_active( str(session.get("session_key") or ""), session.get("profile_home") or None): return with session.setdefault("agent_build_lock", threading.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: # Closed/reaped before the build started: nothing will ever attach an agent to this # record, yet ``agent_ready`` must be set so a prompt waiting on it fails instead of hanging. session["agent_error"] = AGENT_BUILD_ABANDONED ready.set() return notify_registered, scopes, session_db = False, None, None profile_home = current.get("profile_home") try: if not _await_resume_history(sid, current): # Replaced mid-build: the finally still sets ``agent_ready`` with ``agent`` None, so record # why — a turn admitted against this record refuses with the real reason (#111531). current["agent_error"] = AGENT_BUILD_ABANDONED return tokens = _set_session_context(key, cwd=_session_cwd(current)) # Global-remote: bind the session profile's HERMES_HOME and hand the agent that profile's db — # DEDICATED and ours until _transfer_db_to_agent in the finally; FAIL CLOSED rather than # binding the launch DB and bleeding rows into the wrong state.db. scopes = _bind_build_profile_scopes(profile_home) if 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 atomically against session teardown: ``session.close`` may have popped this # session while the expensive build was in flight, in which case teardown could not close # an agent that did not exist yet. Release the orphan immediately and do not keep wiring # workers/callbacks for a dead session (#49852). if not _attach_built_agent(sid, current, agent): # Same contract as the replaced-before-attach exit above: a turn admitted against # this record must refuse with the real reason rather than a generic missing agent. current["agent_error"] = AGENT_BUILD_ABANDONED with contextlib.suppress(Exception): if hasattr(agent, "close"): agent.close() return # 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: from agent.auxiliary_unavailable import ProviderNotConfiguredError current["agent_error"] = str(e) # A client can route "no provider is set up" to its setup flow instead of a dead-end # error toast — but only if it can tell. The sentence is for the reader, the code is # for the client; older clients keep matching the text. _emit("error", sid, { "message": agent_init_failed_message(e), **({"code": "provider_not_configured"} if isinstance(e, ProviderNotConfiguredError) else {})}) 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) # _wait_agent_for_prompt handle: dead thread + unset agent_ready = died hard; waiters must not sit out the cap. 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 (reaped/evicted/TTL): 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 " # A session-scoped RPC hit a runtime id the gateway no longer holds (detached on WS # disconnect and orphan-reaped, LRU-evicted, or torn down after an idle TTL). The client # is expected to recover via session.resume on the STORED session id, but a plain # stale-id send leaves no trace anywhere when the resume never fires — every RPC in this # class returned a silent 4001. Log it so a "message vanished" report is diagnosable as # "request arrived and was rejected" instead of "request never arrived" (see #90428). "(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 the attach RPCs (image/file/pdf, clipboard.paste, image.detach), which only touch creation-time fields and run inline on the socket reader thread, where waiting on a cold build stalled every RPC behind it ("text is instant, images hang").""" s, err = _sess_nowait(params, rid) if not err: _start_agent_build(params.get("session_id") or "", s) return (None, err) if err else (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 dash = root.get("dashboard") if isinstance(root, dict) else {} dash = dash if isinstance(dash, dict) else {} return { "turn_isolation": is_truthy_value(dash.get("turn_isolation"), default=_DASHBOARD_TURN_ISOLATION_DEFAULT), "compute_host_heartbeat_secs": _coerce_int_config_value( dash.get("compute_host_heartbeat_secs"), _DASHBOARD_COMPUTE_HOST_HEARTBEAT_SECS_DEFAULT, min_value=1), "compute_host_respawn_max": _coerce_int_config_value( dash.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 / managed overlay / ``${VAR}`` expansion applied here would be persisted 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_sig, _cfg_path from hermes_cli.config import read_user_config_raw from hermes_cli.config_read_errors import FailedConfigRead try: p = _active_config_path() sig = file_signature(p.stat()) if p.exists() else None with _cfg_lock: if _cfg_cache is not None and _cfg_sig == sig and _cfg_path == p: return copy.deepcopy(_cfg_cache) data = read_user_config_raw(p) if p.exists() else {} except Exception as exc: return FailedConfigRead(error=exc) # readable as {}, refused by _save_cfg with _cfg_lock: # cache the RAW config: _save_cfg writes _cfg_cache back to disk _cfg_cache, _cfg_sig, _cfg_path = copy.deepcopy(data), sig, p return data def _load_cfg() -> dict: """Behavioral config read: the effective USER config (managed overlay, ``${VAR}`` expansion, model-key canon) minus the DEFAULT_CONFIG merge — callers treat a missing key as "unset", so merging would break ``_load_cfg() == {}`` sentinels. Fail-open to ``{}``. Never pass the result to ``_save_cfg`` (use ``_load_cfg_raw()``).""" with contextlib.suppress(Exception): from hermes_cli.config_effective import load_user_config_effective return load_user_config_effective(_active_config_path()) return {} def _save_cfg(cfg: dict): global _cfg_cache, _cfg_sig, _cfg_path from hermes_cli.config import atomic_config_replace path = _active_config_path() atomic_config_replace(path, cfg) with _cfg_lock: _cfg_cache, _cfg_path = copy.deepcopy(cfg), path try: _cfg_sig = file_signature(path.stat()) except Exception: _cfg_sig = 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 _set_session_context(session_key: str, cwd: str | None = None, *, ui_session_id: str = "") -> list: with contextlib.suppress(Exception): from gateway.session_context import set_session_vars sess = _session_for_key(session_key) if session_key else None # 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 (str(sess.get("cwd") or "") if sess is not None else "") source = _resolve_session_platform() profile = _current_profile_name() browser_control_principal = browser_control_transport_family = "" # Live conversation id for subprocess HERMES_SESSION_ID: an explicitly empty contextvar is authoritative # (no os.environ fallback), so never leave it "" — agent's durable session_id, then session_key. session_id = session_key if sess is not None: source = _session_source(sess) # App-global backends multiplex profiles: prefer the live session's own home. profile = profile_name_for_home(sess.get("profile_home")) or profile 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, profile=profile, browser_control_principal=browser_control_principal, browser_control_transport_family=browser_control_transport_family, cwd=resolved, ui_session_id=ui_session_id, cron_session="") return [] def _clear_session_context(tokens: list) -> None: if tokens: 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.update(HERMES_GATEWAY_SESSION="1", HERMES_EXEC_ASK="1", HERMES_INTERACTIVE="1") # ── Blocking prompt factory ────────────────────────────────────────── def _ask(method: str, sid: str, params: dict, timeout: float | None = 300) -> str: """Server→client request whose answer is one string under ``value`` (sudo, secret, vault prompts, GUI reads, MCP setup). Empty string when the renderer skipped, timed out, or was cancelled.""" from tui_gateway import server_requests result = server_requests.send(method, sid, params, timeout=timeout) value = (result or {}).get("value", "") return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) 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).""" with contextlib.suppress(Exception): from tools.clarify_gateway import get_clarify_timeout timeout = get_clarify_timeout() return timeout if timeout > 0 else None return 300 def _clarify_block(sid: str, questions: list[dict]) -> dict: """Bridge the clarify tool callback onto one ``clarify`` server request carrying only the wire fields (tool-side entries carry result-assembly keys too). Answers lock one at a time through ``clarify.lock`` (``null`` = skipped); the tool gets ``{"answers", "outcome"}`` — ``undelivered`` when no client took it.""" from tui_gateway import server_requests wire = [{"qid": e["qid"], "question": e["question"], "choices": e["choices"], "multi_select": bool(e["multi_select"])} for e in questions] result = server_requests.send("clarify", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(), qids=[e["qid"] for e in questions]) return result or {"answers": {}, "outcome": "undelivered"} # A tour action is a DOM op the renderer answers in ms; 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. _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 a ``tour`` server request without paying for a client that cannot answer: against an older app nobody answers ``tour`` and each action would block the full deadline, stacking per turn. First action per session gets the short probe deadline; unanswered → bridge marked unavailable for that session; once answered, the full deadline. Verdict lives on the record, so a new session re-probes. The renderer's ``tour`` handler ships in the desktop bundle, but the tool is offered by this backend — and the two update on different clocks. The model then does what the schema tells it to and tries the next action, so a single "give me a tour" turn stacks those waits (the timeouts reported against #89620). """ session = _sessions.get(sid) if session is None: # detached caller: throwaway record, plain bridge, unprobed ({} is falsy but a REAL record) session = {} state = session.get("tour_bridge") if state == "unanswered": return _TOUR_BRIDGE_UNAVAILABLE answer = _ask("tour", 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 _PREVIEW_ACTION_TIMEOUT_S = 45 # Until a session's client has proven it answers preview.act at all, hold it to a # deadline a working renderer cannot miss (same ladder as the tour probe). _PREVIEW_ACTION_PROBE_TIMEOUT_S = 10 # An unanswered probe condemns the bridge only until the cooldown expires: the # renderer may attach late (app launched after the turn started). One caller at # a time re-probes; concurrent siblings fail fast instead of stacking waits. _PREVIEW_ACTION_REPROBE_COOLDOWN_S = 30 _PREVIEW_ACTION_BRIDGE_UNAVAILABLE = json.dumps({ "success": False, "error": ("No Hermes Desktop window answered the preview action request. The drive_preview / " "annotate_preview bridge is served by the desktop app's renderer, which updates " "separately from this backend, so an app build older than the tool has nothing " "listening. Update the Hermes Desktop app, open a page with open_preview, and try " "again in this session after a short cooldown.")}) # One in-flight cooldown-expiry reprobe per session: concurrent callers fail fast. _preview_action_reprobe: dict[str, object] = {} _preview_action_reprobe_lock = threading.Lock() def _preview_action_request(sid: str, payload: dict) -> str: """Bridge the drive_preview / annotate_preview callback onto a ``preview.act`` server request without paying for a client that cannot answer: against an older app (or a session no window hosts, #94272 / #119333) nobody answers ``preview.act`` and each action would block the full deadline, stacking per turn exactly like the tour timeouts (#89620). First action per session gets the short probe deadline; unanswered → bridge marked unavailable for that session with a cooldown-gated reprobe; once answered, the full deadline. The verdict lives on the session record, so a new session re-probes. Interrupt ≠ timeout: a cancelled wait (Stop, session close) returns without poisoning the state, because ``send()``'s None conflates the two and only the cooldown-reprobe token distinguishes an in-flight probe — so state flips only through it. """ with _sessions_lock: session = _sessions.get(sid) if session is None: # detached caller: throwaway record, plain bridge, unprobed ({} is falsy but a REAL record) session = {} state = session.get("preview_action_bridge") now = time.monotonic() if state == "unanswered" and now < session.get("preview_action_bridge_retry_at", 0): return _PREVIEW_ACTION_BRIDGE_UNAVAILABLE if state == "unanswered": with _preview_action_reprobe_lock: if _preview_action_reprobe.get(sid) is not None: return _PREVIEW_ACTION_BRIDGE_UNAVAILABLE _preview_action_reprobe[sid] = object() session["preview_action_bridge_retry_at"] = now + _PREVIEW_ACTION_REPROBE_COOLDOWN_S try: answer = _ask("preview.act", sid, dict(payload), timeout=_PREVIEW_ACTION_TIMEOUT_S if state == "answered" else _PREVIEW_ACTION_PROBE_TIMEOUT_S) finally: if state == "unanswered": with _preview_action_reprobe_lock: _preview_action_reprobe.pop(sid, None) with _sessions_lock: if answer: session["preview_action_bridge"] = "answered" elif session.get("preview_action_bridge") != "answered": session["preview_action_bridge"] = "unanswered" session["preview_action_bridge_retry_at"] = time.monotonic() + _PREVIEW_ACTION_REPROBE_COOLDOWN_S return answer or _PREVIEW_ACTION_BRIDGE_UNAVAILABLE def _clear_pending(sid: str | None = None) -> None: """Withdraw open server→client requests: only *sid*'s (session.interrupt must not cancel other sessions' prompts), or every one when *sid* is None (process exit). Each one gets a ``request.cancel``.""" from tui_gateway import server_requests server_requests.cancel(sid, reason="interrupted" if sid else "shutdown") # ── 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 / config preference: the cost-safe silent default (cache-only read), never an unpicked flagship. with contextlib.suppress(Exception): from hermes_cli.models import get_preferred_silent_default_model return get_preferred_silent_default_model() return "z-ai/glm-5.2" def _resolve_session_platform() -> str: """``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: the HERMES_MODEL launch 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 if not (explicit_model := _env_model_seed()): return model, None with contextlib.suppress(Exception): from hermes_cli.model_switch import resolve_startup_model_route from hermes_cli.models import detect_static_provider_for_model full_cfg = _load_cfg() cfg = full_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") # Same owner as HermesCLI/oneshot: ``custom::`` selects that provider (#73943). if route := resolve_startup_model_route( explicit_model, current_provider=current_provider, user_providers=full_cfg.get("providers"), custom_providers=full_cfg.get("custom_providers")): return route.model, route.provider if detected := detect_static_provider_for_model(explicit_model, current_provider): provider, detected_model = detected return detected_model, provider return model, None def _is_routable_provider(provider: str) -> bool: with contextlib.suppress(Exception): from hermes_cli.runtime_provider import is_routable_provider return is_routable_provider(provider) 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 = NOT routable, so the caller falls back to the session's picked model.""" 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) return parsed if isinstance(parsed, dict) else {} except Exception: if not quiet: raise logger.debug("failed to parse stored session model_config", exc_info=True) return {} def _row_follows_profile(row: dict | None) -> bool: """Whether a stored row is a canonical Bot Chat whose runtime follows the member profile's config. Identity is the persisted ``follow_profile_config`` marker; the bare title compare stays ONLY here as the legacy fallback for rows written before the marker existed.""" if not row: return False model_config = _parse_model_config(row.get("model_config"), quiet=True) return bool(model_config.get("follow_profile_config") or str(row.get("title") or "").strip() == "Bot Chat") def _stored_session_runtime_overrides(row: dict | None) -> dict: """Runtime fields persisted with a stored session (model column, ``billing_provider``, JSON ``model_config``): resume restores the model/provider/reasoning THAT chat used, not the global pick. Plugin-owned Bot-Mode sessions normally rebuild from the member profile's CURRENT config (a stale provider pin left room bots "out of Nous credits" after a profile switch). A canonical Bot Chat may instead restore an explicit composer pick while the profile model it diverged from remains unchanged.""" if not row: return {} model_config = _parse_model_config(row.get("model_config"), quiet=True) _row_title = str(row.get("title") or "").strip() room_plumbing = model_config.get("room_plumbing") or (row.get("hidden") and _row_title.startswith("Group:")) composer_profile = model_config.get("composer_override_profile") composer_profile_matches = isinstance(composer_profile, dict) and ( str(composer_profile.get("model") or "").strip(), str(composer_profile.get("provider") or "").strip(), ) == _config_model_target() if room_plumbing or (_row_follows_profile(row) and not composer_profile_matches): return {} overrides: dict = {} model = str(row.get("model") or model_config.get("model") or "").strip() # Canonical route reader shared with CLI --resume: nested ``gateway_runtime`` (the route the messaging # gateway last ran) before the TUI's top-level keys, then a routable ``billing_provider`` (#125942). from hermes_state import SessionDB route = SessionDB.session_gateway_runtime(row) provider, base_url, api_mode = (str(route.get(k) or "").strip() for k in ("provider", "base_url", "api_mode")) service_tier = str(model_config.get("service_tier") or "").strip() reasoning_config = model_config.get("reasoning_config") from hermes_cli.runtime_provider import is_foreign_provider_endpoint if is_foreign_provider_endpoint(provider, base_url): # The endpoint and its wire belong to the provider this chat left; resolve the stored one's own. base_url = api_mode = "" # Heal a stale provider persisted by an older build (renamed/removed custom provider → "Unknown provider"): # recover ``custom:`` from the stored base_url, then from the entry serving the model; else drop it. 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: # None = "inherit the profile" at _make_agent; "" = real override "no priority tier" overrides["service_tier_override"] = "" if service_tier.lower() == "normal" else 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: resume reads provider/endpoint from this JSON (model column written separately), so a stale provider would route the resumed chat to the wrong endpoint.""" config = dict(existing or {}) attr = lambda k: str(getattr(agent, k, "") or "").strip() model, provider, base_url = attr("model"), attr("provider"), attr("base_url") if provider.lower() == "custom": # ``agent.provider`` resolves every named custom entry to the literal "custom", losing the entry # identity (api_key is never persisted): recover ``custom:`` from the endpoint URL. 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": attr("api_mode"), # 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.""" live = _live_session_agent_db(session) if live is None: return agent, session_key, db = live try: row = db.get_session(session_key) or {} model_config = _runtime_model_config(agent, _parse_model_config(row.get("model_config"))) if isinstance(composer_profile := session.get("composer_override_profile"), dict): model_config["composer_override_profile"] = composer_profile elif "composer_override_profile" in session: model_config.pop("composer_override_profile", None) if (tier_override := session.get("create_service_tier_override")) is not None: # agent.service_tier is None for explicit normal; without this the distinction is erased on every persist. model_config["service_tier"] = 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 _live_session_agent_db(session: dict | None): """(agent, session_key, db) for a live record, or None when any of them is missing.""" agent = (session or {}).get("agent") session_key = str((session or {}).get("session_key") or "").strip() if agent is None or not session_key: return None db = getattr(agent, "_session_db", None) or _get_db() return None if db is None else (agent, session_key, db) def _persist_live_session_system_prompt(session: dict | None) -> None: """Refresh the stored system prompt after a live runtime identity change.""" live = _live_session_agent_db(session) if live is None or not hasattr(live[0], "_build_system_prompt") or not hasattr(live[2], "update_system_prompt"): return agent, session_key, db = live # Re-bind the session's profile runtime scope (the build's finally reset it → root profile's SOUL.md/skills, # #50233) and session context (on the RPC thread _SESSION_CWD is unset → the process TERMINAL_CWD would # persist). The full scope, not HERMES_HOME alone: the external memory provider's system_prompt_block() # reads its credential through get_secret, which fails closed once this process multiplexes (#112927). session_tokens = _set_session_context(session_key, cwd=_session_cwd(session)) try: with _session_profile_runtime_scope(session): prompt = agent._cached_system_prompt = agent._build_system_prompt(None) 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) # Stable leading text of the model-switch marker (builder + dedup); only the newest marker is meaningful. # Only the newest marker is meaningful (it names the *currently* active model); older ones are stale and # would otherwise be re-sent to the provider on every turn (#65891). _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: """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.""" 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 ones, so N switches leave one marker, not N re-sent every API call; self-healing across resumes because the next switch collapses whatever a reload brought back). See #65891. """ session_key = str((session or {}).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. # See #48338. entry: dict[str, Any] = {"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 (contextlib.nullcontext(db) if db is not None else _session_db(session)) as db: if db is not None: from agent.context_compressor import _DB_PERSISTED_MARKER # Same stale-key hazard as the submit row: this durable pivot must land in the session the # live agent writes to, or a model switch between turns on a rotated session files the notice # under a parent the conversation no longer reads from (#123545). target = _submit_row_target_key(session) # The in-memory strip above keeps one marker; the durable rows need the same invariant or N # switches leave N active rows that all replay on resume (#65891 kept it in memory only). db.deactivate_messages_by_display_kind(target, "model_switch") from agent.message_metadata import stamp_message_uid entry["_row_id"] = db.append_message( session_id=target, role="user", content=marker, display_kind="model_switch", message_uid=stamp_message_uid(entry)) entry[_DB_PERSISTED_MARKER] = True except Exception: # warning, not debug: filing the pivot in the LIVE session (#123545) means this write can now hit # CompressionSessionClosedError on a closed parent, which the old session_key target could not. # Swallowed at debug, a model switch silently loses its durable notice — the next resume replays # without it and nothing lands in errors.log. Matches _persist_live_session_system_prompt above. logger.warning("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 overlaid/expanded view would persist it. 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`). `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 the gate's own ``_get_approval_mode`` (a raw re-read missed the managed overlay and ``${VAR}`` expansion).""" from tools.approval_context 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`` (bools: True → all, False → off); ``wheel`` (DEC 1000+1006) is the tmux-friendly subset without hover events. 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 isinstance(raw, str): return _MOUSE_TRACKING_ALIASES.get(raw.strip().lower(), "all") return "off" if raw is False or raw == 0 else "all" def _load_reasoning_config(model: str = "") -> dict | None: """Via the shared chokepoint :func:`hermes_constants.resolve_reasoning_config` (per-model override > global ``agent.reasoning_effort``; YAML False = disabled). Closes #21256. """ from hermes_constants import resolve_reasoning_config return resolve_reasoning_config(_load_cfg(), model) def _load_service_tier() -> str | None: from agent.fast_mode import parse_service_tier return parse_service_tier((_load_cfg().get("agent") or {}).get("service_tier", "")) def _load_provider_routing() -> dict: """OpenRouter ``provider_routing`` prefs (gateway/CLI parity — without them OpenRouter picks an effectively random provider).""" with contextlib.suppress(Exception): return _load_cfg().get("provider_routing", {}) or {} 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).""" 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 isinstance(raw, bool): return "all" if raw else "off" 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 (both off ``_HERMES_CORE_TOOLS``; this is the one gate). ``platform`` is the SESSION's source, never a process env var: the desktop may drive a URL/cloud backend where ``HERMES_DESKTOP`` is unset (AGENTS.md surface rule).""" from toolsets import CLIENT_SURFACE_TOOLSETS return set(CLIENT_SURFACE_TOOLSETS) if platform == "desktop" else {"project"} def _with_session_toolsets(selection, platform: str | None) -> list[str]: """*selection* plus what the session carries whatever its config says (the client surface's toolsets when *platform* is given; the ones its PROFILE's role reserves, from the backend-written profile.yaml under the session's home override), minus toolsets reserved for another role. The fold-in happens after ``_get_platform_tools`` already subtracted ``agent.disabled_toolsets``, so the same subtraction is applied to the fold-in itself — otherwise ``disabled_toolsets: [project]`` is a no-op on desktop/TUI, the only surfaces where the client toolsets exist (#54433). ``desktop_ui`` is kept regardless: it is the client's own control surface, not a model toolset.""" from toolsets import profile_role_toolsets granted, denied = profile_role_toolsets() surface = _gui_surface_toolsets(platform) if platform is not None else set() kept = [name for name in selection if name not in denied] fold_in = (surface | granted) - set(kept) disabled = set(_load_disabled_toolsets() or []) if disabled: fold_in -= disabled - {"desktop_ui"} return [*kept, *sorted(fold_in)] 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 = [] 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(f"[tui] HERMES_TUI_TOOLSETS=all enables every toolset; ignoring additional entries: {', '.join(ignored)}") return None if not unresolved: return built_in try: # (enabled, disabled) MCP server names from raw config; both empty on any failure from hermes_cli.config import read_raw_config from tools.mcp_tool_common import mcp_server_enabled raw_cfg = read_raw_config() mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {} mcp_names, mcp_disabled = set(), set() for name, server_cfg in mcp_servers.items(): if isinstance(server_cfg, dict): on = mcp_server_enabled(server_cfg) (mcp_names if on else mcp_disabled).add(str(name)) except Exception: mcp_names, mcp_disabled = set(), set() 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 " f"(set enabled: true in config.yaml to use): {', '.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 session (None = all): an explicit HERMES_TUI_TOOLSETS pin; else the coding posture (coding_context collapses to coding toolset + enabled MCP servers in a code workspace); else the configured CLI toolsets. Client-surface toolsets fold in here — only this surface 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(_with_session_toolsets(selection, 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: # An operator pin replaces the surface fold-in but never strips the profile's own role toolsets. return resolved if resolved is None else _with_session_toolsets(resolved, None) 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. # Passing ``False`` here is the config-editing variant — used when we need to persist a toolset list # without baking in implicit MCP defaults. Using the wrong variant at agent creation time makes MCP # tools silently missing from the TUI. See PR #3252 for the original design split. enabled = _get_platform_tools(cfg, "cli", include_default_mcp_servers=True) if fallback_notice is not None: _tui_notice(fallback_notice) return sorted(_with_session_toolsets(enabled, 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 _load_disabled_toolsets() -> list[str] | None: """``agent.disabled_toolsets`` from config.yaml, or ``None``. The classic CLI (``cli_init_mixin``) and the messaging gateway both forward this list to AIAgent, where ``get_tool_definitions`` strips the named toolsets even out of composite defaults like ``hermes-cli`` (#17309). The desktop/TUI gateway historically dropped it, so e.g. ``disabled_toolsets: [browser]`` silently had no effect on Desktop — the only consumer was ``_get_platform_tools``'s name-level subtraction, which can't reach inside a composite default toolset (#44499). """ try: from agent.skill_utils import parse_config_string_list from hermes_cli.config import load_config agent_cfg = load_config().get("agent") or {} disabled = parse_config_string_list(agent_cfg.get("disabled_toolsets")) return [str(ts) for ts in disabled] or None except Exception: 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 _session_show_reasoning(sid: str) -> bool: """Session display flag. Missing means the config default, not hidden.""" session = _sessions.get(sid) or {} if "show_reasoning" in session: return bool(session["show_reasoning"]) return _load_show_reasoning() def _tool_progress_enabled(sid: str) -> bool: return _session_tool_progress_mode(sid) != "off" # Names whose lifecycle a UI renders as a card even with display.tool_progress off. `isCardTool` / # `isFileEditTool` in apps/desktop/src/lib/tool-render-class.ts must stay in sync with this # set (test_gateway_lifecycle_set_covers_desktop_card_tools pins the direction that matters). _TOOL_LIFECYCLE_UI_TOOLS = frozenset({ "clarify", "manage_connections", "setup_mcp", "image_generate", "manage_catalog", "delegate_task", # File edits are the turn's deliverable — the diff card the user reviews. "edit_file", "patch", "write_file", }) def _tool_lifecycle_required_for_ui(name: str) -> bool: """Interactive UI / card surfaces, not optional chrome. Desktop renders these from the tool-call part itself, so suppressing the lifecycle hides the turn's deliverable entirely (`isCardTool` / `isFileEditTool` in apps/desktop/src/lib/tool-render-class.ts must stay in sync with `_TOOL_LIFECYCLE_UI_TOOLS`): clarify / connection cards are consent surfaces, image_generate / manage_catalog / delegate_task draw the thing the user asked for, and file edits are the diff the user reviews. The start and complete guards both consult this set, so a card's `tool.complete` can never arrive without its `tool.start`. """ return name in _TOOL_LIFECYCLE_UI_TOOLS def _restart_slash_worker(sid: str, session: dict): # Close the slash-worker subprocess as part of finalize itself, not just in the callers. # Defense-in-depth: every session-end path goes through _finalize_session (it's the single # ``_finalized``-guarded chokepoint), so folding worker cleanup in here means a future code path that # calls _finalize_session directly — without the surrounding _teardown_session / _shutdown_sessions # worker.close() — can't reintroduce the #38095 leak. Idempotent: _SlashWorker.close() is # poll()-guarded, so the explicit close() still in those callers is harmless. 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"), provider=getattr(session.get("agent"), "provider", None) or None) except Exception: session["slash_worker"] = None return # Store-iff-still-mapped: the post-turn restart races a close_on_disconnect reap (a bare store would orphan it). _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: from agent.context_breakdown import context_usage_fields usage.update(context_usage_fields(comp)) usage["compressions"] = getattr(comp, "compression_count", 0) or 0 # Cache-hit ratio + rolling latency/tps (CLI status-bar parity). 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): # Mirrors the classic CLI bar (cli.py _get_status_bar_snapshot / PR #98250): hit = # session_cache_read_tokens / session_prompt_tokens (CanonicalUsage.prompt_tokens = input + # cache_read + cache_write) latency/tps read the deque(maxlen=10) history maintained per API call in # agent/conversation_loop.py. _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 []) if _n := min(len(_lhist), len(_ohist)): _total_lat = sum(_lhist[-_n:]) _avg_vel = (sum(_ohist[-_n:]) / _total_lat) if _total_lat > 0 else None for _key, _val in (("avg_latency_s", _total_lat / _n), ("avg_tps", _avg_vel)): if _val is not None and _val == _val and 0 < _val < 1e6: # guard NaN/negative/absurd provider timings usage[_key] = round(float(_val), 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: """Warning or '' (``no-key-required`` is a valid sentinel for keyless custom providers).""" 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: """Warn on bare YAML keys (`agent:` → None, silently dropping nested settings) and an unknown ``display.personality``.""" 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: with contextlib.suppress(Exception): from hermes_cli.profiles import get_active_profile_name return get_active_profile_name() or "default" return "default" # Monotonic GUI<->backend contract version: the desktop refuses a backend reporting less (or none) with a # one-click "update to align" prompt. The desktop also warns in the reverse direction: a backend reporting # MORE than the GUI's required value means the GUI build predates this backend (e.g. a long-running app # across a backend update) and should be updated. Bump whenever the desktop's backend contract changes. # v2 file.attach; v3 approvals.mode RPCs + session.info reconciliation; v4 session.create fast=false = explicit normal tier; # v5 ws_max_size >16 MiB file.attach frames; v6 plugins.manage rows carry the canonical registry key; # v7 blocking prompts are JSON-RPC server->client requests (`srq-` frames, `open_requests` replay) — a v6 # backend still emits `.request` notifications the renderer no longer listens for. DESKTOP_BACKEND_CONTRACT = 8 def _session_usage_snapshot(session: dict | None) -> dict: sess = session or {} mirror_usage = _metadata_mirror(session).get("usage") if sess.get("agent") is not None and not (sess.get("_compute_host_active") and isinstance(mirror_usage, dict)): return _get_usage(sess["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) so TUI status, desktop status bar and ``/status`` name the workspace identically. Only explicit named projects resolve.""" 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) return None if project is None else { "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 (desktop keeps the elapsed timer across switches).""" inflight = (session or {}).get("inflight_turn") return float(inflight["started_at"]) if isinstance(inflight, dict) and inflight.get("started_at") else None def _live_session_identity(session: dict) -> tuple[str, str]: """``(model, provider)`` the live session actually runs — the same precedence ``_session_info`` reports: a switch queued mid-turn, the metadata mirror, the built agent, the composer override a deferred record carries. The profile default is the LAST resort, never the answer for a chat that made its own pick.""" pending = session.get("pending_model_switch") or {} mirror = _metadata_mirror(session) agent = session.get("agent") override = session.get("model_override") or {} model = (str(pending.get("display_model") or "").strip() or mirror.get("model") or getattr(agent, "model", "") or override.get("model") or _session_default_model(session)) provider = (str(pending.get("display_provider") or "").strip() or mirror.get("provider") or getattr(agent, "provider", "") or override.get("provider") or "") return str(model), str(provider or "") def _fast_tier_applies(agent, model: str, provider: str, *, route_known: bool, tier: str | None = None) -> bool: """Whether a priority tier reaches this session's route. Every request builder asks the same gate, so a profile-wide ``service_tier: fast`` sends nothing to a local server or a proxy, and the session must not report Fast there either. ``route_known`` is False while a switch is pending: the agent's base URL still belongs to the old route.""" from hermes_cli.models import resolve_fast_mode_overrides base_url = None if route_known and agent is not None: if getattr(agent, "api_mode", None) == "anthropic_messages": base_url = getattr(agent, "_anthropic_base_url", None) base_url = base_url or getattr(agent, "base_url", None) try: return resolve_fast_mode_overrides(model, provider=provider or None, base_url=base_url, tier=tier) is not None except Exception: return False 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 loses "thinking off" after turn 1. 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 ORs the same three sources check_all_command_guards() does (approvals.mode=off, the process # --yolo env, the per-session flag): the session flag alone would show "off" while config auto-approves. 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() yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or approval_mode == "off" except Exception: yolo, approval_mode = False, "manual" # A switch queued mid-turn applies at next turn start (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() provider = mirror.get("provider", getattr(agent, "provider", "")) if provider == "custom" and "provider" not in mirror and agent is not None: # Clients reuse this identity for new chats without carrying the endpoint or key. # Broadcast/resume callers need not be bound to this session's profile. with _profile_build_scope(sess.get("profile_home") or _hermes_home): provider = _runtime_model_config(agent).get("provider", provider) model = pending_model or mirror.get("model", getattr(agent, "model", "")) # The level the route's entry clamp actually sends (== reasoning_effort when verbatim), so the # Desktop can say "ultra sends max on this route" like `/reasoning` does instead of presenting a # Hermes-internal step (#61634) as a wire level the route does not have. reasoning_effort_wire = "" if reasoning_effort and reasoning_effort != "none": reasoning_effort_wire = str(clamp_effort(reasoning_effort, route_supported_efforts( pending_provider or provider, model, getattr(agent, "api_mode", None))) or "") info: dict = { "model": model, "provider": pending_provider or provider, "reasoning_effort": reasoning_effort, "reasoning_effort_wire": reasoning_effort_wire, "service_tier": service_tier, "fast": service_tier in STATIC_TIERS and _fast_tier_applies(agent, model, pending_provider or provider, route_known=not pending_provider, tier=service_tier), "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_probe.branch(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": profile_name_for_home(sess.get("profile_home")) or _current_profile_name(), } with contextlib.suppress(Exception): from hermes_cli import __release_date__ from hermes_cli.version_info import get_version_info info.update(version=get_version_info().base_version, 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() info["mcp_servers"] = [] with contextlib.suppress(Exception): from tools.mcp_tool_discovery import get_mcp_status info["mcp_servers"] = get_mcp_status() 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 # Two assignments (not one info.update): if recommended_update_command() raises, # update_behind must still be reported, as on main. 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, so ``build_tool_label`` here would stutter ("Running Running …") and leak into the desktop's ``args.context``.""" with contextlib.suppress(Exception): from agent.display import build_tool_preview return build_tool_preview(name, args, max_len=80) or "" 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. Only THIS process; a spawned 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's tools would be missing all session): a daemon joins discovery, rebuilds like ``/reload-mcp`` and re-emits ``session.info``. Only pre-first-turn (nothing cached to invalidate); afterwards 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: if not join_mcp_discovery(timeout=30.0): # a server still not connected after this is genuinely slow/dead 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_agent 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. Provider-only fallback entries are skipped so the unavailable primary model can never leak into a different runtime.""" 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 []: fb_provider = str(entry.get("provider") or "").strip() if isinstance(entry, dict) else "" fb_model = str(entry.get("model") or "").strip() if isinstance(entry, dict) else "" if not fb_provider or not fb_model: continue try: from hermes_cli.fallback_config import effective_runtime_provider, resolve_entry_api_key fb_kwargs: dict = {"requested": fb_provider, "target_model": fb_model, **({"explicit_base_url": entry["base_url"]} if entry.get("base_url") else {})} if fb_api_key := resolve_entry_api_key(entry): fb_kwargs["explicit_api_key"] = fb_api_key runtime = resolve_runtime_provider(**fb_kwargs) # Named custom entries resolve to the bare "custom" billing class; keep the configured # identity so the session/UI shows the provider name, matching the manual-switch path (#98739). runtime["provider"] = effective_runtime_provider(entry, runtime) from hermes_cli.auth import primary_failure_wording logging.getLogger(__name__).warning( "Primary %s (%s), falling back to %s model %s", primary_failure_wording(primary_exc)[0], 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 (/model switch or a resumed row's persisted runtime) wins over global config/env. Older rows stored the resolved provider "custom" (no named entry matches) — recover the identity from the persisted base_url or the rebuild fails "No LLM provider configured". Persisted base_url/api_key/api_mode are honored only for the original runtime, never a fallback.""" 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 base_url to the direct-alias branch so pool/env credentials resolve. resolve_kwargs["explicit_base_url"] = override_base_url resolve_kwargs.update(requested=requested_provider, target_model=model or None) overrides = {k: model_override.get(k) for k in ("base_url", "api_key", "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) if resolution.used_fallback: if not resolution.selected_model: raise RuntimeError("Auth fallback resolved without a model") # Same pre-agent switch the messaging gateway surfaces (#74349); _make_agent pops it onto the # agent's one-shot notice so the TUI/Desktop user sees which provider actually answered. from hermes_cli.fallback_config import pre_agent_fallback_notice # requested_provider=None means resolve_runtime_provider read the persisted config provider; # ``model: `` (string shorthand) names no provider. cfg_model = _load_cfg().get("model") primary_provider = requested_provider or (cfg_model.get("provider") if isinstance(cfg_model, dict) else None) resolution.runtime["_fallback_notice"] = pre_agent_fallback_notice( primary_provider, model, resolution.runtime.get("provider"), resolution.selected_model) return resolution.selected_model, resolution.runtime if resolution.runtime.get("source") == "local-runtime": # Live supervisor beat any persisted loopback URL for this identity. overrides.pop("base_url", None) resolution.runtime.update({k: v for k, v in overrides.items() if v}) if any(overrides.values()): _rederive_per_model_route(model, resolution.runtime) return model, resolution.runtime def _rederive_per_model_route(model: str, runtime: dict) -> None: """A row's persisted api_mode/base_url were written for whichever model the session last ran. Providers that pick the wire per model (OpenCode Zen/Go, Copilot, Nous) must re-derive both from the target model, or a resumed opencode-go session keeps a MiniMax-era anthropic_messages route (and its /v1-stripped or other-family relay URL) for a chat_completions model like deepseek-v4-flash-vision-exp (#96066).""" from hermes_cli.model_switch import model_derived_api_mode from hermes_cli.models import normalize_opencode_base_url provider = str(runtime.get("requested_provider") or runtime.get("provider") or "") api_mode = model_derived_api_mode(provider, model) if api_mode is None: return runtime["api_mode"] = api_mode runtime["base_url"] = normalize_opencode_base_url(provider, api_mode, runtime.get("base_url")) def _startup_system_prompt(cfg: dict, task_id: str) -> str: """Config ephemeral system prompt + 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, format_missing_skills skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt(startup_skills, task_id=task_id) if missing_skills: if not loaded_skills: raise ValueError(format_missing_skills(missing_skills)) logger.warning("Skipping %s. Continuing with: %s. List available skills with `hermes skills list`.", format_missing_skills(missing_skills), ", ".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 _transport_auth_user_id(transport) -> str | None: """``:`` the WS-upgrade credential authenticated for ``transport``, or None for the legacy token, stdio and the PTY child's server-internal credential. The prefix keeps a basic-auth ``alice`` and an OIDC ``alice`` apart.""" identity = getattr(transport, "auth_identity", None) if _methods_browser_control._is_authenticated_identity(identity): return f"{str(identity['provider']).strip()}:{str(identity['user_id']).strip()}" return None def _session_auth_user_id(session: dict | None) -> str | None: """The login ``session`` was created under, stamped on the record as ``auth_user_id``. A second window turns the transport slot into a FanoutTransport, which names no login, so only a record without the slot reads its transport.""" session = session or {} if "auth_user_id" in session: return session["auth_user_id"] return _transport_auth_user_id(session.get("transport")) 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, cwd_override: str | None = None, auth_user_id: str | 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 daemon thread (a dead server can't freeze the shell); the agent snapshots its tool # list once, so briefly wait for in-flight discovery. Dashboard /api/ws uses mcp_startup; TUI stdio uses entry. 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() # Load hooks alongside the same profile config used to construct this agent. from agent.shell_hooks import register_from_config register_from_config(cfg) system_prompt = _startup_system_prompt(cfg, session_id or key) model, runtime = _resolve_agent_model_runtime(model_override, provider_override) fallback_notice = runtime.pop("_fallback_notice", None) _pr = _load_provider_routing() platform = _resolve_agent_platform(platform_override) ignore_rules = is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")) with _sessions_lock: session = _sessions.get(sid) agent = AIAgent( model=model, max_iterations=_cfg_max_turns(cfg, 500), provider=runtime.get("provider"), requested_provider=runtime.get("requested_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), disabled_toolsets=_load_disabled_toolsets(), # 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, cwd=cwd_override, # The dashboard login identity reaches memory providers as the runtime user, like a gateway user id. # Builds that run before the record exists (branch, eager resume, compute host) pass it explicitly. user_id=auth_user_id if auth_user_id is not None else _session_auth_user_id(session), 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(), # The resolved provider's request body (a custom entry's extra_body), as the CLI/cron/gateway pass it. request_overrides=runtime.get("request_overrides"), prefill_messages=_load_prefill_messages() or None, **_agent_cbs(sid)) if context_cwd_is_launch_artifact is None: context_cwd_is_launch_artifact = _context_cwd_is_launch_artifact(session) agent._context_cwd_is_launch_artifact = bool(context_cwd_is_launch_artifact) if fallback_notice: # Emitted once on the first successful reply via _emit_pending_fallback_notice -> status_callback. agent._pending_fallback_notice = fallback_notice return agent def _hydrate_session_cwd(sid: str, key: str, session_db, profile_home: str | None) -> None: """Adopt the stored row's cwd and fill missing Git metadata, or persist a fresh cwd.""" owns_db, db = False, session_db if db is None and not profile_home: db = _get_db() elif db is None: try: db = _open_profile_session_db(profile_home) owns_db = True except Exception: # FAIL CLOSED (as the deferred-build bind): a named-profile session must never touch the launch # state.db — skip 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) try: if db is not None: row = db.get_session(key) if hasattr(db, "get_session") else None if row and _resumable_stored_cwd(row.get("cwd"), profile_home): # An ssh session's stored cwd is its workspace: explicit, so the remote terminal uses it instead of # the profile's ~. Other backends keep main's semantics (resolved outside the sessions lock: I/O). remote = _cwd_is_remote(profile_home) with _sessions_lock: if sid in _sessions: _sessions[sid]["cwd"] = row["cwd"] if remote: _sessions[sid]["explicit_cwd"] = True # Lazy desktop rows already carry their explicitly chosen cwd, so they never reach the fresh-cwd # branch below. Claim a generation before probing to keep an older probe from overwriting a later # workspace move; complete rows do not need another probe on every resume. if (not row.get("git_branch") or not row.get("git_repo_root")) and hasattr( db, "update_session_cwd" ): try: _persist_session_cwd_and_schedule_git_meta(_sessions[sid], row["cwd"], db=db) except Exception: logger.debug("failed to enrich resumed session git metadata", exc_info=True) elif not (row and row.get("cwd")) and hasattr(db, "update_session_cwd") and not _is_remote_launch_cwd( _sessions.get(sid) ): # A stored cwd that was set aside (Hermes's own host tree) stays as stored: only an empty row is filled. try: _persist_session_cwd_and_schedule_git_meta(_sessions[sid], _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); SessionBranch copies the parent's (same state.db). "profile_home": profile_home, # In-session /model switch, honored on rebuild (/new, resume) — never leaks to siblings via 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, "auth_user_id": _transport_auth_user_id(current_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 new_session_id() # Server-minted session keys are ``%Y%m%d_%H%M%S_`` + 6 hex chars (see # ``_new_session_key``). session.resume uses this shape as the fail-closed gate # for materializing a row for a minted-but-never-persisted key: only keys the # server itself could have produced qualify — arbitrary strings and 8-hex # runtime session ids (``uuid4().hex[:8]``) are rejected. _MINTED_SESSION_KEY_RE = re.compile(r"^\d{8}_\d{6}_[0-9a-f]{6}$") def _is_server_minted_key(value: str | None) -> bool: return bool(value and _MINTED_SESSION_KEY_RE.fullmatch(value)) def _any_live_session_claims_key(target: str) -> bool: """True if any live registry record claims this stored key (any profile). Fail-closed gate for minted-key materialization: a key claimed by a live session — even one scoped to a different profile — is owned, so an unscoped resume must not mint a phantom row in the launch store (#93296 cross-profile rule: routing guesses are forbidden). """ for record in list(_sessions.values()): if not isinstance(record, dict): continue if str(record.get("session_key") or "") == target: return True return False 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.""" return { "cwd": cwd, "branch": git_probe.branch(cwd), "project": _project_info_for_cwd(cwd), "model": model or _session_default_model({"profile_home": _profile_home(profile)}), "tools": {}, "skills": {}, "lazy": True, "desktop_contract": DESKTOP_BACKEND_CONTRACT, "profile_name": _response_profile_name(profile), **({"provider": provider} if provider else {}), } 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, "auth_user_id": _transport_auth_user_id(current_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.""" # A live runtime of the same stored id under ANOTHER profile is not a winner to reuse. # See #100029. 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 reap is cancelled by the guarded reuse (_reattach_refusal), not here: a rejected # reattach must leave an in-flight orphan interrupt polling. 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: cancel their orphan-reap Timer and pop them here (under the caller's _session_resume_lock); the caller finalizes after release.""" 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) if (popped := _pop_session_by_id(old_sid)) is not None: stale.append((old_sid, popped)) return stale def _finalize_superseded_runtimes(stale: list[tuple[str, dict]]) -> None: """end_reason ``superseded_by_resume`` is deliberately NOT in _RECLAIM_END_REASONS (no ``session.reclaimed`` broadcast → no reap->broadcast->resume loop) but IN _RECOVERABLE_END_REASONS (Bot Chat resurrection 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(): if (session := _sessions.get(sid)) 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, *, model_history_only: bool = False) -> tuple[list, list, list]: """(raw_history, display_history, ancestor_prefix) for a cold resume. The full lineage is materialized only while it fits sessions.max_resume_messages (the transcript is REST-paginated), else the tip alone.""" from hermes_state import SessionResumeTooLargeError if model_history_only: raw_history = db.get_messages_as_conversation( stored_id, repair_alternation=True, include_row_ids=True) return raw_history, [], [] 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, model_history_only: 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"}) # Read-only mount (#85303): hydration is a read; an ended row stays ended — # the first real turn (prompt.submit) reopens it. But a restart discarded the # busy-queue, so retire never-drained accept rows (#125577) before the read — # with #128508 the reopen (and its retire) no longer runs on this path. # Best-effort like the resume guard: a handle without the method skips it. retire = getattr(db, "retire_undrained_queue_rows", None) if callable(retire): retire(stored_id) raw_history, display_history, prefix = _load_resume_transcript( db, stored_id, model_history_only=model_history_only) # Display keeps the full transcript; the model-fed history uses the # same canonicalization as gateway resume and the send path. history = canonicalize_replay_history(raw_history) if _sessions.get(sid) is not session: return with session["history_lock"]: session.update(history=history, display_history_prefix=prefix, resume_hydrating=False) if not model_history_only: session["resume_message_count"] = len(display_history) # Deferred resumes answered before the transcript existed; cache the derived todo snapshot now. 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": session["resume_message_count"], "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 = resume_failed_message(exc) session.update(resume_hydrating=False, resume_history_error=message, 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 if (lease := (discarded or {}).get("active_session_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: """Method of the server→client request *sid* is blocked on ("" when none).""" from tui_gateway import server_requests return server_requests.pending_kind(sid) 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 _session_live_title(session: dict, key: str) -> str: title = str(session.get("pending_title") or "").strip() with contextlib.suppress(Exception), _session_db(session) as db: title = str(db.get_session_title(key) or title or "").strip() if db is not None else title 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 = next((" ".join(text.split())[:160] for msg in reversed(history) if (text := _content_display_text(msg.get("content", msg.get("text", ""))).strip())), "") 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: return str(getattr(session.get("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). # Profile-aware callers pass the home they resolved; the match must then be on (profile_home, # session_key). See #100029. for sid, session in list(_sessions.items()): if (not session.get("_finalized") and _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 launch dir (wrong project in the desktop Files pane). `branch` is # always emitted ("" outside git) so a stale label clears; `desktop_contract` missing reads as "out of date". # Reporting `_default_session_cwd()` here told a lazily-resumed session's client that its workspace was # wherever the gateway process happened to start, so the desktop Files pane painted the wrong project # even after the renderer rebound correctly (#71254). `branch` is always emitted ("" outside a git repo) # so a client can clear a stale label instead of retaining it — the same contract `_lazy_session_info` # above already follows. cwd = _session_cwd(session) return { "cwd": cwd, "branch": git_probe.branch(cwd), "project": _project_info_for_cwd(cwd), "lazy": True, "model": _session_default_model(session), "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) but can lag by a flush; ``in_memory`` is the recency authority but the collapsed *model* projection. Keep the DB display as base, append only the in-memory tail past the last DB row's ``(role, text)`` anchor — the verification answer survives a warm switch AND a not-yet-flushed live turn is kept.""" 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 = max((idx for idx, msg in enumerate(in_memory) if isinstance(msg, dict) and _key(msg) == anchor), default=-1) 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 payloads agree) reconciled with the in-memory tail; in-memory when the DB 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) # See #92080. 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, inline_images: bool = True) -> dict: with session["history_lock"]: if cols is not None: session["cols"] = cols if transport is not None: _rebind_live_transport(sid, session, transport) if touch: # #84417: do not re-fire the live turn's original user text from a stale server-queue # self-duplicate after settle. session["last_active"] = time.time() in_memory_history = list(session.get("display_history_prefix") or []) + list(session.get("history") or []) inflight, queued = _inflight_snapshot(session), _queued_prompt_snapshot(session) running, turn_started_at = bool(session.get("running")), _turn_started_at(session) # Persisted display lineage via the session's profile-aware DB (not the launch ``_get_db()``), read # outside the history lock (the DB has its own). ``omit_messages`` skips the read (fast path). if omit_messages: history = in_memory_history else: with _session_db(session) as db: history = _live_visible_history(session, db, in_memory_history) # message_count follows _resume_response: the stored size when messages are omitted, else the wire count # (a hidden seed row is in ``history`` but never on the wire). messages = ([] if omit_messages else _history_to_messages(history, profile_home=session.get("profile_home"), image_urls=inline_images)) payload = { "info": _fallback_session_info(session), "message_count": len(history) if omit_messages else len(messages), "messages": messages, "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), } for key, value in (("inflight", inflight), ("queued", queued), ("pending_approval", _pending_approval_request_payload(str(session.get("session_key") or ""))), ("open_requests", _open_requests(sid)), ("pending_connection", _pending_connection_request_payload(sid))): if value: payload[key] = value 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 runtime.""" if agent is None: return None runtime: dict = {} # ``session_id`` rides along so a session-bound ``llm.oneshot`` (title, approval) on an OpenCode # route sends the conversation's ``x-opencode-session`` like the main turn does (#112717). for field in ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode", "session_id"): 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. _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.""" with contextlib.suppress(Exception): stat = spritesheet.stat() return f"{stat.st_mtime_ns}:{stat.st_size}" return "0:0" 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.""" with contextlib.suppress(Exception): 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 blank = lambda col: render._frame_is_blank(image.crop((col * W, top, col * W + W, top + H))) out[name] = next((col for col in range(cols) if blank(col)), cols) # frames before the first blank cell return out return {} def _pet_cfg() -> dict: """``display.pet`` from the canonical config ({} on any failure).""" with contextlib.suppress(Exception): from hermes_cli.config import load_config display = load_config().get("display") pet = display.get("pet") if isinstance(display, dict) else None return pet if isinstance(pet, dict) else {} return {} def _pet_config_scale() -> float: """Configured ``display.pet.scale`` (or the engine default), never raises.""" from agent.pet import constants with contextlib.suppress(Exception): return float(_pet_cfg().get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) 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 try: stat = pet.spritesheet.stat() cache_key = (str(pet.spritesheet), stat.st_mtime_ns, stat.st_size, pet.slug, pet.display_name, round(scale, 4)) except Exception: # noqa: BLE001 cache_key = None 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) try: # real (padding-trimmed) frame count per state; {} → the canvas uses the static framesPerState from agent.pet import render frames_by_state = render.state_frame_counts(str(pet.spritesheet)) except Exception: # noqa: BLE001 frames_by_state = {} raw = pet.spritesheet.read_bytes() mime = "image/png" if pet.spritesheet.suffix.lower() == ".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": frames_by_state, "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) pet = store.resolve_active_pet(str(pet_cfg.get("slug", "") or "")) if enabled else None return enabled, pet, float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) def _pet_state_rows(spritesheet) -> list[str]: """Row taxonomy for the concrete sheet (legacy 8-row or current 9-row atlas), in the renderer's `PetState` names.""" from agent.pet import constants with contextlib.suppress(Exception): 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)) 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 (c for c in root.iterdir() if c.is_dir() and now - c.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) img.save(buf := io.BytesIO(), 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") if (ext := _PET_REFERENCE_MIME_EXT.get(match.group(1).lower())) is None: raise ValueError("unsupported reference image type") payload = "".join(match.group(2).split()) if (len(payload) * 3) // 4 > _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}").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: the TUI owns subagent state (/agents overlay; registry in tools/delegate_tool), posts # the final tree on turn-complete and /replay fetches by session_id + filename. Layout: spawn-trees//.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): d = _spawn_trees_root() / ("".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) or "unknown") d.mkdir(parents=True, exist_ok=True) return d # Per-session append-only JSONL index so `spawn_tree.list` needn't read every snapshot; a cache — a lost # line just means list() falls back to a directory scan. # Read by `spawn_tree.list` so scanning doesn't require reading every full snapshot file (Copilot review on # #14045). One JSON object per line. _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]: out: list[dict] = [] try: with (session_dir / _SPAWN_TREE_INDEX).open("r", encoding="utf-8-sig") 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 the 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. The caller must set the Event AND join the thread before ``message.complete``: a late tick would roll the final usage back.""" stop = threading.Event() # Dedup baseline sampled BEFORE the thread starts (the client has the turn-start values); a late-scheduled # thread would otherwise absorb the first counter growth and never emit it. baseline: dict | None = None with contextlib.suppress(Exception): baseline = _get_usage(agent) 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: tools & system ────────────────────────────────────────── def _session_processes(session: dict) -> list: """Background processes owned by this session (registry session_key match).""" # Drain completion notifications that arrived during this turn. The background poller handles # between-turn delivery; this is the safety net for events that arrived mid-turn. Ownership filter # (#42674, #35652): a turn finishing in session B must not consume an event that belongs to session A. # The registry requeues every addressed event this session cannot positively claim; the poller then # delivers it to a live owner or drops an orphan. 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 not None and str(getattr(proc, "session_key", "") or "") == key: entry["output_tail"] = (proc.output_buffer or "")[-4000:] # the 200-char list preview is too thin for the viewer 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 → the 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 until the hash is stable. _MCP_RELOAD_MAX_PASSES = 3 def _compute_mcp_rev() -> str: """Hash of mcp_servers (definitions — omitting it meant an edited server never bumped the rev) + mcp + tools. ``config.get mtime`` ships it so cosmetic writes don't reload; ``reload.mcp`` coalesces on it. "" = unknown.""" with contextlib.suppress(Exception): cfg = _load_cfg() rev_src = json.dumps({k: cfg.get(k) for k in ("mcp", "mcp_servers", "tools")}, sort_keys=True, default=str) return hashlib.sha1(rev_src.encode()).hexdigest()[:12] 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 save_config_value("approvals.mcp_reload_confirm", False) except Exception as _exc: logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc) return _ok(rid, {"status": "reloaded", "loaded_rev": _mcp_reload_loaded_rev, **({"coalesced": True} if coalesced else {})}) _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 catalog: activity count (use + view + patch) and "hub" / "bundled" / "local" (``/api/skills`` ``provenance``, "local" spelled "agent"). Failure → 0 / "local".""" try: from tools.skill_usage import ( _read_bundled_names, _read_hub_installed_names, activity_count, load_usage) records, bundled, hub = load_usage(), _read_bundled_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: with contextlib.suppress(Exception): return activity_count(records.get(name) or {}) 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, registry_command_names: frozenset[str] | None = None) -> list[dict]: """Registry commands keep their order; only skills reorder: fuzzy ``score_of`` first, then most-used, then A-Z. The limit is spent PER KIND (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 — nothing pruned, only reordered. While browsing, only names in ``registry_command_names`` (default ``GATEWAY_KNOWN_COMMANDS``) skip the cap: plugin-registered commands are also ``kind != "skill"`` but unbounded, so they stay capped like skills.""" 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] skills.sort(key=lambda item: ( *(() if score_of is None else (score_of(item),)), -usage(name_of(item)), name_of(item))) if browsing: if registry_command_names is None: from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS registry_command_names = GATEWAY_KNOWN_COMMANDS fixed = [c for c in commands if name_of(c) in registry_command_names] other = [c for c in commands if name_of(c) not in registry_command_names] ranked_commands = fixed + other[:_SLASH_COMPLETION_LIMIT] else: ranked_commands = commands[:_SLASH_COMPLETION_LIMIT] return ranked_commands + 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: with contextlib.suppress(Exception): from hermes_cli.commands import resolve_command return r.name if (r := resolve_command(name)) else name return name _paste_counter = 0 # mcp.servers.* handlers (methods_tools) resolve this BARE through this namespace. from .mcp_rpc_helpers import summarize_server as _mcp_summarize_server # noqa: E402, F401 # ── 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, plugin_inject as _plugin_inject, rpc_dispatch as _rpc_dispatch, 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, session_transports as _session_transports, 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, methods_session_foreign as _methods_session_foreign, methods_session_control as _methods_session_control, methods_subagents as _methods_subagents, methods_vault as _methods_vault, methods_free_tier as _methods_free_tier, methods_connectors as _methods_connectors, methods_connectors_account as _methods_connectors_account, methods_display as _methods_display, methods_display_watch as _methods_display_watch, methods_onboarding as _methods_onboarding, methods_i18n as _methods_i18n, methods_shared_metrics as _methods_shared_metrics) for _m in ( _session_transports, _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, _plugin_inject, _rpc_dispatch, _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, _methods_session_foreign, _methods_session_control, _methods_subagents, _methods_vault, _methods_free_tier, _methods_connectors, _methods_connectors_account, _methods_display, _methods_display_watch, _methods_onboarding, _methods_i18n, _methods_shared_metrics): _m.register(sys.modules[__name__]) del _m